@rip-lang/ui 0.3.66 → 0.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/AGENTS.md +93 -0
  2. package/README.md +22 -625
  3. package/browser/AGENTS.md +213 -0
  4. package/browser/CONTRIBUTING.md +375 -0
  5. package/browser/README.md +11 -0
  6. package/browser/TESTING.md +59 -0
  7. package/browser/browser.rip +56 -0
  8. package/{components → browser/components}/accordion.rip +1 -1
  9. package/{components → browser/components}/alert-dialog.rip +6 -3
  10. package/{components → browser/components}/autocomplete.rip +27 -21
  11. package/{components → browser/components}/avatar.rip +3 -3
  12. package/{components → browser/components}/badge.rip +1 -1
  13. package/{components → browser/components}/breadcrumb.rip +2 -2
  14. package/{components → browser/components}/button-group.rip +3 -3
  15. package/{components → browser/components}/button.rip +2 -2
  16. package/{components → browser/components}/card.rip +1 -1
  17. package/{components → browser/components}/carousel.rip +5 -5
  18. package/{components → browser/components}/checkbox-group.rip +40 -11
  19. package/{components → browser/components}/checkbox.rip +4 -4
  20. package/{components → browser/components}/collapsible.rip +2 -2
  21. package/{components → browser/components}/combobox.rip +36 -23
  22. package/{components → browser/components}/context-menu.rip +1 -1
  23. package/{components → browser/components}/date-picker.rip +5 -5
  24. package/{components → browser/components}/dialog.rip +8 -4
  25. package/{components → browser/components}/drawer.rip +8 -4
  26. package/{components → browser/components}/editable-value.rip +7 -1
  27. package/{components → browser/components}/field.rip +5 -5
  28. package/{components → browser/components}/fieldset.rip +2 -2
  29. package/{components → browser/components}/form.rip +1 -1
  30. package/{components → browser/components}/grid.rip +8 -8
  31. package/{components → browser/components}/input-group.rip +1 -1
  32. package/{components → browser/components}/input.rip +6 -6
  33. package/{components → browser/components}/label.rip +2 -2
  34. package/{components → browser/components}/menu.rip +17 -10
  35. package/{components → browser/components}/menubar.rip +1 -1
  36. package/{components → browser/components}/meter.rip +7 -7
  37. package/{components → browser/components}/multi-select.rip +76 -33
  38. package/{components → browser/components}/native-select.rip +3 -3
  39. package/{components → browser/components}/nav-menu.rip +3 -3
  40. package/{components → browser/components}/number-field.rip +11 -11
  41. package/{components → browser/components}/otp-field.rip +4 -4
  42. package/{components → browser/components}/pagination.rip +4 -4
  43. package/{components → browser/components}/popover.rip +11 -24
  44. package/{components → browser/components}/preview-card.rip +7 -11
  45. package/{components → browser/components}/progress.rip +3 -3
  46. package/{components → browser/components}/radio-group.rip +4 -4
  47. package/{components → browser/components}/resizable.rip +3 -3
  48. package/{components → browser/components}/scroll-area.rip +1 -1
  49. package/{components → browser/components}/select.rip +55 -27
  50. package/{components → browser/components}/separator.rip +2 -2
  51. package/{components → browser/components}/skeleton.rip +4 -4
  52. package/{components → browser/components}/slider.rip +15 -10
  53. package/{components → browser/components}/spinner.rip +2 -2
  54. package/{components → browser/components}/table.rip +2 -2
  55. package/{components → browser/components}/tabs.rip +12 -7
  56. package/{components → browser/components}/textarea.rip +8 -8
  57. package/{components → browser/components}/toast.rip +3 -3
  58. package/{components → browser/components}/toggle-group.rip +42 -11
  59. package/{components → browser/components}/toggle.rip +2 -2
  60. package/{components → browser/components}/toolbar.rip +2 -2
  61. package/{components → browser/components}/tooltip.rip +19 -23
  62. package/browser/hljs-rip.js +209 -0
  63. package/browser/playwright.config.mjs +31 -0
  64. package/browser/tests/overlays.js +349 -0
  65. package/email/AGENTS.md +16 -0
  66. package/email/README.md +55 -0
  67. package/email/benchmarks/benchmark.rip +94 -0
  68. package/email/benchmarks/samples.rip +104 -0
  69. package/email/compat.rip +129 -0
  70. package/email/components.rip +371 -0
  71. package/email/dom.rip +330 -0
  72. package/email/email.rip +10 -0
  73. package/email/render.rip +82 -0
  74. package/package.json +29 -39
  75. package/shared/README.md +3 -0
  76. package/shared/styles.rip +17 -0
  77. package/tailwind/AGENTS.md +3 -0
  78. package/tailwind/README.md +27 -0
  79. package/tailwind/engine.js +107 -0
  80. package/tailwind/inline.js +215 -0
  81. package/tailwind/serve.js +6 -0
  82. package/tailwind/tailwind.rip +13 -0
  83. package/ui.rip +3 -0
