styles.client.spec.ts 4.9 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192
  1. /**
  2. * Models section stylesheet contract, asserted against the CSS text on disk.
  3. *
  4. * The section paints in both themes, and a `--dsw-*` name the theme does not
  5. * declare fails silently: the browser takes the `var()` fallback, so the sheet
  6. * still renders and only the dark theme looks wrong. Checking the names against
  7. * the sheet that declares them is what turns that into a test failure.
  8. */
  9. import { readdirSync, readFileSync } from 'node:fs'
  10. import { fileURLToPath } from 'node:url'
  11. import { describe, expect, it } from 'vitest'
  12. const css = readFileSync(fileURLToPath(new URL('../src/client/ModelsSection.module.css', import.meta.url)), 'utf8')
  13. // The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
  14. // stay on the source plane rather than needing a build.
  15. // Every theme sheet, not just the platform tokens: font and scrollbar
  16. // variables are declared in siblings, and a gate reading one file would call
  17. // their names undeclared.
  18. const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
  19. .filter(name => name.endsWith('.css'))
  20. .map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
  21. .join('\n')
  22. /** The declarations of one top-level rule, by selector. */
  23. function block(selector: string): string {
  24. const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css)
  25. if (match === null) throw new Error(`ModelsSection.module.css has no \`${selector}\` rule`)
  26. return match[1] ?? ''
  27. }
  28. describe('ModelsSection theme styles', () => {
  29. it('names only theme variables the token sheet defines', () => {
  30. // A `--dsw-*` name the sheet never declares is not a near miss: it silently
  31. // resolves to whatever literal sits in its fallback slot, which is how this
  32. // section stayed light under the dark theme before. Undeclared names have
  33. // no fallback at all and inherit, so both spellings must fail here.
  34. // Every theme-variable prefix the sheets actually use, not just `--dsw-`:
  35. // a `--dsh-` name reads as a plausible sibling and would otherwise slip
  36. // past this gate into a fallback literal.
  37. const named = [...css.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
  38. const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
  39. expect(undeclared).toEqual([])
  40. expect(css).not.toMatch(/var\(--(?:surface|text-|border|accent-strong)/)
  41. })
  42. it('closes every block, so no rule is swallowed by the one above it', () => {
  43. // A missing `}` on an `@media` block is not a parse error: every rule after
  44. // it silently becomes conditional, and the whole fetch dialog once painted
  45. // unstyled for anyone whose system does not ask for reduced motion. Nothing
  46. // downstream reports this — the sheet loads and the classes still attach.
  47. const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
  48. expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
  49. })
  50. it('separates the row card from the editor it expands into', () => {
  51. // `bg-layer-3` and `bg-module-platform` both resolve to neutral-bluish-800
  52. // under the dark theme, so filling the row with either erases the nested
  53. // editor's boundary. The row is outlined; the fill is the editor's alone.
  54. expect(block('.editor')).toContain('background: var(--dsw-alias-bg-module-platform)')
  55. expect(block('.rowCard')).toContain('border: 0.5px solid var(--dsw-alias-border-l4)')
  56. expect(block('.rowCard')).not.toMatch(/\bbackground\s*:/)
  57. })
  58. it('gives every dropdown the shared chevron instead of the OS arrow', () => {
  59. // `select.input` caps the control at 240px, and the OS arrow is painted
  60. // flush inside that shrunk right edge — visibly tighter than every other
  61. // control on the page. `.selectInput` is what removes it, reserves the
  62. // right pad, and paints the shared chevron; a `<select>` that takes
  63. // `.input` alone silently keeps the OS one.
  64. const sources = readdirSync(fileURLToPath(new URL('../src/client/', import.meta.url)))
  65. .filter(name => name.endsWith('.tsx'))
  66. .map(name => ({
  67. name,
  68. text: readFileSync(fileURLToPath(new URL(`../src/client/${name}`, import.meta.url)), 'utf8'),
  69. }))
  70. const bare = sources.flatMap(({ name, text }) => text
  71. .split('<select')
  72. .slice(1)
  73. // The element's own attributes end at the first `>`; a child `<option>`
  74. // carries no className of its own and must not answer for the select.
  75. .map(rest => rest.slice(0, rest.indexOf('>')))
  76. .filter(attributes => !attributes.includes('selectInput'))
  77. .map(() => name))
  78. expect(bare).toEqual([])
  79. })
  80. it('never falls back to a literal colour', () => {
  81. // A token that resolves is never the problem; an undeclared one takes this
  82. // branch, and a literal here is a single colour for both themes.
  83. expect(css).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
  84. })
  85. })