browser-styles.client.spec.ts 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. /**
  2. * WorkspaceBrowser spacing contract, asserted against the CSS text on disk:
  3. * row fills share the shell's trailing inset, the stable scrollbar counts
  4. * inside it, and flat, grouped, and search views keep their intended rhythm.
  5. */
  6. import { readFileSync } from 'node:fs'
  7. import { fileURLToPath } from 'node:url'
  8. import { describe, expect, it } from 'vitest'
  9. const css = readFileSync(fileURLToPath(new URL('../src/client/rows/WorkspaceBrowser.module.css', import.meta.url)), 'utf8')
  10. const rowsCss = readFileSync(fileURLToPath(new URL('../src/client/rows/Rows.module.css', import.meta.url)), 'utf8')
  11. /**
  12. * Declarations of one selector rule, keyed by property with whitespace collapsed.
  13. * Declaration order and trailing semicolons are normalized away.
  14. * @param selector - one exact selector, including a leading dot for local classes.
  15. * @returns the rule's declarations, or undefined when no such rule exists.
  16. */
  17. function declarationsFrom(source: string, selector: string): Map<string, string> | undefined {
  18. const withoutComments = source.replace(/\/\*[\s\S]*?\*\//g, ' ')
  19. const found = new Map<string, string>()
  20. for (const [, selectorList = '', body = ''] of withoutComments.matchAll(/([^{}]+)\{([^{}]*)\}/g)) {
  21. if (!selectorList.split(',').map(value => value.trim()).includes(selector)) continue
  22. for (const part of body.split(';')) {
  23. const colon = part.indexOf(':')
  24. if (colon === -1) continue
  25. found.set(part.slice(0, colon).trim(), part.slice(colon + 1).trim().replace(/\s+/g, ' '))
  26. }
  27. }
  28. return found.size === 0 ? undefined : found
  29. }
  30. const declarations = (selector: string): Map<string, string> | undefined => declarationsFrom(css, selector)
  31. const rowDeclarations = (selector: string): Map<string, string> | undefined => declarationsFrom(rowsCss, selector)
  32. describe('WorkspaceBrowser.module.css list', () => {
  33. const root = declarations('.root')
  34. const listArea = declarations('.listArea')
  35. const list = declarations('.list')
  36. it('is the scrolling region', () => {
  37. expect(list).toBeDefined()
  38. expect(list!.get('overflow-y')).toBe('auto')
  39. })
  40. it('counts the themed scrollbar inside the shell trailing inset', () => {
  41. expect(root?.get('--dsh-session-list-edge-inset')).toBe('var(--dsh-sidebar-inline-padding)')
  42. expect(root?.get('--dsh-session-list-scrollbar-width')).toBe('8px')
  43. expect(root?.get('--dsh-session-list-scrollbar-offset')).toBe('2px')
  44. expect(root?.get('padding-right')).toBe('var(--dsh-session-list-edge-inset)')
  45. expect(listArea?.get('margin-left')).toBe('-4px')
  46. expect(listArea?.get('padding-left')).toBe('4px')
  47. expect(listArea?.get('margin-right')).toBe('calc(-1 * var(--dsh-session-list-edge-inset))')
  48. expect(declarations('.fade')?.get('right')).toBe('var(--dsh-session-list-edge-inset)')
  49. expect(list?.get('margin-right')).toBe('var(--dsh-session-list-scrollbar-offset)')
  50. expect(list?.get('margin-left')).toBe('-4px')
  51. expect(list?.get('padding-left')).toBe('4px')
  52. expect(list?.get('padding-right')).toBe([
  53. 'calc(',
  54. 'var(--dsh-session-list-edge-inset)',
  55. '- var(--dsh-session-list-scrollbar-width)',
  56. '- var(--dsh-session-list-scrollbar-offset)',
  57. ')',
  58. ].join(' '))
  59. expect(declarations('.list::-webkit-scrollbar')).toBeUndefined()
  60. })
  61. it('reserves the scrollbar whether or not the list overflows', () => {
  62. expect(list!.get('scrollbar-gutter')).toBe('stable')
  63. })
  64. it('keeps 2px between rows and 4px between workspace groups', () => {
  65. expect(declarations('.flatList > * + *')?.get('margin-top')).toBe('2px')
  66. expect(declarations(".searchTree > [role='treeitem'] + [role='treeitem']")?.get('margin-top')).toBe('2px')
  67. expect(declarations('.groupSection > * + *')?.get('margin-top')).toBe('2px')
  68. expect(declarations('.groupSection + .groupSection')?.get('margin-top')).toBe('4px')
  69. })
  70. it('draws drag targets as a leading chevron joined to the insertion line', () => {
  71. const listTopMarker = declarations('.listTopDropIndicator')
  72. const workspaceMarker = declarations('.workspaceDropBefore::before')
  73. const sessionMarker = rowDeclarations('.sessionRow.dropBefore::before')
  74. expect(listTopMarker?.get('top')).toBe('-8px')
  75. expect(listTopMarker?.get('left')).toBe('0')
  76. expect(workspaceMarker?.get('left')).toBe('0')
  77. expect(sessionMarker?.get('left')).toBe('0')
  78. for (const marker of [listTopMarker, workspaceMarker, sessionMarker]) {
  79. expect(marker?.get('height')).toBe('12px')
  80. expect(marker?.get('background')).not.toContain('radial-gradient')
  81. expect(marker?.get('background')).toContain('55deg')
  82. expect(marker?.get('background')).toContain('125deg')
  83. expect(marker?.get('background')).toContain('calc(50% - 1px) calc(50% + 1px)')
  84. expect(marker?.get('background')).toContain('0 0 / 5px 7px')
  85. expect(marker?.get('background')).toContain('0 5px / 5px 7px')
  86. expect(marker?.get('background')).toContain('4px 5px / calc(100% - 4px) 2px')
  87. }
  88. })
  89. it('keeps the compact fade, overflow control, search field, and row heights', () => {
  90. expect(declarations('.fade')?.get('height')).toBe('24px')
  91. expect(declarations('.sessionOverflowButton')?.get('height')).toBe('28px')
  92. expect(declarations('.searchExpanded')?.get('height')).toBe('30px')
  93. expect(rowDeclarations('.projectRow')?.get('height')).toBe('34px')
  94. expect(rowDeclarations('.sessionRow')?.get('height')).toBe('32px')
  95. expect(rowDeclarations('.flatSessionRowWithoutStatus .title')?.get('margin-left')).toBe('0')
  96. expect(rowDeclarations('.searchResultRow')?.get('min-height')).toBe('48px')
  97. expect(rowDeclarations('.sessionRow.selected')?.get('background'))
  98. .toBe('var(--dsw-alias-interactive-bg-hover)')
  99. })
  100. it('reveals a clipped session title by scrolling it on row hover', () => {
  101. // Smooth versus reduced motion is pinned as a computed style in
  102. // apps/web/tests/sidebar-title-hover-scroll.e2e.ts: this helper merges
  103. // same-selector rules across media queries, so the reduce override would
  104. // mask the smooth declaration here.
  105. expect(rowDeclarations('.sessionRow .title')?.get('flex')).toBe('1')
  106. expect(rowDeclarations('.sessionRow:hover .title')?.get('text-overflow')).toBe('clip')
  107. })
  108. it('pins both rail controls to the shared left anchor during the column slide', () => {
  109. expect(declarations('.rail .sectionHeader')?.get('justify-content')).toBe('flex-start')
  110. expect(declarations('.rail .iconButton')?.get('width')).toBe('36px')
  111. expect(declarations('.rail .search')?.get('width')).toBe('36px')
  112. })
  113. })