rip-lang 3.13.92 → 3.13.94

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 (74) hide show
  1. package/CHANGELOG.md +2 -2
  2. package/README.md +3 -3
  3. package/bin/rip +11 -1
  4. package/docs/AGENTS.md +43 -0
  5. package/docs/RIP-LANG.md +3 -3
  6. package/docs/RIP-TYPES.md +72 -91
  7. package/docs/charts.html +15 -15
  8. package/docs/dist/rip.js +142 -38
  9. package/docs/dist/rip.min.js +174 -174
  10. package/docs/dist/rip.min.js.br +0 -0
  11. package/docs/index.html +2 -2
  12. package/package.json +1 -1
  13. package/src/AGENTS.md +456 -0
  14. package/src/lexer.js +1 -2
  15. package/src/typecheck.js +188 -6
  16. package/src/types.js +63 -38
  17. package/src/ui.rip +65 -0
  18. package/docs/ui/accordion.rip +0 -113
  19. package/docs/ui/alert-dialog.rip +0 -96
  20. package/docs/ui/autocomplete.rip +0 -141
  21. package/docs/ui/avatar.rip +0 -37
  22. package/docs/ui/badge.rip +0 -15
  23. package/docs/ui/breadcrumb.rip +0 -46
  24. package/docs/ui/button-group.rip +0 -26
  25. package/docs/ui/button.rip +0 -23
  26. package/docs/ui/card.rip +0 -25
  27. package/docs/ui/carousel.rip +0 -110
  28. package/docs/ui/checkbox-group.rip +0 -65
  29. package/docs/ui/checkbox.rip +0 -33
  30. package/docs/ui/collapsible.rip +0 -50
  31. package/docs/ui/combobox.rip +0 -155
  32. package/docs/ui/context-menu.rip +0 -105
  33. package/docs/ui/date-picker.rip +0 -214
  34. package/docs/ui/dialog.rip +0 -107
  35. package/docs/ui/drawer.rip +0 -79
  36. package/docs/ui/editable-value.rip +0 -80
  37. package/docs/ui/field.rip +0 -53
  38. package/docs/ui/fieldset.rip +0 -22
  39. package/docs/ui/form.rip +0 -39
  40. package/docs/ui/grid.rip +0 -901
  41. package/docs/ui/hljs-rip.js +0 -209
  42. package/docs/ui/index.css +0 -1772
  43. package/docs/ui/index.html +0 -2433
  44. package/docs/ui/input-group.rip +0 -28
  45. package/docs/ui/input.rip +0 -36
  46. package/docs/ui/label.rip +0 -16
  47. package/docs/ui/menu.rip +0 -162
  48. package/docs/ui/menubar.rip +0 -155
  49. package/docs/ui/meter.rip +0 -36
  50. package/docs/ui/multi-select.rip +0 -158
  51. package/docs/ui/native-select.rip +0 -32
  52. package/docs/ui/nav-menu.rip +0 -129
  53. package/docs/ui/number-field.rip +0 -162
  54. package/docs/ui/otp-field.rip +0 -89
  55. package/docs/ui/pagination.rip +0 -123
  56. package/docs/ui/popover.rip +0 -143
  57. package/docs/ui/preview-card.rip +0 -73
  58. package/docs/ui/progress.rip +0 -25
  59. package/docs/ui/radio-group.rip +0 -67
  60. package/docs/ui/resizable.rip +0 -123
  61. package/docs/ui/scroll-area.rip +0 -145
  62. package/docs/ui/select.rip +0 -184
  63. package/docs/ui/separator.rip +0 -17
  64. package/docs/ui/skeleton.rip +0 -22
  65. package/docs/ui/slider.rip +0 -165
  66. package/docs/ui/spinner.rip +0 -17
  67. package/docs/ui/table.rip +0 -27
  68. package/docs/ui/tabs.rip +0 -124
  69. package/docs/ui/textarea.rip +0 -48
  70. package/docs/ui/toast.rip +0 -87
  71. package/docs/ui/toggle-group.rip +0 -78
  72. package/docs/ui/toggle.rip +0 -24
  73. package/docs/ui/toolbar.rip +0 -46
  74. package/docs/ui/tooltip.rip +0 -115
