styles.client.spec.ts 5.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107
  1. /**
  2. * Feedback stylesheet contract, asserted against the CSS text on disk for the
  3. * message controls and the dialog.
  4. *
  5. * A `--dsw-*` name the theme never declares fails silently, and for the
  6. * controls' sheet it failed loudly in the product: `border`, `background`, and
  7. * the primary button's fill and label each named a token that does not exist,
  8. * so every one of those declarations was invalid at computed-value time and
  9. * dropped. The note editor of the time shipped with no border and no surface,
  10. * and its Save button with neither fill nor readable label. Nothing downstream
  11. * reports this — the sheet parses, the classes attach, and the DOM snapshots
  12. * are unchanged.
  13. *
  14. * The dialog is the body-portaled Modal primitive, so nothing this package
  15. * renders enters the IconActions row's flex layout beyond the two 28px
  16. * buttons and the failure notice.
  17. */
  18. import { readdirSync, readFileSync } from 'node:fs'
  19. import { fileURLToPath } from 'node:url'
  20. import { describe, expect, it } from 'vitest'
  21. const SHEETS = ['MessageFeedbackActions', 'FeedbackDialog'] as const
  22. const sheets = Object.fromEntries(SHEETS.map(name => [name, readFileSync(
  23. fileURLToPath(new URL(`../src/client/${name}.module.css`, import.meta.url)),
  24. 'utf8',
  25. )])) as Record<(typeof SHEETS)[number], string>
  26. // The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
  27. // stay on the source plane rather than needing a build. Every theme sheet, not
  28. // just the platform tokens: font and scrollbar variables are declared in
  29. // siblings, and a gate reading one file would call their names undeclared.
  30. const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
  31. .filter(name => name.endsWith('.css'))
  32. .map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
  33. .join('\n')
  34. /**
  35. * The declarations of one top-level rule, by selector.
  36. * @param sheetName - the stylesheet containing the rule.
  37. * @param selector - the class selector to read, including its leading dot.
  38. * @returns the rule's declaration text.
  39. */
  40. function block(sheetName: (typeof SHEETS)[number], selector: string): string {
  41. const escapedSelector = selector.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
  42. const match = new RegExp(`^${escapedSelector} \\{([^}]*)\\}`, 'm').exec(sheets[sheetName])
  43. if (match === null) throw new Error(`${sheetName}.module.css has no \`${selector}\` rule`)
  44. return match[1] ?? ''
  45. }
  46. describe.each(SHEETS)('%s theme styles', (name) => {
  47. const sheet = sheets[name]
  48. it('names only theme variables the token sheet defines', () => {
  49. // The regression that motivated this file. An undeclared custom property
  50. // has no fallback and does not inherit a usable value: the entire
  51. // declaration is thrown away, so the control renders as if the line had
  52. // never been written. Every theme-variable prefix the sheets actually use,
  53. // not just `--dsw-`: a `--dsh-` name reads as a plausible sibling and would
  54. // otherwise slip past into an invalid declaration.
  55. const named = [...sheet.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
  56. // Vacuity guard: the sheet has to actually name tokens, or the filter below
  57. // is satisfied by an empty list and this test proves nothing.
  58. expect(named.length).toBeGreaterThan(3)
  59. const undeclared = [...new Set(named)].filter(token => !tokens.includes(` ${String(token)}:`))
  60. expect(undeclared).toEqual([])
  61. })
  62. it('never falls back to a literal colour', () => {
  63. // A token that resolves is never the problem; an undeclared one takes this
  64. // branch, and a literal here is a single colour for both themes.
  65. expect(sheet).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
  66. })
  67. it('closes every block, so no rule is swallowed by the one above it', () => {
  68. // A missing `}` is not a parse error: every rule after it silently becomes
  69. // part of the block above, and the controls would paint unstyled.
  70. const bare = sheet.replace(/\/\*[\s\S]*?\*\//g, '')
  71. expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
  72. })
  73. })
  74. describe('MessageFeedbackActions row styles', () => {
  75. it('slot-injected actions ride the content font-size axis like their host row', () => {
  76. // These buttons render inside ui-chat's MessageIconActions row; a fixed
  77. // 28px would leave them undersized (or overflowing) once the Settings
  78. // font size moves the row.
  79. expect(block('MessageFeedbackActions', '.action')).toMatch(/width:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
  80. expect(block('MessageFeedbackActions', '.action')).toMatch(/height:\s*calc\(28px \+ var\(--dsh-content-font-delta, 0px\)\)/)
  81. expect(block('MessageFeedbackActions', '.action svg')).toMatch(/width:\s*calc\(15px \+ var\(--dsh-content-font-delta, 0px\)\)/)
  82. })
  83. it('uses the tertiary label colour for recorded rating icons', () => {
  84. expect(block('MessageFeedbackActions', '.action[data-active]'))
  85. .toMatch(/color:\s*var\(--dsw-alias-label-tertiary\)/)
  86. })
  87. })
  88. describe('FeedbackDialog layout styles', () => {
  89. it('uses the reviewed spacing, text colours, and submit-button geometry', () => {
  90. expect(block('FeedbackDialog', '.dialog.dialog')).toMatch(/gap:\s*38px/)
  91. expect(block('FeedbackDialog', '.categories')).toMatch(/margin-top:\s*-14px/)
  92. expect(block('FeedbackDialog', '.chip')).toMatch(/color:\s*var\(--dsw-alias-label-primary\)/)
  93. expect(block('FeedbackDialog', '.detail::placeholder')).toMatch(/color:\s*var\(--dsw-alias-label-caption\)/)
  94. expect(block('FeedbackDialog', '.detail')).toMatch(/border-radius:\s*16px/)
  95. expect(block('FeedbackDialog', '.submit')).toMatch(/border-radius:\s*18px/)
  96. expect(block('FeedbackDialog', '.submit')).toMatch(/font-weight:\s*500/)
  97. })
  98. })