styles.client.spec.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293
  1. /**
  2. * Feedback controls stylesheet contract, asserted against the CSS text on disk.
  3. *
  4. * A `--dsw-*` name the theme never declares fails silently, and for this sheet
  5. * it failed loudly in the product: `border`, `background`, and the primary
  6. * button's fill and label each named a token that does not exist, so every one
  7. * of those declarations was invalid at computed-value time and dropped. The
  8. * note editor shipped with no border and no surface, and its Save button with
  9. * neither fill nor readable label. Nothing downstream reports this — the sheet
  10. * parses, the classes attach, and the DOM snapshots are unchanged.
  11. *
  12. * The editor is a popover portaled to `document.body` and fixed-positioned
  13. * from the note trigger's rect, so it never enters the IconActions row's flex
  14. * layout at all — the row keeps its single 28px line of icons and the note
  15. * trigger, and no wrapping (`flex-wrap`) or `order` is needed for it. The
  16. * width-independent half of that contract is asserted here (the panel is a
  17. * fixed portal, not an inline flex item); the resulting geometry is measured
  18. * in a real engine by `apps/web/tests/message-feedback-layout`.
  19. */
  20. import { readdirSync, readFileSync } from 'node:fs'
  21. import { fileURLToPath } from 'node:url'
  22. import { describe, expect, it } from 'vitest'
  23. const css = readFileSync(
  24. fileURLToPath(new URL('../src/client/MessageFeedbackActions.module.css', import.meta.url)),
  25. 'utf8',
  26. )
  27. // The theme package maps `./styles/*` to `./src/styles/*`, so the declarations
  28. // stay on the source plane rather than needing a build. Every theme sheet, not
  29. // just the platform tokens: font and scrollbar variables are declared in
  30. // siblings, and a gate reading one file would call their names undeclared.
  31. const tokens = readdirSync(fileURLToPath(new URL('../../ui-theme/src/styles/', import.meta.url)))
  32. .filter(name => name.endsWith('.css'))
  33. .map(name => readFileSync(fileURLToPath(new URL(`../../ui-theme/src/styles/${name}`, import.meta.url)), 'utf8'))
  34. .join('\n')
  35. /**
  36. * The declarations of one top-level rule, by selector.
  37. * @param selector - the class selector to read, including its leading dot.
  38. * @returns the rule's declaration text.
  39. */
  40. function block(selector: string): string {
  41. const match = new RegExp(`^\\${selector} \\{([^}]*)\\}`, 'm').exec(css)
  42. if (match === null) throw new Error(`MessageFeedbackActions.module.css has no \`${selector}\` rule`)
  43. return match[1] ?? ''
  44. }
  45. describe('MessageFeedbackActions theme styles', () => {
  46. it('names only theme variables the token sheet defines', () => {
  47. // The regression that motivated this file. An undeclared custom property
  48. // has no fallback and does not inherit a usable value: the entire
  49. // declaration is thrown away, so the control renders as if the line had
  50. // never been written. Every theme-variable prefix the sheets actually use,
  51. // not just `--dsw-`: a `--dsh-` name reads as a plausible sibling and would
  52. // otherwise slip past into an invalid declaration.
  53. const named = [...css.matchAll(/var\((--(?:dsw|dsh|ds)-[a-z0-9-]+)/g)].map(match => match[1])
  54. // Vacuity guard: the sheet has to actually name tokens, or the filter below
  55. // is satisfied by an empty list and this test proves nothing.
  56. expect(named.length).toBeGreaterThan(5)
  57. const undeclared = [...new Set(named)].filter(name => !tokens.includes(` ${String(name)}:`))
  58. expect(undeclared).toEqual([])
  59. })
  60. it('never falls back to a literal colour', () => {
  61. // A token that resolves is never the problem; an undeclared one takes this
  62. // branch, and a literal here is a single colour for both themes.
  63. expect(css).not.toMatch(/var\(--dsw-[a-z0-9-]+\s*,\s*(?:#|rgb|rgba|hsl|hsla)/)
  64. })
  65. it('keeps the note editor out of the row as a fixed portal, not a flex item', () => {
  66. // The editor is a popover portaled to document.body, so the IconActions row
  67. // never has to grow or wrap around it. Fixed positioning comes from the
  68. // placement code (inline `left`/`top`), not a class, so only `position:
  69. // fixed` and the elevated surface live in the sheet — plus the absence of a
  70. // flex rule on the panel, which would resurrect the row-overflow defect an
  71. // inline editor had. The row stays one 28px line, so a fixed `width` on the
  72. // panel is fine (it floats, it does not compete for row space).
  73. expect(block('.notePanel')).toMatch(/position:\s*fixed/)
  74. // The panel flex-sets its own children (textarea over buttons), which is
  75. // fine. What must be absent is the flex-SIZING that made an inline editor a
  76. // row item: grow/shrink/basis (or the `flex:` shorthand) would let it rejoin
  77. // the IconActions layout, resurrecting the overflow defect.
  78. expect(block('.notePanel')).not.toMatch(/flex-(?:grow|shrink|basis)\s*:/)
  79. expect(block('.notePanel')).not.toMatch(/^\s*flex\s*:/m)
  80. })
  81. it('closes every block, so no rule is swallowed by the one above it', () => {
  82. // A missing `}` is not a parse error: every rule after it silently becomes
  83. // part of the block above, and the controls would paint unstyled.
  84. const bare = css.replace(/\/\*[\s\S]*?\*\//g, '')
  85. expect((bare.match(/\}/g) ?? []).length).toBe((bare.match(/\{/g) ?? []).length)
  86. })
  87. })