package/src/typecheck.js CHANGED
@@ -33,14 +33,55 @@ export function countLines(str) {
33
33
  export function toVirtual(p) { return p + '.ts'; }
34
34
  export function fromVirtual(p) { return p.endsWith('.rip.ts') ? p.slice(0, -3) : p; }
35
35
 
36
+ // Patch uninitialized, untyped variables with inferred types from their
37
+ // first assignment. This makes `let total; total = count + ratio;` behave
38
+ // like `let total: number;` — so a later `total = "string"` is caught.
39
+ // Called by both the LSP and the CLI type-checker to keep them aligned.
40
+ export function patchUninitializedTypes(ts, service, compiledEntries) {
41
+ const program = service.getProgram();
42
+ if (!program) return;
43
+ const checker = program.getTypeChecker();
44
+ for (const [filePath] of compiledEntries) {
45
+ const sf = program.getSourceFile(toVirtual(filePath));
46
+ if (!sf) continue;
47
+ const uninitialized = new Map();
48
+ for (const stmt of sf.statements) {
49
+ if (ts.isVariableStatement(stmt)) {
50
+ for (const decl of stmt.declarationList.declarations) {
51
+ if (!decl.initializer && !decl.type && ts.isIdentifier(decl.name)) {
52
+ const sym = checker.getSymbolAtLocation(decl.name);
53
+ if (sym) uninitialized.set(decl.name.text, sym);
54
+ }
55
+ }
56
+ }
57
+ if (ts.isExpressionStatement(stmt) && ts.isBinaryExpression(stmt.expression) &&
58
+ stmt.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
59
+ ts.isIdentifier(stmt.expression.left)) {
60
+ const name = stmt.expression.left.text;
61
+ const sym = uninitialized.get(name);
62
+ if (sym) {
63
+ const rhsType = checker.getTypeAtLocation(stmt.expression.right);
64
+ sym.flags |= ts.SymbolFlags.Transient;
65
+ sym.links = { type: rhsType };
66
+ uninitialized.delete(name);
67
+ }
68
+ }
69
+ }
70
+ }
71
+ }
72
+
36
73
  // TS error codes to skip — Rip resolves modules differently and
37
74
  // treats async return types transparently.
38
75
  export const SKIP_CODES = new Set([
39
76
  2300, // Duplicate identifier (DTS declarations coexist with compiled class bodies)
40
77
  2304, // Cannot find name
41
78
  2307, // Cannot find module
79
+ 2389, // Function implementation name must match overload (DTS + compiled body)
80
+ 2391, // Function implementation is missing (DTS overload sigs separated from implementations)
42
81
  2393, // Duplicate function implementation
82
+ 2394, // Overload signature not compatible with implementation (untyped compiled params)
43
83
  2451, // Cannot redeclare block-scoped variable
84
+ 2567, // Enum declarations can only merge with namespace or other enum (DTS + compiled body)
44
85
  1064, // Return type of async function must be Promise
45
86
  2582, // Cannot find name 'test' (test runner globals)
46
87
  2593, // Cannot find name 'describe' (test runner globals)
@@ -94,26 +135,120 @@ export function compileForCheck(filePath, source, compiler) {
94
135
  // Ensure every file is treated as a module (not a global script)
95
136
  if (!/\bexport\b/.test(code) && !/\bimport\b/.test(code)) code += '\nexport {};\n';
96
137
 
97
- const tsContent = (hasTypes ? dts + '\n' : '') + code;
98
- const headerLines = hasTypes ? countLines(dts + '\n') : 1;
138
+ // Interleave function overload signatures from DTS header into the code
139
+ // section, immediately before their implementations. TypeScript requires
140
+ // overload signatures adjacent to the implementation — without this, TS
141
+ // reports error 2391 ("Function implementation is missing or not immediately
142
+ // following the declaration"). Moving signatures into the code also enables
143
+ // proper call-site type checking of function parameters.
144
+ let headerDts = dts;
145
+ if (hasTypes && dts && code) {
146
+ const dl = dts.split('\n');
147
+ const cl = code.split('\n');
148
+ const fnSigs = [];
149
+ for (let i = 0; i < dl.length; i++) {
150
+ const m = dl[i].match(/^(?:export\s+)?(?:declare\s+)?function\s+(\w+)/);
151
+ if (m) fnSigs.push({ name: m[1], sig: dl[i], idx: i });
152
+ }
153
+ if (fnSigs.length > 0) {
154
+ const injections = [];
155
+ const moved = new Set();
156
+ for (const fn of fnSigs) {
157
+ const pat = new RegExp(`^(?:export\\s+)?(?:async\\s+)?function\\s+${fn.name}\\s*[(<]`);
158
+ for (let j = 0; j < cl.length; j++) {
159
+ if (pat.test(cl[j])) {
160
+ injections.push({ codeLine: j, sig: fn.sig });
161
+ moved.add(fn.idx);
162
+ break;
163
+ }
164
+ }
165
+ }
166
+ if (injections.length > 0) {
167
+ injections.sort((a, b) => a.codeLine - b.codeLine);
168
+ // Adjust reverseMap: each injection shifts subsequent code lines down by 1
169
+ if (result.reverseMap) {
170
+ for (const [, entry] of result.reverseMap) {
171
+ let offset = 0;
172
+ for (const inj of injections) {
173
+ if (inj.codeLine <= entry.genLine + offset) offset++;
174
+ }
175
+ entry.genLine += offset;
176
+ }
177
+ }
178
+ // Insert signatures bottom-up to preserve indices.
179
+ // Strip 'declare ' — signatures must be non-ambient to match implementations.
180
+ for (let k = injections.length - 1; k >= 0; k--) {
181
+ cl.splice(injections[k].codeLine, 0, injections[k].sig.replace(/^declare /, ''));
182
+ }
183
+ code = cl.join('\n');
184
+ // Rebuild header DTS without the moved function signatures
185
+ headerDts = dl.filter((_, i) => !moved.has(i)).join('\n').trimEnd() + '\n';
186
+ }
187
+ }
188
+ }
189
+
190
+ // Annotate reactive/readonly/computed const assignments with their declared
191
+ // types from the DTS header, and remove the corresponding `declare const`
192
+ // from the header. This enables TypeScript to check initializer values
193
+ // against type annotations: `const x: Signal<number> = __state("oops")`
194
+ // produces a real type error, whereas two separate declarations
195
+ // (`declare const x: Signal<number>` + `const x = __state("oops")`)
196
+ // only produce a duplicate-identifier error (2451), which is suppressed.
197
+ if (hasTypes && headerDts && code) {
198
+ const dl = headerDts.split('\n');
199
+ const cl = code.split('\n');
200
+ const constTypes = new Map();
201
+
202
+ for (let i = 0; i < dl.length; i++) {
203
+ const m = dl[i].match(/^(?:export\s+)?declare\s+const\s+(\w+):\s+(.+);$/);
204
+ if (m) constTypes.set(m[1], { type: m[2], idx: i });
205
+ }
206
+
207
+ if (constTypes.size > 0) {
208
+ const movedDts = new Set();
209
+
210
+ for (let j = 0; j < cl.length; j++) {
211
+ const cm = cl[j].match(/^((?:export\s+)?const\s+)(\w+)(\s*=\s*)/);
212
+ if (cm && constTypes.has(cm[2])) {
213
+ const entry = constTypes.get(cm[2]);
214
+ cl[j] = cm[1] + cm[2] + ': ' + entry.type + cm[3] + cl[j].slice(cm[0].length);
215
+ movedDts.add(entry.idx);
216
+ }
217
+ }
218
+
219
+ if (movedDts.size > 0) {
220
+ code = cl.join('\n');
221
+ headerDts = dl.filter((_, i) => !movedDts.has(i)).join('\n').trimEnd() + '\n';
222
+ }
223
+ }
224
+ }
225
+
226
+ let tsContent = (hasTypes ? headerDts + '\n' : '') + code;
227
+ const headerLines = hasTypes ? countLines(headerDts + '\n') : 1;
99
228
 
100
229
  // Build bidirectional line maps
101
230
  const { srcToGen, genToSrc } = buildLineMap(result.reverseMap, result.map, headerLines);
102
231
 
232
+ // Snapshot code-section mappings before DTS mapping can overwrite them.
233
+ // Needed by @ts-expect-error injection which must target code lines, not DTS.
234
+ const codeSrcToGen = new Map(srcToGen);
235
+
103
236
  // Map DTS declaration lines back to source lines (bidirectional).
104
- // Covers: let/var declarations, type aliases, interfaces, enums, classes.
237
+ // Covers: imports, let/var declarations, type aliases, interfaces, enums, classes.
105
238
  // This enables hover, go-to-definition, and diagnostics for type-only code.
106
- if (hasTypes && dts) {
107
- const dtsLines = dts.split('\n');
239
+ if (hasTypes && headerDts) {
240
+ const dtsLines = headerDts.split('\n');
108
241
  const srcLines = source.split('\n');
109
242
  for (let i = 0; i < dtsLines.length; i++) {
110
243
  const line = dtsLines[i];
244
+
111
245
  const m = line.match(/^(?:export\s+)?(?:declare\s+)?(?:let|var|type|interface|enum|class)\s+(\w+)/);
112
246
  if (!m) continue;
113
247
  const name = m[1];
114
248
  for (let s = 0; s < srcLines.length; s++) {
115
249
  const src = srcLines[s];
116
- if (new RegExp('\\b' + name + '\\s*(?:::=|::)').test(src) ||
250
+ if (new RegExp('\\b' + name + '\\s*::').test(src) ||
251
+ new RegExp('^(?:export\\s+)?type\\s+' + name + '\\b').test(src) ||
117
252
  new RegExp('^(?:export\\s+)?interface\\s+' + name + '\\b').test(src) ||
118
253
  new RegExp('^(?:export\\s+)?enum\\s+' + name + '\\b').test(src) ||
119
254
  new RegExp('^(?:export\\s+)?' + name + '\\s*=\\s*component\\b').test(src)) {
@@ -144,6 +279,50 @@ export function compileForCheck(filePath, source, compiler) {
144
279
  }
145
280
  }
146
281
 
282
+ // Inject @ts-expect-error directives from Rip source into the generated
283
+ // TypeScript. This lets TypeScript natively suppress expected errors and
284
+ // report TS2578 for unused directives — works in both CLI and LSP.
285
+ if (hasTypes) {
286
+ const srcLines = source.split('\n');
287
+ const injects = [];
288
+ for (let s = 0; s < srcLines.length; s++) {
289
+ const m = srcLines[s].match(/^\s*#\s*(@ts-expect-error\b.*)/);
290
+ if (m) {
291
+ const nextSrc = s + 1;
292
+ // Prefer code-section line (where the assignment lives and TS reports
293
+ // the error) over the DTS declaration line.
294
+ let genLine = codeSrcToGen.get(nextSrc);
295
+ if (genLine === undefined) {
296
+ genLine = srcToGen.get(nextSrc);
297
+ if (genLine !== undefined && genLine < headerLines) genLine = undefined;
298
+ }
299
+ if (genLine !== undefined) {
300
+ injects.push({ genLine, srcLine: s, comment: `// ${m[1]}` });
301
+ }
302
+ }
303
+ }
304
+ if (injects.length > 0) {
305
+ // Sort descending so bottom-up insertion doesn't shift earlier positions
306
+ injects.sort((a, b) => b.genLine - a.genLine);
307
+ const tsLines = tsContent.split('\n');
308
+ for (const { genLine, srcLine, comment } of injects) {
309
+ tsLines.splice(genLine, 0, comment);
310
+ // Shift existing gen→src mappings at or after the insertion point
311
+ const shifted = new Map();
312
+ for (const [g, s] of genToSrc) shifted.set(g >= genLine ? g + 1 : g, s);
313
+ shifted.set(genLine, srcLine);
314
+ genToSrc.clear();
315
+ for (const [g, s] of shifted) genToSrc.set(g, s);
316
+ // Shift existing src→gen mappings that pointed at or past the insertion
317
+ for (const [s, g] of srcToGen) {
318
+ if (g >= genLine) srcToGen.set(s, g + 1);
319
+ }
320
+ srcToGen.set(srcLine, genLine);
321
+ }
322
+ tsContent = tsLines.join('\n');
323
+ }
324
+ }
325
+
147
326
  return { tsContent, headerLines, hasTypes, srcToGen, genToSrc, source, dts };
148
327
  }
149
328
 
@@ -288,6 +467,9 @@ export async function runCheck(targetDir, opts = {}) {
288
467
 
289
468
  const service = ts.createLanguageService(host, ts.createDocumentRegistry());
290
469
 
470
+ // Patch uninitialized variables with inferred types (same as LSP)
471
+ patchUninitializedTypes(ts, service, compiled);
472
+
291
473
  // Collect diagnostics
292
474
  let totalErrors = 0;
293
475
  let totalWarnings = 0;
package/src/types.js CHANGED
@@ -26,7 +26,7 @@ export function installTypeSupport(Lexer) {
26
26
  //
27
27
  // Scans the token stream for:
28
28
  // :: (TYPE_ANNOTATION) — collects type string, stores on surviving token
29
- // ::= (TYPE_ALIAS) — collects type body, replaces with TYPE_DECL marker
29
+ // type Name = (contextual keyword) — collects type body, replaces with TYPE_DECL marker
30
30
  // INTERFACE — collects body, replaces with TYPE_DECL marker
31
31
  // DEF IDENTIFIER<...> — collects generic params via .spaced detection
32
32
  //
@@ -50,29 +50,27 @@ export function installTypeSupport(Lexer) {
50
50
  this.scanTokens((token, i, tokens) => {
51
51
  let tag = token[0];
52
52
 
53
- // ── Generic type parameters: DEF name<T>(...) or Name<T> ::= ───────
53
+ // ── Generic type parameters: DEF name<T>(...) ──────────────────────
54
+ // (Generic params on type aliases are handled by the `type` keyword handler below)
54
55
  if (tag === 'IDENTIFIER') {
55
56
  let next = tokens[i + 1];
56
57
  if (next && next[0] === 'COMPARE' && next[1] === '<' && !next.spaced) {
57
58
  let isDef = tokens[i - 1]?.[0] === 'DEF';
58
59
  let genTokens = collectBalancedAngles(tokens, i + 1);
59
- if (genTokens) {
60
- let isAlias = !isDef && tokens[i + 1 + genTokens.length]?.[0] === 'TYPE_ALIAS';
61
- if (isDef || isAlias) {
62
- if (!token.data) token.data = {};
63
- token.data.typeParams = buildTypeString(genTokens);
64
- tokens.splice(i + 1, genTokens.length);
65
- // After removing <T>, retag ( as CALL_START if it follows DEF IDENTIFIER
66
- if (isDef && tokens[i + 1]?.[0] === '(') {
67
- tokens[i + 1][0] = 'CALL_START';
68
- // Find matching ) and retag as CALL_END
69
- let d = 1, m = i + 2;
70
- while (m < tokens.length && d > 0) {
71
- if (tokens[m][0] === '(' || tokens[m][0] === 'CALL_START') d++;
72
- if (tokens[m][0] === ')' || tokens[m][0] === 'CALL_END') d--;
73
- if (d === 0) tokens[m][0] = 'CALL_END';
74
- m++;
75
- }
60
+ if (genTokens && isDef) {
61
+ if (!token.data) token.data = {};
62
+ token.data.typeParams = buildTypeString(genTokens);
63
+ tokens.splice(i + 1, genTokens.length);
64
+ // After removing <T>, retag ( as CALL_START if it follows DEF IDENTIFIER
65
+ if (tokens[i + 1]?.[0] === '(') {
66
+ tokens[i + 1][0] = 'CALL_START';
67
+ // Find matching ) and retag as CALL_END
68
+ let d = 1, m = i + 2;
69
+ while (m < tokens.length && d > 0) {
70
+ if (tokens[m][0] === '(' || tokens[m][0] === 'CALL_START') d++;
71
+ if (tokens[m][0] === ')' || tokens[m][0] === 'CALL_END') d--;
72
+ if (d === 0) tokens[m][0] = 'CALL_END';
73
+ m++;
76
74
  }
77
75
  }
78
76
  }
@@ -125,14 +123,33 @@ export function installTypeSupport(Lexer) {
125
123
  return 0;
126
124
  }
127
125
 
128
- // ── TYPE_ALIAS (::=)collect type body, create TYPE_DECL marker ───
129
- if (tag === 'TYPE_ALIAS') {
130
- let nameToken = tokens[i - 1];
131
- if (!nameToken) return 1;
126
+ // ── type Name = ... contextual type keyword ──────────────────────
127
+ if (tag === 'IDENTIFIER' && token[1] === 'type') {
128
+ let prevTag = tokens[i - 1]?.[0];
129
+ let atStatement = !prevTag || prevTag === 'TERMINATOR' || prevTag === 'INDENT' || prevTag === 'EXPORT';
130
+ if (!atStatement) return 1;
131
+
132
+ let nameIdx = i + 1;
133
+ let nameToken = tokens[nameIdx];
134
+ if (!nameToken || nameToken[0] !== 'IDENTIFIER') return 1;
132
135
  let name = nameToken[1];
133
- let exported = i >= 2 && tokens[i - 2]?.[0] === 'EXPORT';
134
- let removeFrom = exported ? i - 2 : i - 1;
135
- let next = tokens[i + 1];
136
+
137
+ let exported = prevTag === 'EXPORT';
138
+ let removeFrom = exported ? i - 1 : i;
139
+
140
+ // Handle generic type parameters: type Name<T> = ...
141
+ let eqIdx = nameIdx + 1;
142
+ if (tokens[eqIdx]?.[0] === 'COMPARE' && tokens[eqIdx]?.[1] === '<' && !tokens[eqIdx].spaced) {
143
+ let genTokens = collectBalancedAngles(tokens, eqIdx);
144
+ if (genTokens) {
145
+ if (!nameToken.data) nameToken.data = {};
146
+ nameToken.data.typeParams = buildTypeString(genTokens);
147
+ tokens.splice(eqIdx, genTokens.length);
148
+ }
149
+ }
150
+
151
+ // Must have = after name (or after stripped generics)
152
+ if (tokens[eqIdx]?.[0] !== '=') return 1;
136
153
 
137
154
  let makeDecl = (typeText) => {
138
155
  let dt = gen('TYPE_DECL', name, nameToken);
@@ -141,26 +158,29 @@ export function installTypeSupport(Lexer) {
141
158
  return dt;
142
159
  };
143
160
 
144
- // Structural type: Name ::= type INDENT ... OUTDENT
145
- if (next && next[0] === 'IDENTIFIER' && next[1] === 'type' &&
146
- tokens[i + 2]?.[0] === 'INDENT') {
147
- let endIdx = findMatchingOutdent(tokens, i + 2);
148
- tokens.splice(removeFrom, endIdx - removeFrom + 1, makeDecl(collectStructuralType(tokens, i + 2)));
149
- return 0;
150
- }
161
+ let afterEq = eqIdx + 1;
162
+ let next = tokens[afterEq];
151
163
 
152
- // Block union: Name ::= TERMINATOR INDENT | "a" | "b" ... OUTDENT
164
+ // Block union: type Name = (TERMINATOR?) INDENT | "a" | "b" ... OUTDENT
165
+ // Must check before structural — `=` suppresses TERMINATOR so INDENT follows directly
153
166
  if (next && (next[0] === 'TERMINATOR' || next[0] === 'INDENT')) {
154
- let result = collectBlockUnion(tokens, i + 1);
167
+ let result = collectBlockUnion(tokens, afterEq);
155
168
  if (result) {
156
169
  tokens.splice(removeFrom, result.endIdx - removeFrom + 1, makeDecl(result.typeText));
157
170
  return 0;
158
171
  }
159
172
  }
160
173
 
161
- // Simple alias: Name ::= type-expression
162
- let typeTokens = collectTypeExpression(tokens, i + 1);
163
- tokens.splice(removeFrom, i + 1 + typeTokens.length - removeFrom, makeDecl(buildTypeString(typeTokens)));
174
+ // Structural type: type Name = INDENT ... OUTDENT
175
+ if (next && next[0] === 'INDENT') {
176
+ let endIdx = findMatchingOutdent(tokens, afterEq);
177
+ tokens.splice(removeFrom, endIdx - removeFrom + 1, makeDecl(collectStructuralType(tokens, afterEq)));
178
+ return 0;
179
+ }
180
+
181
+ // Simple alias: type Name = type-expression
182
+ let typeTokens = collectTypeExpression(tokens, afterEq);
183
+ tokens.splice(removeFrom, afterEq + typeTokens.length - removeFrom, makeDecl(buildTypeString(typeTokens)));
164
184
  return 0;
165
185
  }
166
186
 
@@ -932,9 +952,14 @@ export function emitTypes(tokens, sexpr = null) {
932
952
  let preamble = [];
933
953
  if (usesSignal) {
934
954
  preamble.push('interface Signal<T> { value: T; read(): T; lock(): Signal<T>; free(): Signal<T>; kill(): T; }');
955
+ preamble.push('declare function __state<T>(value: T): Signal<T>;');
935
956
  }
936
957
  if (usesComputed) {
937
958
  preamble.push('interface Computed<T> { readonly value: T; read(): T; lock(): Computed<T>; free(): Computed<T>; kill(): T; }');
959
+ preamble.push('declare function __computed<T>(fn: () => T): Computed<T>;');
960
+ }
961
+ if (usesSignal || usesComputed) {
962
+ preamble.push('declare function __effect(fn: () => void | (() => void)): () => void;');
938
963
  }
939
964
  if (preamble.length > 0) {
940
965
  preamble.push('');
package/src/ui.rip CHANGED
@@ -1013,3 +1013,68 @@ export launch = (appBase = '', opts = {}) ->
1013
1013
  version: '0.3.0'
1014
1014
 
1015
1015
  { app, components: appComponents, router, renderer }
1016
+
1017
+ # ==============================================================================
1018
+ # ARIA — keyboard navigation and popup lifecycle utilities for UI components
1019
+ #
1020
+ # Provides the WAI-ARIA keyboard interaction patterns used by headless widgets.
1021
+ # Registered on globalThis so any component can use them without explicit imports.
1022
+ #
1023
+ # ARIA.listNav(e, handlers) — popup lists (listbox, menu, combobox)
1024
+ # ARIA.rovingNav(e, handlers, orient) — inline composites (radiogroup, tabs, toolbar)
1025
+ # ARIA.popupDismiss(open, popup, close, els) — close on outside click or scroll
1026
+ #
1027
+ # Both nav handlers:
1028
+ # - Guard against IME composition events (e.which === 229, CJK input)
1029
+ # - Call e.preventDefault() + e.stopPropagation() for handled keys
1030
+ # - Only invoke a handler if it is provided (all keys are optional)
1031
+ # - Alias PageUp/PageDown to first/last (fn+Up/Down on macOS)
1032
+ #
1033
+ # ARIA: https://www.w3.org/WAI/ARIA/apg/practices/keyboard-interface/
1034
+ # ==============================================================================
1035
+
1036
+ _ariaNAV = (e, fn) ->
1037
+ return unless fn
1038
+ e.preventDefault()
1039
+ e.stopPropagation()
1040
+ fn()
1041
+
1042
+ _ariaListNav = (e, h) ->
1043
+ return if e.which is 229 # IME guard: suppress CJK composition events
1044
+ switch e.key
1045
+ when 'ArrowDown' then _ariaNAV e, h.next
1046
+ when 'ArrowUp' then _ariaNAV e, h.prev
1047
+ when 'Home', 'PageUp' then _ariaNAV e, h.first
1048
+ when 'End', 'PageDown' then _ariaNAV e, h.last
1049
+ when 'Enter', ' ' then _ariaNAV e, h.select
1050
+ when 'Escape' then _ariaNAV e, h.dismiss
1051
+ when 'Tab' then h.tab?() # no preventDefault: allow natural focus movement
1052
+ else h.char?(e.key) if e.key.length is 1 # printable chars: typeahead
1053
+
1054
+ _ariaPopupDismiss = (open, popup, close, els = []) ->
1055
+ return unless open
1056
+ inside = [popup, ...els]
1057
+ onDown = (e) => close() unless inside.some (el) -> el?.contains(e.target)
1058
+ onScroll = (e) => close() unless popup?.contains(e.target)
1059
+ document.addEventListener 'mousedown', onDown
1060
+ window.addEventListener 'scroll', onScroll, true
1061
+ ->
1062
+ document.removeEventListener 'mousedown', onDown
1063
+ window.removeEventListener 'scroll', onScroll, true
1064
+
1065
+ _ariaRovingNav = (e, h, orientation = 'vertical') ->
1066
+ return if e.which is 229 # IME guard
1067
+ vert = orientation isnt 'horizontal'
1068
+ horz = orientation isnt 'vertical'
1069
+ switch e.key
1070
+ when 'ArrowDown' then _ariaNAV e, h.next if vert
1071
+ when 'ArrowUp' then _ariaNAV e, h.prev if vert
1072
+ when 'ArrowRight' then _ariaNAV e, h.next if horz
1073
+ when 'ArrowLeft' then _ariaNAV e, h.prev if horz
1074
+ when 'Home', 'PageUp' then _ariaNAV e, h.first
1075
+ when 'End', 'PageDown' then _ariaNAV e, h.last
1076
+ when 'Enter', ' ' then _ariaNAV e, h.select
1077
+ when 'Escape' then _ariaNAV e, h.dismiss
1078
+
1079
+ globalThis.__aria ??= { listNav: _ariaListNav, rovingNav: _ariaRovingNav, popupDismiss: _ariaPopupDismiss }
1080
+ globalThis.ARIA ??= globalThis.__aria
@@ -1,113 +0,0 @@
1
- # Accordion — accessible headless expand/collapse widget
2
- #
3
- # Supports single or multiple expanded sections. Keyboard: Enter/Space to
4
- # toggle, ArrowDown/Up to move between triggers. Exposes $open on items.
5
- # Ships zero CSS.
6
- #
7
- # Usage:
8
- # Accordion multiple: false
9
- # div $item: "a"
10
- # button $trigger: true, "Section A"
11
- # div $content: true
12
- # p "Content A"
13
- # div $item: "b"
14
- # button $trigger: true, "Section B"
15
- # div $content: true
16
- # p "Content B"
17
-
18
- export Accordion = component
19
- @multiple := false
20
-
21
- openItems := new Set()
22
- _ready := false
23
- _id =! "acc-#{Math.random().toString(36).slice(2, 8)}"
24
-
25
- mounted: ->
26
- _ready = true
27
- @_content?.querySelectorAll('[data-trigger]').forEach (trigger) =>
28
- item = trigger.closest('[data-item]')
29
- return unless item
30
- id = item.dataset.item
31
- trigger.addEventListener 'click', =>
32
- return if item.hasAttribute('data-disabled')
33
- @toggle(id)
34
- trigger.addEventListener 'keydown', (e) => @onTriggerKeydown(e, id)
35
-
36
- ~>
37
- return unless _ready
38
- @_content?.querySelectorAll('[data-item]').forEach (item) =>
39
- id = item.dataset.item
40
- isOpen = openItems.has(id)
41
- item.toggleAttribute 'data-open', isOpen
42
- trigger = item.querySelector('[data-trigger]')
43
- content = item.querySelector('[data-content]')
44
- triggerId = "#{_id}-trigger-#{id}"
45
- panelId = "#{_id}-panel-#{id}"
46
- if trigger
47
- isDisabled = item.hasAttribute('data-disabled')
48
- trigger.id = triggerId
49
- trigger.setAttribute 'aria-expanded', isOpen
50
- trigger.setAttribute 'aria-controls', panelId
51
- trigger.setAttribute 'aria-disabled', isDisabled if isDisabled
52
- trigger.tabIndex = if isDisabled then -1 else 0
53
- if content
54
- content.id = panelId
55
- content.hidden = if isOpen then false else 'until-found'
56
- content.setAttribute 'role', 'region'
57
- content.setAttribute 'aria-labelledby', triggerId
58
- if isOpen
59
- rect = content.getBoundingClientRect()
60
- content.style.setProperty '--accordion-panel-height', "#{rect.height}px"
61
- content.style.setProperty '--accordion-panel-width', "#{rect.width}px"
62
-
63
- toggle: (id) ->
64
- if openItems.has(id)
65
- openItems.delete(id)
66
- else
67
- openItems.clear() unless @multiple
68
- openItems.add(id)
69
- openItems = new Set(openItems)
70
- @emit 'change', Array.from(openItems)
71
-
72
- isOpen: (id) ->
73
- openItems.has(id)
74
-
75
- onTriggerKeydown: (e, id) ->
76
- item = e.currentTarget.closest('[data-item]')
77
- return if item?.hasAttribute('data-disabled') and e.key in ['Enter', ' ']
78
- switch e.key
79
- when 'Enter', ' '
80
- e.preventDefault()
81
- @toggle(id)
82
- when 'ArrowDown'
83
- e.preventDefault()
84
- @_focusNext(1)
85
- when 'ArrowUp'
86
- e.preventDefault()
87
- @_focusNext(-1)
88
- when 'Home'
89
- e.preventDefault()
90
- @_focusTrigger(0)
91
- when 'End'
92
- e.preventDefault()
93
- @_focusTrigger(-1)
94
-
95
- _triggers: ->
96
- return [] unless @_content
97
- Array.from(@_content.querySelectorAll('[data-trigger]'))
98
-
99
- _focusNext: (dir) ->
100
- triggers = @_triggers()
101
- idx = triggers.indexOf(document.activeElement)
102
- return if idx is -1
103
- next = (idx + dir) %% triggers.length
104
- triggers[next]?.focus()
105
-
106
- _focusTrigger: (idx) ->
107
- triggers = @_triggers()
108
- target = if idx < 0 then triggers[triggers.length - 1] else triggers[idx]
109
- target?.focus()
110
-
111
- render
112
- div ref: "_content"
113
- slot
@@ -1,96 +0,0 @@
1
- # AlertDialog — accessible headless non-dismissable modal
2
- #
3
- # A Dialog variant that requires explicit user action to close.
4
- # Cannot be dismissed by clicking outside or pressing Escape.
5
- # Use for destructive confirmations, unsaved changes, etc.
6
- # Ships zero CSS.
7
- #
8
- # Usage:
9
- # AlertDialog open <=> showConfirm
10
- # h2 "Delete account?"
11
- # p "This action cannot be undone."
12
- # button @click: (=> showConfirm = false), "Cancel"
13
- # button @click: handleDelete, "Delete"
14
-
15
- alertDialogStack = []
16
-
17
- export AlertDialog = component
18
- @open := false
19
- @initialFocus := null
20
-
21
- _prevFocus = null
22
- _cleanupTrap = null
23
- _scrollY = 0
24
- _id =! "adlg-#{Math.random().toString(36).slice(2, 8)}"
25
-
26
- _wireAria: ->
27
- panel = @_panel
28
- return unless panel
29
- heading = panel.querySelector('h1,h2,h3,h4,h5,h6')
30
- if heading
31
- heading.id ?= "#{_id}-title"
32
- panel.setAttribute 'aria-labelledby', heading.id
33
- desc = panel.querySelector('p')
34
- if desc
35
- desc.id ?= "#{_id}-desc"
36
- panel.setAttribute 'aria-describedby', desc.id
37
-
38
- ~>
39
- if @open
40
- _prevFocus = document.activeElement
41
- _scrollY = window.scrollY
42
- alertDialogStack.push this
43
- document.body.style.position = 'fixed'
44
- document.body.style.top = "-#{_scrollY}px"
45
- document.body.style.width = '100%'
46
-
47
- setTimeout =>
48
- panel = @_panel
49
- if panel
50
- @_wireAria()
51
- if @initialFocus
52
- target = if typeof @initialFocus is 'string' then panel.querySelector(@initialFocus) else @initialFocus
53
- target?.focus()
54
- else
55
- focusable = panel.querySelectorAll 'a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])'
56
- focusable[0]?.focus()
57
- _cleanupTrap = (e) ->
58
- return unless e.key is 'Tab'
59
- list = Array.from(panel.querySelectorAll('a[href],button:not([disabled]),input:not([disabled]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex="-1"])')).filter (f) -> f.offsetParent isnt null
60
- return unless list.length
61
- first = list[0]
62
- last = list[list.length - 1]
63
- if e.shiftKey
64
- if document.activeElement is first then (e.preventDefault(); last.focus())
65
- else
66
- if document.activeElement is last then (e.preventDefault(); first.focus())
67
- panel.addEventListener 'keydown', _cleanupTrap
68
- , 0
69
-
70
- return ->
71
- idx = alertDialogStack.indexOf this
72
- alertDialogStack.splice(idx, 1) if idx >= 0
73
- document.body.style.position = '' unless alertDialogStack.length
74
- document.body.style.top = '' unless alertDialogStack.length
75
- document.body.style.width = '' unless alertDialogStack.length
76
- window.scrollTo 0, _scrollY unless alertDialogStack.length
77
- _prevFocus?.focus()
78
- else
79
- idx = alertDialogStack.indexOf this
80
- alertDialogStack.splice(idx, 1) if idx >= 0
81
- unless alertDialogStack.length
82
- document.body.style.position = ''
83
- document.body.style.top = ''
84
- document.body.style.width = ''
85
- window.scrollTo 0, _scrollY
86
- _prevFocus?.focus()
87
-
88
- close: ->
89
- @open = false
90
- @emit 'close'
91
-
92
- render
93
- if @open
94
- div ref: "_backdrop", $open: true
95
- div ref: "_panel", role: "alertdialog", aria-modal: "true", tabindex: "-1"
96
- slot