@@ -0,0 +1,209 @@
1
+ // highlight.js language definition for Rip
2
+
3
+ export default function(hljs) {
4
+ const KEYWORDS = [
5
+ // Control flow
6
+ 'if', 'else', 'unless', 'then', 'switch', 'when',
7
+ 'for', 'while', 'until', 'loop', 'do',
8
+ 'return', 'break', 'continue', 'throw',
9
+ 'try', 'catch', 'finally',
10
+ 'yield', 'await',
11
+ // Modules
12
+ 'import', 'export', 'from', 'default',
13
+ // Operators as keywords
14
+ 'delete', 'typeof', 'instanceof', 'new', 'super',
15
+ 'and', 'or', 'not', 'is', 'isnt',
16
+ // Declarations
17
+ 'class', 'def', 'enum', 'interface', 'type', 'extends', 'own',
18
+ // Iteration
19
+ 'in', 'of', 'by', 'as',
20
+ // Component system
21
+ 'component', 'render', 'slot', 'offer', 'accept',
22
+ // Other
23
+ 'use', 'debugger', 'it',
24
+ ];
25
+
26
+ const LITERALS = [
27
+ 'true', 'false', 'yes', 'no', 'on', 'off',
28
+ 'null', 'undefined', 'NaN', 'Infinity', 'this',
29
+ ];
30
+
31
+ const BUILT_INS = [
32
+ 'console', 'process', 'require', 'module', 'exports',
33
+ 'setTimeout', 'setInterval', 'clearTimeout', 'clearInterval',
34
+ 'requestAnimationFrame', 'cancelAnimationFrame',
35
+ 'Promise', 'Array', 'Object', 'String', 'Number', 'Boolean',
36
+ 'Math', 'Date', 'RegExp', 'Error', 'TypeError', 'RangeError',
37
+ 'JSON', 'Map', 'Set', 'WeakMap', 'WeakSet',
38
+ 'Symbol', 'Proxy', 'Reflect',
39
+ 'Buffer', 'Bun',
40
+ 'document', 'window', 'globalThis', 'navigator',
41
+ 'fetch', 'URL', 'URLSearchParams', 'FormData',
42
+ 'Event', 'CustomEvent', 'EventSource',
43
+ 'HTMLElement', 'Node', 'NodeList', 'Element',
44
+ 'DocumentFragment', 'MutationObserver', 'ResizeObserver',
45
+ 'IntersectionObserver',
46
+ // Rip stdlib
47
+ 'p', 'pp', 'abort', 'assert', 'exit', 'kind', 'noop',
48
+ 'raise', 'rand', 'sleep', 'todo', 'warn', 'zip',
49
+ ];
50
+
51
+ const INTERPOLATION = {
52
+ className: 'subst',
53
+ begin: /#\{/, end: /\}/,
54
+ keywords: { keyword: KEYWORDS, literal: LITERALS },
55
+ };
56
+
57
+ const STRING_DOUBLE = {
58
+ className: 'string',
59
+ begin: '"', end: '"',
60
+ contains: [hljs.BACKSLASH_ESCAPE, INTERPOLATION],
61
+ };
62
+
63
+ const STRING_SINGLE = {
64
+ className: 'string',
65
+ begin: "'", end: "'",
66
+ contains: [hljs.BACKSLASH_ESCAPE],
67
+ };
68
+
69
+ const HEREDOC_DOUBLE = {
70
+ className: 'string',
71
+ begin: '"""', end: '"""',
72
+ contains: [hljs.BACKSLASH_ESCAPE, INTERPOLATION],
73
+ };
74
+
75
+ const HEREDOC_SINGLE = {
76
+ className: 'string',
77
+ begin: "'''", end: "'''",
78
+ contains: [hljs.BACKSLASH_ESCAPE],
79
+ };
80
+
81
+ const HEREGEX = {
82
+ className: 'regexp',
83
+ begin: '///', end: '///[gimsuy]*',
84
+ contains: [INTERPOLATION, hljs.HASH_COMMENT_MODE],
85
+ };
86
+
87
+ const REGEX = {
88
+ className: 'regexp',
89
+ begin: /\/(?![/*])(?:[^\/\\]|\\.)*\/[gimsuy]*/,
90
+ relevance: 0,
91
+ };
92
+
93
+ const BLOCK_COMMENT = {
94
+ className: 'comment',
95
+ begin: '###', end: '###',
96
+ contains: [hljs.PHRASAL_WORDS_MODE],
97
+ };
98
+
99
+ const LINE_COMMENT = hljs.COMMENT('#', '$');
100
+
101
+ const NUMBER = {
102
+ className: 'number',
103
+ variants: [
104
+ { begin: /0x[0-9a-fA-F](?:_?[0-9a-fA-F])*n?/ },
105
+ { begin: /0o[0-7](?:_?[0-7])*n?/ },
106
+ { begin: /0b[01](?:_?[01])*n?/ },
107
+ { begin: /\d[\d_]*(?:\.[\d][\d_]*)?(?:[eE][+-]?\d+)?n?/ },
108
+ ],
109
+ relevance: 0,
110
+ };
111
+
112
+ const INSTANCE_VAR = {
113
+ className: 'variable',
114
+ begin: /@[a-zA-Z_$][\w$]*/,
115
+ };
116
+
117
+ const SIGIL_ATTR = {
118
+ className: 'attribute',
119
+ begin: /\$[a-zA-Z_][\w]*/,
120
+ };
121
+
122
+ const CLASS_NAME = {
123
+ className: 'title.class',
124
+ begin: /[A-Z][\w]*/,
125
+ };
126
+
127
+ const FUNCTION_DEF = {
128
+ className: 'function',
129
+ begin: /\bdef\s+/,
130
+ end: /[(\s]/,
131
+ excludeEnd: true,
132
+ keywords: { keyword: 'def' },
133
+ contains: [
134
+ { className: 'title.function', begin: /[a-zA-Z_$][\w$]*[!?]?/ },
135
+ ],
136
+ };
137
+
138
+ const METHOD_DEF = {
139
+ className: 'function',
140
+ match: /[a-zA-Z_$][\w$]*[!?]?(?=\s*:\s*(?:\([^)]*\)\s*)?[-=]>)/,
141
+ contains: [
142
+ { className: 'title.function', begin: /[a-zA-Z_$][\w$]*[!?]?/ },
143
+ ],
144
+ };
145
+
146
+ const COMPONENT_DEF = {
147
+ className: 'class',
148
+ begin: /\b(?:export\s+)?[A-Z][\w]*\s*=\s*component\b/,
149
+ returnBegin: true,
150
+ keywords: { keyword: ['export', 'component'] },
151
+ contains: [
152
+ { className: 'title.class', begin: /[A-Z][\w]*/ },
153
+ ],
154
+ };
155
+
156
+ const CLASS_DEF = {
157
+ className: 'class',
158
+ beginKeywords: 'class',
159
+ end: /$/,
160
+ contains: [
161
+ { className: 'title.class', begin: /[A-Z][\w]*/ },
162
+ { beginKeywords: 'extends', contains: [CLASS_NAME] },
163
+ ],
164
+ };
165
+
166
+ const OPERATORS = {
167
+ className: 'operator',
168
+ begin: /\|>|::|:=|~=|~>|<=>|\.=|=!|!\?|\?!|=~|\?\?=|\?\?|\?\.|\.\.\.|\.\.|=>|->|\*\*|\/\/|%%|===|!==|==|!=|<=|>=|&&|\|\||[+\-*\/%&|^~<>=!?]/,
169
+ relevance: 0,
170
+ };
171
+
172
+ const TYPE_KEYWORDS = {
173
+ className: 'type',
174
+ begin: /\b(?:number|string|boolean|void|any|never|unknown|object|symbol|bigint)\b/,
175
+ };
176
+
177
+ return {
178
+ name: 'Rip',
179
+ aliases: ['rip'],
180
+ keywords: {
181
+ keyword: KEYWORDS,
182
+ literal: LITERALS,
183
+ built_in: BUILT_INS,
184
+ },
185
+ contains: [
186
+ BLOCK_COMMENT,
187
+ LINE_COMMENT,
188
+ HEREDOC_DOUBLE,
189
+ HEREDOC_SINGLE,
190
+ STRING_DOUBLE,
191
+ STRING_SINGLE,
192
+ HEREGEX,
193
+ REGEX,
194
+ COMPONENT_DEF,
195
+ FUNCTION_DEF,
196
+ METHOD_DEF,
197
+ CLASS_DEF,
198
+ NUMBER,
199
+ INSTANCE_VAR,
200
+ SIGIL_ATTR,
201
+ TYPE_KEYWORDS,
202
+ OPERATORS,
203
+ { // inline JS (backtick)
204
+ className: 'string',
205
+ begin: /`[^`]*`/,
206
+ },
207
+ ],
208
+ };
209
+ }
@@ -0,0 +1,31 @@
1
+ import { defineConfig, devices } from 'playwright/test'
2
+
3
+ export default defineConfig({
4
+ testDir: './tests',
5
+ testMatch: ['**/overlays.js'],
6
+ testIgnore: ['**/results/**'],
7
+ timeout: 30_000,
8
+ expect: {
9
+ timeout: 5_000,
10
+ },
11
+ fullyParallel: true,
12
+ retries: process.env.CI ? 2 : 0,
13
+ reporter: process.env.CI ? [['dot'], ['html', { open: 'never' }]] : 'list',
14
+ use: {
15
+ baseURL: 'http://localhost:3005',
16
+ trace: 'retain-on-failure',
17
+ screenshot: 'only-on-failure',
18
+ video: 'retain-on-failure',
19
+ },
20
+ webServer: {
21
+ command: '../../../bin/rip ../index.rip',
22
+ url: 'http://localhost:3005',
23
+ reuseExistingServer: true,
24
+ timeout: 120_000,
25
+ },
26
+ projects: [
27
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
28
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
29
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
30
+ ],
31
+ })
@@ -0,0 +1,349 @@
1
+ import { test, expect } from 'playwright/test'
2
+ import AxeBuilder from '@axe-core/playwright'
3
+
4
+ async function expectPopoverOpen(locator, expected) {
5
+ await expect
6
+ .poll(async () => locator.evaluate((el) => el.matches(':popover-open')))
7
+ .toBe(expected)
8
+ }
9
+
10
+ async function getCenter(locator) {
11
+ const box = await locator.boundingBox()
12
+ if (!box) throw new Error('element has no bounding box')
13
+ return {
14
+ x: box.x + box.width / 2,
15
+ y: box.y + box.height / 2,
16
+ }
17
+ }
18
+
19
+ test.describe('overlay primitives', () => {
20
+ test('popover opens and dismisses by Escape/outside click', async ({ page }) => {
21
+ await page.goto('/#popover')
22
+
23
+ const trigger = page.locator('#popover button:has-text("Open Popover")')
24
+ const content = page.locator('#popover [data-content]').first()
25
+
26
+ await trigger.click()
27
+ await expectPopoverOpen(content, true)
28
+ await expect(content).toBeVisible()
29
+
30
+ await page.keyboard.press('Escape')
31
+ await expectPopoverOpen(content, false)
32
+ await expect(trigger).toBeFocused()
33
+ })
34
+
35
+ test('dialog closes on Escape and restores closed state', async ({ page, browserName }) => {
36
+ await page.goto('/#dialog')
37
+
38
+ const trigger = page.locator('#dialog button:has-text("Open Dialog")')
39
+ await trigger.click()
40
+ const dialog = page.locator('#dialog dialog')
41
+ await expect(dialog).toHaveAttribute('open', '')
42
+
43
+ await page.keyboard.press('Escape')
44
+ await expect(dialog).not.toHaveAttribute('open', '')
45
+ await expect(page.locator('#dialog .status')).toContainText('open: false')
46
+ if (browserName === 'webkit') {
47
+ // WebKit may leave focus on <body> after native dialog Escape close.
48
+ // Ensure focus is no longer trapped and trigger can be immediately focused.
49
+ await trigger.focus()
50
+ await expect(trigger).toBeFocused()
51
+ } else {
52
+ await expect(trigger).toBeFocused()
53
+ }
54
+ })
55
+
56
+ test('alert dialog ignores Escape until explicit action', async ({ page }) => {
57
+ await page.goto('/#alert-dialog')
58
+
59
+ await page.locator('#alert-dialog button:has-text("Delete Account")').click()
60
+ const dialog = page.locator('#alert-dialog dialog')
61
+ await expect(dialog).toHaveAttribute('open', '')
62
+
63
+ await page.keyboard.press('Escape')
64
+ await expect(dialog).toHaveAttribute('open', '')
65
+
66
+ await page.locator('#alert-dialog button:has-text("Cancel")').click()
67
+ await expect(dialog).not.toHaveAttribute('open', '')
68
+ })
69
+
70
+ test('menu has semantic role and opens list', async ({ page }) => {
71
+ await page.goto('/#menu')
72
+
73
+ const trigger = page.locator('#menu button:has-text("Actions")')
74
+ await expect(trigger).toHaveAttribute('aria-haspopup', /menu/)
75
+ await trigger.click()
76
+
77
+ const menu = page.locator('#menu [role="menu"]')
78
+ await expect(menu).toBeVisible()
79
+ await expect(trigger).toHaveAttribute('aria-expanded', /true/)
80
+ })
81
+
82
+ test('select demo with dynamic slot options opens on pointerdown, stays open on release, and closes on a later trigger click', async ({ page }) => {
83
+ await page.goto('/#select')
84
+
85
+ const row = page.locator('#select .demo-row').first()
86
+ const trigger = row.locator('[role="combobox"]')
87
+ const listbox = row.locator('[role="listbox"]')
88
+
89
+ const center = await getCenter(trigger)
90
+ await page.mouse.move(center.x, center.y)
91
+ await page.mouse.down()
92
+ await expect(trigger).toHaveAttribute('aria-expanded', /true/)
93
+ await expect(listbox).toBeVisible()
94
+ await expect(listbox.locator('[role="option"]')).toHaveCount(21)
95
+ await expect(listbox.locator('[role="option"]').first()).toContainText('Apple')
96
+ await page.mouse.up()
97
+ await expect(listbox).toBeVisible()
98
+ await trigger.click()
99
+ await expect(listbox).not.toBeVisible()
100
+ })
101
+
102
+ test('select supports one-gesture mouse selection from trigger press to option release', async ({ page }) => {
103
+ await page.goto('/#select')
104
+
105
+ const row = page.locator('#select .demo-row').first()
106
+ const trigger = row.locator('[role="combobox"]')
107
+ const listbox = row.locator('[role="listbox"]')
108
+ const option = listbox.locator('[role="option"]').nth(2)
109
+ const status = row.locator('.status')
110
+
111
+ const triggerCenter = await getCenter(trigger)
112
+ await page.mouse.move(triggerCenter.x, triggerCenter.y)
113
+ await page.mouse.down()
114
+ await expect(listbox).toBeVisible()
115
+
116
+ const optionCenter = await getCenter(option)
117
+ await page.mouse.move(optionCenter.x, optionCenter.y)
118
+ await expect(option).toContainText('Avocado')
119
+ await page.mouse.up()
120
+ await expect(listbox).not.toBeVisible()
121
+ await expect(status).toContainText(/avocado/i)
122
+ await expect(trigger).toContainText('Avocado')
123
+ })
124
+
125
+ test('select does not commit on option pointerdown alone', async ({ page }) => {
126
+ await page.goto('/#select')
127
+
128
+ const row = page.locator('#select .demo-row').first()
129
+ const trigger = row.locator('[role="combobox"]')
130
+ const listbox = row.locator('[role="listbox"]')
131
+ const option = listbox.locator('[role="option"]').nth(2)
132
+ const status = row.locator('.status')
133
+
134
+ await trigger.click()
135
+ await expect(listbox).toBeVisible()
136
+
137
+ await option.dispatchEvent('pointerdown', {
138
+ pointerType: 'mouse',
139
+ button: 0,
140
+ buttons: 1,
141
+ isPrimary: true,
142
+ })
143
+ await expect(status).toContainText('selected: none')
144
+ await expect(listbox).toBeVisible()
145
+ })
146
+
147
+ test('select ignores touch pointerdown and opens on click', async ({ page }) => {
148
+ await page.goto('/#select')
149
+
150
+ const row = page.locator('#select .demo-row').first()
151
+ const trigger = row.locator('[role="combobox"]')
152
+ const listbox = row.locator('[role="listbox"]')
153
+
154
+ await trigger.dispatchEvent('pointerdown', {
155
+ pointerType: 'touch',
156
+ button: 0,
157
+ buttons: 1,
158
+ isPrimary: true,
159
+ })
160
+ await expect(trigger).toHaveAttribute('aria-expanded', /false/)
161
+ await expect(listbox).not.toBeVisible()
162
+
163
+ await trigger.click()
164
+ await expect(trigger).toHaveAttribute('aria-expanded', /true/)
165
+ await expect(listbox).toBeVisible()
166
+ })
167
+
168
+ test('select supports keyboard selection in the placeholder demo', async ({ page }) => {
169
+ await page.goto('/#select')
170
+
171
+ const row = page.locator('#select .demo-row').nth(1)
172
+ const trigger = row.locator('[role="combobox"]')
173
+ await trigger.focus()
174
+ await page.keyboard.press('ArrowDown')
175
+
176
+ const listbox = row.locator('[role="listbox"]')
177
+ await expect(listbox).toBeVisible()
178
+ await expect(listbox.locator('[role="option"]')).toHaveCount(4)
179
+ await page.keyboard.press('ArrowDown')
180
+ await page.keyboard.press('Enter')
181
+ await expect(listbox).not.toBeVisible()
182
+ await expect(row.locator('.status')).not.toContainText('none')
183
+ })
184
+
185
+ test('combobox opens suggestions and selects via Enter', async ({ page }) => {
186
+ await page.goto('/#combobox')
187
+
188
+ const input = page.locator('#combobox [role="combobox"]').first()
189
+ await input.fill('ap')
190
+ await expect(page.locator('#combobox [role="listbox"]').first()).toBeVisible()
191
+
192
+ await page.keyboard.press('ArrowDown')
193
+ await page.keyboard.press('Enter')
194
+ await expect(page.locator('#combobox [role="listbox"]').first()).not.toBeVisible()
195
+ await expect(input).toHaveValue(/.+/)
196
+ })
197
+
198
+ test('autocomplete opens suggestions and accepts keyboard selection', async ({ page }) => {
199
+ await page.goto('/#autocomplete')
200
+
201
+ const input = page.locator('#autocomplete [role="combobox"]').first()
202
+ await input.fill('n')
203
+ await expect(page.locator('#autocomplete [role="listbox"]').first()).toBeVisible()
204
+
205
+ await page.keyboard.press('ArrowDown')
206
+ await page.keyboard.press('Enter')
207
+ await expect(page.locator('#autocomplete [role="listbox"]').first()).not.toBeVisible()
208
+ await expect(input).toHaveValue(/.+/)
209
+ })
210
+
211
+ test('multiselect opens listbox and toggles an option', async ({ page }) => {
212
+ await page.goto('/#multi-select')
213
+
214
+ const input = page.locator('#multi-select [role="combobox"]').first()
215
+ await input.focus()
216
+ await page.keyboard.press('ArrowDown')
217
+ const listbox = page.locator('#multi-select [role="listbox"]').first()
218
+ await expect(listbox).toBeVisible()
219
+
220
+ const firstOption = page.locator('#multi-select [role="option"]').first()
221
+ const before = await firstOption.getAttribute('aria-selected')
222
+ await page.keyboard.press('Enter')
223
+ await expect(firstOption).toHaveAttribute('aria-selected', before === 'true' ? 'false' : 'true')
224
+ })
225
+
226
+ test('tooltip appears on hover with role=tooltip', async ({ page }) => {
227
+ await page.goto('/#tooltip')
228
+
229
+ const target = page.locator('#tooltip button:has-text("Save (top)")')
230
+ await target.hover()
231
+
232
+ const tip = page.locator('[role="tooltip"]').first()
233
+ await expect(tip).toBeVisible()
234
+ await expect(tip).toContainText('Save your changes')
235
+ })
236
+
237
+ test('nested popover inside dialog opens without closing dialog', async ({ page }) => {
238
+ await page.goto('/#dialog')
239
+
240
+ await page.locator('#dialog button:has-text("Open Dialog")').click()
241
+ const dialog = page.locator('#dialog dialog')
242
+ await expect(dialog).toHaveAttribute('open', '')
243
+
244
+ const nestedTrigger = page.locator('#dialog dialog button:has-text("More options")')
245
+ await nestedTrigger.click()
246
+
247
+ const nestedContent = page.locator('#dialog dialog [data-content]').first()
248
+ await expectPopoverOpen(nestedContent, true)
249
+ await expect(dialog).toHaveAttribute('open', '')
250
+ })
251
+
252
+ test('popover remains stable under rapid toggle/escape sequence', async ({ page }) => {
253
+ await page.goto('/#popover')
254
+
255
+ const trigger = page.locator('#popover button:has-text("Open Popover")')
256
+ const content = page.locator('#popover [data-content]').first()
257
+
258
+ for (let i = 0; i < 3; i++) {
259
+ await trigger.click()
260
+ await expectPopoverOpen(content, true)
261
+ await page.keyboard.press('Escape')
262
+ await expectPopoverOpen(content, false)
263
+ }
264
+ })
265
+ })
266
+
267
+ test.describe('overlay accessibility (optional)', () => {
268
+ test.skip(process.env.UI_AXE !== '1', 'set UI_AXE=1 to enable axe scans')
269
+
270
+ test('key overlay primitives have no critical axe violations', async ({ page }) => {
271
+ const cases = [
272
+ {
273
+ name: 'dialog',
274
+ hash: '#dialog',
275
+ include: '#dialog dialog',
276
+ setup: async () => {
277
+ const trigger = page.locator('#dialog button:has-text("Open Dialog")')
278
+ await trigger.scrollIntoViewIfNeeded()
279
+ await trigger.click()
280
+ await expect(page.locator('#dialog dialog')).toHaveAttribute('open', '')
281
+ },
282
+ },
283
+ {
284
+ name: 'popover',
285
+ hash: '#popover',
286
+ include: '#popover [data-content]',
287
+ setup: async () => {
288
+ const trigger = page.locator('#popover button:has-text("Open Popover")')
289
+ await trigger.scrollIntoViewIfNeeded()
290
+ await trigger.click()
291
+ await expect(page.locator('#popover [data-content]').first()).toBeVisible()
292
+ },
293
+ },
294
+ {
295
+ name: 'menu',
296
+ hash: '#menu',
297
+ include: '#menu [role="menu"]',
298
+ setup: async () => {
299
+ const trigger = page.locator('#menu button:has-text("Actions")')
300
+ await trigger.scrollIntoViewIfNeeded()
301
+ await trigger.click()
302
+ await expect(page.locator('#menu [role="menu"]')).toBeVisible()
303
+ },
304
+ },
305
+ {
306
+ name: 'select',
307
+ hash: '#select',
308
+ include: '#select [role="listbox"]',
309
+ setup: async () => {
310
+ const trigger = page.locator('#select [role="combobox"]').first()
311
+ await trigger.scrollIntoViewIfNeeded()
312
+ await trigger.focus()
313
+ await page.keyboard.press('ArrowDown')
314
+ await expect(page.locator('#select [role="listbox"]').first()).toBeVisible()
315
+ },
316
+ },
317
+ {
318
+ name: 'tooltip',
319
+ hash: '#tooltip',
320
+ include: '#tooltip [role="tooltip"]',
321
+ setup: async () => {
322
+ const trigger = page.locator('#tooltip button:has-text("Save (top)")')
323
+ await trigger.scrollIntoViewIfNeeded()
324
+ await trigger.hover()
325
+ await expect(page.locator('#tooltip [role="tooltip"]').first()).toBeVisible()
326
+ },
327
+ },
328
+ ]
329
+
330
+ for (const item of cases) {
331
+ await page.goto(`/?axe=${item.name}${item.hash}`)
332
+ await item.setup()
333
+ const results = await new AxeBuilder({ page }).include(item.include).analyze()
334
+ const critical = results.violations.filter((v) => v.impact === 'critical')
335
+ const serious = results.violations.filter((v) => v.impact === 'serious')
336
+ if (serious.length) {
337
+ console.warn(
338
+ `[axe:${item.name}] serious findings (non-blocking for now): ${serious
339
+ .map((v) => `${v.id}(${v.nodes.length})`)
340
+ .join(', ')}`
341
+ )
342
+ }
343
+ expect(
344
+ critical,
345
+ `${item.name} critical violations:\n${critical.map((v) => `${v.id}: ${v.help}`).join('\n')}`
346
+ ).toEqual([])
347
+ }
348
+ })
349
+ })
@@ -0,0 +1,16 @@
1
+ # Email Domain Guide
2
+
3
+ Curated PascalCase email components for `@rip-lang/ui/email`.
4
+
5
+ Internally, the components use native lowercase HTML tags in Rip render blocks and are rendered server-side through a DOM shim.
6
+
7
+ Key APIs:
8
+ - `toHTML`
9
+ - `toText`
10
+ - `renderEmail`
11
+
12
+ Type all exported component props explicitly with `@prop:: T := default`.
13
+
14
+ Tailwind note:
15
+ - the email domain must not import `tailwindcss` or `css-tree` directly
16
+ - `Tailwind` support goes through `@rip-lang/ui/tailwind`
@@ -0,0 +1,55 @@
1
+ # @rip-lang/ui/email
2
+
3
+ Curated PascalCase email components for Rip.
4
+
5
+ ```coffee
6
+ import { toHTML, Email, Head, Body, Container, Heading, Text, Button } from '@rip-lang/ui/email'
7
+ ```
8
+
9
+ Exports:
10
+ - `toHTML`
11
+ - `toText`
12
+ - `renderEmail`
13
+ - `Email`, `Head`, `Body`, `Preview`, `Font`, `Container`, `Section`, `Row`, `Column`, `Heading`, `Text`, `Link`, `Image`, `Divider`, `Button`, `Markdown`, `CodeBlock`, `CodeInline`, `Tailwind`
14
+
15
+ Implementation notes:
16
+ - Components are authored as real Rip components using native lowercase HTML tags internally.
17
+ - `Tailwind` delegates to `@rip-lang/ui/tailwind` for CSS generation/injection.
18
+ - Exported props are typed with Rip's current component typing model and validated through the virtual TypeScript pipeline.
19
+
20
+ ## Safety
21
+
22
+ - `Markdown` now escapes raw HTML from input before applying the supported markdown subset.
23
+ - Markdown links are restricted to safe href schemes (`http`, `https`, `mailto`, `tel`, `/`, `#`).
24
+ - `innerHTML` in this package should be treated as a curated rendering primitive, not a general-purpose raw HTML escape hatch.
25
+
26
+ ## Render model
27
+
28
+ - `toHTML`, `toText`, and `renderEmail` are synchronous.
29
+ - Rendering works by swapping in a temporary global DOM shim while the email component tree is created and serialized.
30
+ - Keep this render path synchronous. Do not introduce awaits into component lifecycle code that runs during email rendering.
31
+
32
+ ## Tailwind theming
33
+
34
+ Custom Tailwind config is supported in the synchronous email path, but the config must be prepared once ahead of rendering:
35
+
36
+ ```coffee
37
+ import { prepareConfig } from '@rip-lang/ui/tailwind'
38
+
39
+ theme =
40
+ theme:
41
+ extend:
42
+ colors:
43
+ brand: '#0f172a'
44
+
45
+ await prepareConfig(theme)
46
+ ```
47
+
48
+ Then pass the same config to the email `Tailwind` component:
49
+
50
+ ```coffee
51
+ Tailwind config: theme
52
+ # email content
53
+ ```
54
+
55
+ Use one shared Tailwind config per rendered email tree.