@valentinkolb/cloud 0.4.0 → 0.5.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 (194) hide show
  1. package/package.json +18 -6
  2. package/scripts/preload.ts +78 -23
  3. package/src/_internal/define-app.ts +53 -46
  4. package/src/api/accounts-entities.ts +4 -0
  5. package/src/api/admin-core-settings.ts +98 -0
  6. package/src/api/announcements.ts +131 -0
  7. package/src/api/auth/schemas.ts +24 -0
  8. package/src/api/auth.ts +116 -13
  9. package/src/api/index.ts +7 -2
  10. package/src/api/me.ts +203 -14
  11. package/src/api/search/schemas.ts +1 -0
  12. package/src/api/search.ts +62 -8
  13. package/src/config/ssr.ts +2 -9
  14. package/src/contracts/announcements.test.ts +37 -0
  15. package/src/contracts/announcements.ts +121 -0
  16. package/src/contracts/app.ts +2 -0
  17. package/src/contracts/index.ts +3 -2
  18. package/src/contracts/registry.ts +2 -0
  19. package/src/contracts/shared.ts +108 -1
  20. package/src/desktop/index.ts +704 -0
  21. package/src/desktop/solid.tsx +938 -0
  22. package/src/server/api/index.ts +1 -1
  23. package/src/server/api/respond.ts +50 -10
  24. package/src/server/index.ts +44 -38
  25. package/src/server/middleware/auth.ts +98 -9
  26. package/src/server/middleware/index.ts +2 -1
  27. package/src/server/middleware/settings.ts +26 -0
  28. package/src/server/services/access.test.ts +197 -0
  29. package/src/server/services/access.ts +254 -6
  30. package/src/server/services/index.ts +14 -11
  31. package/src/server/services/pagination.ts +22 -0
  32. package/src/server/time.ts +45 -0
  33. package/src/services/account-lifecycle/index.ts +142 -18
  34. package/src/services/accounts/app.ts +658 -170
  35. package/src/services/accounts/authz.test.ts +77 -0
  36. package/src/services/accounts/authz.ts +22 -0
  37. package/src/services/accounts/entities.ts +84 -5
  38. package/src/services/accounts/groups.ts +30 -24
  39. package/src/services/accounts/model.test.ts +30 -0
  40. package/src/services/accounts/switching.test.ts +14 -0
  41. package/src/services/accounts/switching.ts +15 -6
  42. package/src/services/accounts/users.ts +75 -52
  43. package/src/services/announcements/index.test.ts +32 -0
  44. package/src/services/announcements/index.ts +224 -0
  45. package/src/services/audit/index.test.ts +84 -0
  46. package/src/services/audit/index.ts +431 -0
  47. package/src/services/auth-flows/index.ts +9 -2
  48. package/src/services/auth-flows/ipa.ts +47 -7
  49. package/src/services/auth-flows/magic-link.ts +92 -20
  50. package/src/services/auth-flows/password-reset.ts +284 -0
  51. package/src/services/auth-flows/proxy-return.test.ts +24 -0
  52. package/src/services/auth-flows/proxy-return.ts +49 -0
  53. package/src/services/gateway.ts +162 -0
  54. package/src/services/index.ts +44 -2
  55. package/src/services/ipa/effective-groups.test.ts +33 -0
  56. package/src/services/ipa/effective-groups.ts +70 -0
  57. package/src/services/ipa/profile.ts +45 -3
  58. package/src/services/ipa/search.ts +3 -5
  59. package/src/services/ipa/service-account.ts +15 -0
  60. package/src/services/ipa/sync-planning.test.ts +32 -0
  61. package/src/services/ipa/sync-planning.ts +22 -0
  62. package/src/services/ipa/sync.ts +110 -38
  63. package/src/services/notifications/index.ts +82 -11
  64. package/src/services/oauth-tokens.ts +104 -0
  65. package/src/services/postgres.ts +21 -6
  66. package/src/services/providers/local/auth.test.ts +22 -0
  67. package/src/services/providers/local/auth.ts +46 -3
  68. package/src/services/secrets.ts +10 -0
  69. package/src/services/service-account-credentials.test.ts +210 -0
  70. package/src/services/service-account-credentials.ts +715 -0
  71. package/src/services/service-accounts.ts +188 -0
  72. package/src/services/session/index.ts +7 -8
  73. package/src/services/settings/app.ts +4 -20
  74. package/src/services/settings/defaults.ts +79 -22
  75. package/src/services/settings/store.ts +47 -0
  76. package/src/services/weather/forecast.ts +40 -7
  77. package/src/services/webauthn.test.ts +36 -0
  78. package/src/services/webauthn.ts +384 -0
  79. package/src/shared/icons.ts +391 -100
  80. package/src/shared/index.ts +7 -0
  81. package/src/shared/markdown/extensions/code.ts +38 -1
  82. package/src/shared/markdown/extensions/images.ts +39 -3
  83. package/src/shared/markdown/extensions/info-blocks.ts +5 -5
  84. package/src/shared/markdown/extensions/mark.ts +48 -0
  85. package/src/shared/markdown/extensions/sub-sup.ts +60 -0
  86. package/src/shared/markdown/extensions/tables.ts +79 -58
  87. package/src/shared/markdown/formula.test.ts +1089 -0
  88. package/src/shared/markdown/formula.ts +1187 -0
  89. package/src/shared/markdown/index.ts +76 -2
  90. package/src/shared/mock-cover.ts +130 -0
  91. package/src/shared/redirect.test.ts +58 -0
  92. package/src/shared/redirect.ts +56 -0
  93. package/src/shared/theme.test.ts +24 -0
  94. package/src/shared/theme.ts +68 -0
  95. package/src/shared/time.ts +13 -0
  96. package/src/ssr/AdminLayout.tsx +7 -3
  97. package/src/ssr/AdminSidebar.tsx +115 -49
  98. package/src/ssr/AppLaunchpad.island.tsx +176 -0
  99. package/src/ssr/Footer.island.tsx +3 -8
  100. package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
  101. package/src/ssr/GlobalSearchDialog.tsx +545 -117
  102. package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
  103. package/src/ssr/Layout.tsx +74 -66
  104. package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
  105. package/src/ssr/LayoutHelp.tsx +266 -0
  106. package/src/ssr/NavMenu.island.tsx +0 -39
  107. package/src/ssr/ThemeToggleRail.island.tsx +3 -3
  108. package/src/ssr/TimezoneCookie.island.tsx +23 -0
  109. package/src/ssr/islands/index.ts +13 -0
  110. package/src/styles/base-popover.css +5 -2
  111. package/src/styles/effects.css +87 -6
  112. package/src/styles/global.css +146 -9
  113. package/src/styles/input.css +3 -1
  114. package/src/styles/utilities-buttons.css +133 -27
  115. package/src/styles/utilities-code-display.css +67 -0
  116. package/src/styles/utilities-completion.css +223 -0
  117. package/src/styles/utilities-detail.css +73 -0
  118. package/src/styles/utilities-feedback.css +16 -15
  119. package/src/styles/utilities-layout.css +42 -2
  120. package/src/styles/utilities-markdown-editor.css +472 -0
  121. package/src/styles/utilities-navigation.css +63 -8
  122. package/src/styles/utilities-script.css +84 -0
  123. package/src/styles/utilities-table-tile.css +229 -0
  124. package/src/types/ambient.d.ts +9 -0
  125. package/src/ui/completion/behaviors.test.ts +95 -0
  126. package/src/ui/completion/behaviors.ts +205 -0
  127. package/src/ui/completion/engine.ts +368 -0
  128. package/src/ui/completion/index.ts +40 -0
  129. package/src/ui/completion/overlay.ts +92 -0
  130. package/src/ui/dialog-core.ts +173 -45
  131. package/src/ui/filter/FilterChip.tsx +42 -40
  132. package/src/ui/index.ts +11 -12
  133. package/src/ui/input/AutocompleteEditor.tsx +656 -0
  134. package/src/ui/input/CheckboxCard.tsx +91 -0
  135. package/src/ui/input/Combobox.tsx +375 -0
  136. package/src/ui/input/DatePicker.tsx +846 -0
  137. package/src/ui/input/DateTimeInput.tsx +29 -4
  138. package/src/ui/input/FileDropzone.tsx +116 -0
  139. package/src/ui/input/IconInput.tsx +116 -0
  140. package/src/ui/input/ImageInput.tsx +19 -2
  141. package/src/ui/input/MultiSelectInput.tsx +448 -0
  142. package/src/ui/input/NumberInput.tsx +417 -61
  143. package/src/ui/input/SegmentedControl.tsx +2 -2
  144. package/src/ui/input/Select.tsx +172 -10
  145. package/src/ui/input/Slider.tsx +3 -4
  146. package/src/ui/input/Switch.tsx +3 -2
  147. package/src/ui/input/TemplateEditor.tsx +212 -0
  148. package/src/ui/input/TextInput.tsx +144 -13
  149. package/src/ui/input/index.ts +53 -8
  150. package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
  151. package/src/ui/input/markdown/Toolbar.tsx +90 -0
  152. package/src/ui/input/markdown/actions.ts +233 -0
  153. package/src/ui/input/markdown/active-formats.ts +94 -0
  154. package/src/ui/input/markdown/behaviors.ts +193 -0
  155. package/src/ui/input/markdown/code-zone.ts +23 -0
  156. package/src/ui/input/markdown/highlight.ts +316 -0
  157. package/src/ui/layout.ts +22 -0
  158. package/src/ui/misc/AppOverview.tsx +105 -0
  159. package/src/ui/misc/AppWorkspace.tsx +607 -0
  160. package/src/ui/misc/Calendar.tsx +1291 -0
  161. package/src/ui/misc/Chart.tsx +162 -0
  162. package/src/ui/misc/CodeDisplay.tsx +54 -0
  163. package/src/ui/misc/ContextMenu.tsx +2 -2
  164. package/src/ui/misc/DataTable.tsx +269 -0
  165. package/src/ui/misc/DockWorkspace.tsx +425 -0
  166. package/src/ui/misc/Docs.tsx +153 -0
  167. package/src/ui/misc/Dropdown.tsx +2 -2
  168. package/src/ui/misc/EntitySearch.tsx +260 -129
  169. package/src/ui/misc/LinkCard.tsx +14 -2
  170. package/src/ui/misc/LogEntriesTable.tsx +34 -31
  171. package/src/ui/misc/Pagination.tsx +31 -12
  172. package/src/ui/misc/PanelDialog.tsx +109 -0
  173. package/src/ui/misc/Panes.tsx +873 -0
  174. package/src/ui/misc/PermissionEditor.tsx +358 -262
  175. package/src/ui/misc/Placeholder.tsx +40 -0
  176. package/src/ui/misc/ProgressBar.tsx +1 -1
  177. package/src/ui/misc/ResourceApiKeys.tsx +260 -0
  178. package/src/ui/misc/SettingsModal.tsx +150 -0
  179. package/src/ui/misc/StatCell.tsx +182 -40
  180. package/src/ui/misc/StatGrid.tsx +149 -0
  181. package/src/ui/misc/StructuredDataPreview.tsx +107 -0
  182. package/src/ui/misc/code-highlight.ts +213 -0
  183. package/src/ui/misc/index.ts +93 -12
  184. package/src/ui/prompts.tsx +362 -312
  185. package/src/ui/toast.ts +384 -0
  186. package/src/ui/widgets/Widget.tsx +12 -4
  187. package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
  188. package/src/ui/ipa/GroupView.tsx +0 -36
  189. package/src/ui/ipa/LoginBtn.tsx +0 -16
  190. package/src/ui/ipa/UserView.tsx +0 -58
  191. package/src/ui/ipa/index.ts +0 -4
  192. package/src/ui/navigation.ts +0 -32
  193. package/src/ui/sidebar.tsx +0 -468
  194. /package/src/ui/{ipa → misc}/Avatar.tsx +0 -0
@@ -0,0 +1,84 @@
1
+ /* `\`\`\`script` block styling — both edit-mode (CodeMirror widget)
2
+ and read-mode (rendered HTML).
3
+
4
+ Markup contract:
5
+ <div class="md-script-block" data-script-source="<base64>"> ← read-mode wrapper
6
+ <pre class="md-script-source"> ... source ... </pre>
7
+ <div class="md-script-output"></div>
8
+ </div>
9
+
10
+ In edit-mode the CM6 extension just emits a bare
11
+ `<div class="md-script-output">` (no wrapper); the source stays
12
+ visible as the underlying fenced code.
13
+
14
+ Read-mode-active state: when `enhanceReadModeScripts` runs the
15
+ block, it adds `.md-script-active` to the wrapper, which hides
16
+ the source `<pre>` so the user only sees the widget output. The
17
+ source is preserved for view-source / a11y. */
18
+
19
+ .md-script-block.md-script-active .md-script-source {
20
+ display: none;
21
+ }
22
+
23
+ /* The output container itself — used by both edit + read modes.
24
+ Slight padding so widgets don't touch the surrounding text /
25
+ code-fence edges; no border (the rendered widget supplies its
26
+ own affordances). */
27
+ .md-script-output {
28
+ padding: 0.25rem 0;
29
+ /* Empty output (e.g. a script that only does background work)
30
+ should collapse to zero height — no minimum height here. */
31
+ }
32
+
33
+ /* Error path — `runner.ts:renderError` adds the `md-script-error`
34
+ class to the output container alongside the rendered error
35
+ block. The class is just a marker today (the inline error block
36
+ carries its own styles) but reserved for future use, e.g. a
37
+ notification dot in the script-block frame. */
38
+ .md-script-output.md-script-error {
39
+ /* No additional rules yet; reserved. */
40
+ }
41
+
42
+ /* Script UI buttons use the shared button utilities, with a subtle
43
+ dynamic tint so they read as actions inside generated script output
44
+ rather than tag-like pills. */
45
+ .md-script-button.btn-primary {
46
+ border-color: color-mix(in oklab, rgb(59 130 246) 72%, transparent);
47
+ background-image:
48
+ linear-gradient(180deg, rgb(255 255 255 / 0.22), rgb(255 255 255 / 0) 42%),
49
+ linear-gradient(135deg, rgb(59 130 246), rgb(34 197 94));
50
+ color: white;
51
+ box-shadow:
52
+ inset 0 1px 0 rgb(255 255 255 / 0.34),
53
+ inset 0 -1px 1px rgb(0 0 0 / 0.2);
54
+ }
55
+
56
+ .md-script-button.btn-primary:hover {
57
+ border-color: color-mix(in oklab, rgb(59 130 246) 88%, white 12%);
58
+ background-image:
59
+ linear-gradient(180deg, rgb(255 255 255 / 0.28), rgb(255 255 255 / 0) 44%),
60
+ linear-gradient(135deg, color-mix(in oklab, rgb(59 130 246) 92%, black), color-mix(in oklab, rgb(34 197 94) 92%, black));
61
+ color: white;
62
+ filter: saturate(1.08);
63
+ }
64
+
65
+ .md-script-button.btn-primary:active {
66
+ box-shadow:
67
+ inset 0 2px 4px rgb(0 0 0 / 0.28),
68
+ inset 0 1px 1px rgb(0 0 0 / 0.14);
69
+ }
70
+
71
+ .dark .md-script-button.btn-primary {
72
+ border-color: color-mix(in oklab, rgb(96 165 250) 62%, transparent);
73
+ background-image:
74
+ linear-gradient(180deg, rgb(255 255 255 / 0.16), rgb(255 255 255 / 0) 42%),
75
+ linear-gradient(135deg, rgb(37 99 235), rgb(20 184 166));
76
+ color: white;
77
+ }
78
+
79
+ .dark .md-script-button.btn-primary:hover {
80
+ background-image:
81
+ linear-gradient(180deg, rgb(255 255 255 / 0.22), rgb(255 255 255 / 0) 44%),
82
+ linear-gradient(135deg, rgb(29 78 216), rgb(13 148 136));
83
+ color: white;
84
+ }
@@ -0,0 +1,229 @@
1
+ /* Tile-style markdown tables — every cell is a self-contained rounded
2
+ rectangle on a soft background, with 4px gaps between. Used by both
3
+ the server-side `marked` renderer and the CodeMirror table widget so
4
+ read-mode and edit-mode look identical.
5
+
6
+ Three-layer markup:
7
+ <div class="md-table-wrap"> ← horizontal scroll owner
8
+ <table class="md-table"> ← layout reset + height-1px chain
9
+ <thead><tr><th>
10
+ <span class="md-table-cell"> ← visible tile (bg + radius)
11
+ See https://www.notion.so etc — same pattern for the same reason: the
12
+ inner span ensures uniform border-radius on every cell with no
13
+ first/last edge cases. */
14
+
15
+ .md-table-wrap {
16
+ overflow-x: auto;
17
+ margin-top: 0.5rem;
18
+ margin-bottom: 0.5rem;
19
+ }
20
+
21
+ /* `height: 1px` is the trick — it gives the table an explicit height
22
+ reference so children's `height: 100%` resolves. The table grows past
23
+ 1px to fit content; the 1px purely enables percentage children. */
24
+ .md-table {
25
+ width: 100%;
26
+ border-collapse: collapse;
27
+ height: 1px;
28
+ border: 0;
29
+ }
30
+
31
+ /* `@tailwindcss/typography` (`prose` class) puts a `border-bottom` on
32
+ `thead` AND every `tbody tr`. The tile look already separates rows
33
+ visually via the 4px gap + per-cell radius, so we reset all
34
+ table/row/cell borders to keep the surface clean. Our utility CSS
35
+ is loaded AFTER the typography plugin in `global.css` → same
36
+ specificity, later wins. */
37
+ .md-table thead,
38
+ .md-table tbody,
39
+ .md-table thead tr,
40
+ .md-table tbody tr,
41
+ .md-table th,
42
+ .md-table td {
43
+ border: 0;
44
+ }
45
+
46
+ .md-table th,
47
+ .md-table td {
48
+ padding: 0;
49
+ height: 100%;
50
+ vertical-align: top;
51
+ /* Per-cell minimum width — the table's own `width: 100%` keeps it
52
+ fitted to the wrap when content is narrow, but `min-width` here
53
+ gives every column a readable floor and triggers the wrap div's
54
+ `overflow-x: auto` once the sum exceeds the available width. */
55
+ min-width: 7rem;
56
+ }
57
+
58
+ /* Gaps via adjacent-sibling padding (NOT border-spacing — that would
59
+ add outer space and trigger spurious horizontal scroll). */
60
+ .md-table th + th,
61
+ .md-table td + td {
62
+ padding-left: 4px;
63
+ }
64
+
65
+ .md-table tbody tr + tr > *,
66
+ .md-table thead + tbody tr:first-child > * {
67
+ padding-top: 4px;
68
+ }
69
+
70
+ /* Body cells: filled zinc tiles. They intentionally avoid hairline
71
+ borders; the fill + crisp inset edge separates cells from the page
72
+ without reading like a button. */
73
+ .md-table-cell {
74
+ display: flex;
75
+ align-items: flex-start;
76
+ justify-content: flex-start;
77
+ gap: 0.35rem;
78
+ height: 100%;
79
+ box-sizing: border-box;
80
+ padding: 0.46rem 0.68rem 0.52rem;
81
+ border: 0;
82
+ border-radius: 0.5rem;
83
+ background: color-mix(in oklab, rgb(244 244 245) 78%, white);
84
+ box-shadow:
85
+ inset 0 1px 0 rgb(255 255 255 / 0.56),
86
+ inset 0 -1px 0 rgb(24 24 27 / 0.035);
87
+ font-size: 0.8125rem;
88
+ line-height: 1.35;
89
+ color: rgb(24 24 27); /* zinc-900 */
90
+ }
91
+
92
+ .dark .md-table-cell {
93
+ background: color-mix(in oklab, rgb(24 24 27) 88%, rgb(39 39 42));
94
+ box-shadow:
95
+ inset 0 1px 0 rgb(255 255 255 / 0.045),
96
+ inset 0 -1px 0 rgb(0 0 0 / 0.26);
97
+ color: rgb(244 244 245); /* zinc-100 */
98
+ }
99
+
100
+ /* Header tiles: one shade darker than body + medium weight. Subtle
101
+ bg-shift only — no accent line, no caps, no shouting. Headers also
102
+ get `nowrap` so they don't ever break char-by-char even if the
103
+ widest body cell still pushes the column past the 7 rem floor. */
104
+ .md-table thead .md-table-cell {
105
+ background: color-mix(in oklab, rgb(244 244 245) 74%, rgb(228 228 231));
106
+ font-weight: 700;
107
+ white-space: nowrap;
108
+ }
109
+
110
+ .dark .md-table thead .md-table-cell {
111
+ background: color-mix(in oklab, rgb(39 39 42) 88%, rgb(24 24 27));
112
+ }
113
+
114
+ /* Per-column alignment from `:---:` syntax. Marked / our parser map
115
+ that to one of these classes; default is left. */
116
+ .md-table-cell.md-align-right {
117
+ text-align: right;
118
+ justify-content: flex-end;
119
+ }
120
+
121
+ .md-table-cell.md-align-center {
122
+ text-align: center;
123
+ justify-content: center;
124
+ }
125
+
126
+ /* "Total" / summary rows — when ≥50 % of a row's formula cells are
127
+ aggregates (`SUM`, `AVG`, …) the renderer tags the `<tr>` with this
128
+ class. Body cells in such rows render with bumped weight + a stable
129
+ tile bg one shade darker than the regular zinc-50 floor, so the
130
+ summary visually anchors the bottom of the table. */
131
+ .md-table tbody tr.md-table-total-row .md-table-cell {
132
+ font-weight: 600;
133
+ background: color-mix(in oklab, rgb(244 244 245) 74%, rgb(228 228 231));
134
+ }
135
+
136
+ .dark .md-table tbody tr.md-table-total-row .md-table-cell {
137
+ background: color-mix(in oklab, rgb(39 39 42) 88%, rgb(24 24 27));
138
+ }
139
+
140
+ /* Formula-OK cells: blue text + `ti-math-function` icon prefix so the
141
+ reader can tell which values are computed vs hand-typed. Hover
142
+ title shows the original formula. */
143
+ .md-table-cell.md-formula-ok {
144
+ color: rgb(37 99 235); /* blue-600 */
145
+ cursor: help;
146
+ }
147
+
148
+ .md-table-cell.md-formula-ok > i.ti {
149
+ margin-right: 0.25rem;
150
+ font-size: 0.75rem;
151
+ opacity: 0.7;
152
+ line-height: 1.35;
153
+ transform: translateY(0.04em);
154
+ }
155
+
156
+ .dark .md-table-cell.md-formula-ok {
157
+ color: rgb(96 165 250); /* blue-400 */
158
+ }
159
+
160
+ /* CodeMirror inline live-preview after each `=...` cell when the
161
+ table source is visible (cursor inside the table). Greyed out so
162
+ it's clearly virtual / not part of the markdown. Errors switch to
163
+ red to mirror the read-mode error styling.
164
+
165
+ The OK variant is click-to-copy (handled in tables.ts). Hover gets
166
+ underline + pointer cursor as the affordance. Errors stay inert —
167
+ `pointer-events: none` blocks any interaction, leaving CM to handle
168
+ clicks at the underlying source position. */
169
+ .cm-formula-preview {
170
+ color: rgb(113 113 122); /* zinc-500 */
171
+ font-style: italic;
172
+ font-size: 0.85em;
173
+ opacity: 0.7;
174
+ user-select: none;
175
+ }
176
+
177
+ .cm-formula-preview:hover {
178
+ text-decoration: underline;
179
+ cursor: pointer;
180
+ opacity: 1;
181
+ }
182
+
183
+ .dark .cm-formula-preview {
184
+ color: rgb(161 161 170); /* zinc-400 */
185
+ }
186
+
187
+ .cm-formula-preview-error {
188
+ color: rgb(220 38 38); /* red-600 */
189
+ font-style: normal;
190
+ pointer-events: none;
191
+ }
192
+
193
+ .cm-formula-preview-error:hover {
194
+ text-decoration: none;
195
+ cursor: default;
196
+ }
197
+
198
+ .dark .cm-formula-preview-error {
199
+ color: rgb(248 113 113); /* red-400 */
200
+ }
201
+
202
+ /* Brief post-copy state — green, no italic, stable through hover. */
203
+ .cm-formula-preview-copied,
204
+ .cm-formula-preview-copied:hover {
205
+ color: rgb(22 163 74); /* green-600 */
206
+ font-style: normal;
207
+ text-decoration: none;
208
+ opacity: 1;
209
+ }
210
+
211
+ .dark .cm-formula-preview-copied,
212
+ .dark .cm-formula-preview-copied:hover {
213
+ color: rgb(74 222 128); /* green-400 */
214
+ }
215
+
216
+ /* Formula-error cells render the original formula in red so the user
217
+ spots the broken cell immediately. The full diagnostic (with
218
+ "did-you-mean" suggestion when available) lives in the `title`
219
+ attribute → hover tooltip. */
220
+ .md-table-cell.md-formula-error {
221
+ color: rgb(220 38 38); /* red-600 */
222
+ background-color: rgb(254 242 242); /* red-50 */
223
+ cursor: help;
224
+ }
225
+
226
+ .dark .md-table-cell.md-formula-error {
227
+ color: rgb(248 113 113); /* red-400 */
228
+ background-color: rgb(127 29 29 / 0.25); /* red-900 / 25% */
229
+ }
@@ -6,3 +6,12 @@ declare module "*.md" {
6
6
  const content: string;
7
7
  export default content;
8
8
  }
9
+
10
+ declare module "@babel/core" {
11
+ export const types: typeof import("@babel/types");
12
+
13
+ export function transformAsync(
14
+ source: string,
15
+ options: Record<string, unknown>,
16
+ ): Promise<{ code?: string } | null>;
17
+ }
@@ -0,0 +1,95 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import type { QueryContext, Suggestion } from "./engine";
3
+ import { applySuggestion, resetCompletionState, tryRestore } from "./behaviors";
4
+
5
+ type FakeTextarea = HTMLTextAreaElement & {
6
+ value: string;
7
+ selectionStart: number;
8
+ selectionEnd: number;
9
+ setSelectionRange: (start: number, end: number) => void;
10
+ };
11
+
12
+ let activeTextarea: FakeTextarea | null = null;
13
+ const originalDocument = globalThis.document;
14
+
15
+ const installExecCommand = () => {
16
+ globalThis.document = {
17
+ execCommand: (_command: string, _showUi: boolean, value: string) => {
18
+ if (!activeTextarea) return false;
19
+ const start = activeTextarea.selectionStart;
20
+ const end = activeTextarea.selectionEnd;
21
+ activeTextarea.value = `${activeTextarea.value.slice(0, start)}${value}${activeTextarea.value.slice(end)}`;
22
+ activeTextarea.selectionStart = start + value.length;
23
+ activeTextarea.selectionEnd = start + value.length;
24
+ return true;
25
+ },
26
+ } as Document;
27
+ };
28
+
29
+ const textarea = (value: string): FakeTextarea =>
30
+ ({
31
+ value,
32
+ selectionStart: 0,
33
+ selectionEnd: 0,
34
+ setSelectionRange(start: number, end: number) {
35
+ activeTextarea = this;
36
+ this.selectionStart = start;
37
+ this.selectionEnd = end;
38
+ },
39
+ }) as FakeTextarea;
40
+
41
+ const ctx = (text: string): QueryContext => ({
42
+ start: 0,
43
+ end: text.length,
44
+ text,
45
+ query: text,
46
+ completion: { suggest: () => [] },
47
+ });
48
+
49
+ const fieldSuggestion: Suggestion = {
50
+ text: "Units",
51
+ expansion: "#Wf87H",
52
+ label: "Units",
53
+ };
54
+
55
+ afterEach(() => {
56
+ resetCompletionState();
57
+ activeTextarea = null;
58
+ globalThis.document = originalDocument;
59
+ });
60
+
61
+ describe("completion behaviours", () => {
62
+ test("accepted expansion restores to display text by default", () => {
63
+ installExecCommand();
64
+ const el = textarea("Units");
65
+
66
+ expect(applySuggestion(el, ctx("Units"), fieldSuggestion)).toBe(true);
67
+ expect(el.value).toBe("#Wf87H ");
68
+
69
+ expect(tryRestore(el)).toBe(true);
70
+ expect(el.value).toBe("Units ");
71
+ });
72
+
73
+ test("accepted expansion can opt out of Backspace restore tracking", () => {
74
+ installExecCommand();
75
+ const el = textarea("Units");
76
+
77
+ expect(applySuggestion(el, ctx("Units"), fieldSuggestion, { trackExpansion: false })).toBe(true);
78
+ expect(el.value).toBe("#Wf87H ");
79
+
80
+ expect(tryRestore(el)).toBe(false);
81
+ expect(el.value).toBe("#Wf87H ");
82
+ });
83
+
84
+ test("applies explicit suggestion text edits", () => {
85
+ installExecCommand();
86
+ const el = textarea("from table Ord\nselect Amount");
87
+ const suggestion: Suggestion = {
88
+ text: "Orders",
89
+ textEdit: { start: "from table ".length, end: "from table Ord".length, text: "Orders" },
90
+ };
91
+
92
+ expect(applySuggestion(el, ctx("Ord"), suggestion)).toBe(true);
93
+ expect(el.value).toBe("from table Orders\nselect Amount");
94
+ });
95
+ });
@@ -0,0 +1,205 @@
1
+ /**
2
+ * DOM-bound completion behaviours. Each function mutates a textarea
3
+ * via `document.execCommand("insertText", …)` so the native undo
4
+ * stack stays usable.
5
+ *
6
+ * State lives at module scope (`lastExpansion`, `suppressNextExpansion`)
7
+ * because only one cursor / one expansion exists at a time. Module
8
+ * scope keeps the API stateless from the caller's perspective.
9
+ *
10
+ * Re-entrancy: `execCommand` fires a synchronous `input` event. If
11
+ * `tryExpand` runs in that handler and itself calls `execCommand`, we
12
+ * arm `suppressNextExpansion` so the NEXT input event (the one
13
+ * triggered by the expansion's own insertion) skips the expand check
14
+ * — otherwise an expansion's tail could cascade into another match.
15
+ */
16
+
17
+ import { type Completion, type QueryContext, type Suggestion, type SuggestContext, TRIGGER_CHARS, WORD_CHAR, suggestSync } from "./engine";
18
+
19
+ type LastExpansion = {
20
+ textarea: HTMLTextAreaElement;
21
+ startOffset: number;
22
+ originalWord: string;
23
+ triggerChar: string;
24
+ expansion: string;
25
+ };
26
+
27
+ let lastExpansion: LastExpansion | null = null;
28
+ let suppressNextExpansion = false;
29
+
30
+ /** Reset module-level state. Call on editor blur or unmount so a
31
+ * stale expansion record doesn't leak across editor instances. */
32
+ export const resetCompletionState = (): void => {
33
+ lastExpansion = null;
34
+ suppressNextExpansion = false;
35
+ };
36
+
37
+ /** Find the first sync suggestion whose `text === word` and that
38
+ * carries an `expansion`. Returns null when nothing matches OR when
39
+ * no plain completion provides a sync result. */
40
+ const findExpansion = (
41
+ word: string,
42
+ completions: Completion[],
43
+ ctx: SuggestContext,
44
+ ): { suggestion: Suggestion; completion: Completion } | null => {
45
+ for (const c of completions) {
46
+ if (c.trigger !== undefined) continue;
47
+ const list = suggestSync(c, word, ctx);
48
+ if (!list) continue;
49
+ const exact = list.find((s) => s.text === word && s.expansion && s.expansion !== word);
50
+ if (exact) return { suggestion: exact, completion: c };
51
+ const lower = word.toLowerCase();
52
+ const ci = list.find((s) => s.text.toLowerCase() === lower && s.expansion && s.expansion !== word);
53
+ if (ci) return { suggestion: ci, completion: c };
54
+ }
55
+ return null;
56
+ };
57
+
58
+ export type TryExpandOptions = {
59
+ /** Optional predicate to suppress expansion at a position (e.g.
60
+ * markdown code spans). Default: never suppress. */
61
+ isExcluded?: (text: string, pos: number) => boolean;
62
+ };
63
+
64
+ /**
65
+ * If the user just typed a word-boundary char AND the preceding word
66
+ * has a registered `expansion` AND the position passes `isExcluded`,
67
+ * replace the word with its expansion via `execCommand`. Returns true
68
+ * if an expansion happened — caller should treat that as "consumed"
69
+ * and not run the normal input pipeline.
70
+ */
71
+ export const tryExpand = (
72
+ textarea: HTMLTextAreaElement,
73
+ completions: Completion[] | undefined,
74
+ options: TryExpandOptions = {},
75
+ ): boolean => {
76
+ if (suppressNextExpansion) {
77
+ suppressNextExpansion = false;
78
+ return false;
79
+ }
80
+ if (!completions || completions.length === 0) return false;
81
+
82
+ const value = textarea.value;
83
+ const caret = textarea.selectionStart;
84
+ if (caret === 0 || caret !== textarea.selectionEnd) return false;
85
+
86
+ const triggerChar = value[caret - 1];
87
+ if (!triggerChar || !TRIGGER_CHARS.has(triggerChar)) return false;
88
+
89
+ let wordEnd = caret - 1;
90
+ let wordStart = wordEnd;
91
+ while (wordStart > 0 && WORD_CHAR.test(value[wordStart - 1]!)) wordStart--;
92
+ if (wordStart === wordEnd) return false;
93
+
94
+ const word = value.slice(wordStart, wordEnd);
95
+ if (options.isExcluded?.(value, wordStart)) return false;
96
+
97
+ const ctx: SuggestContext = { fullText: value, caret, tokenStart: wordStart };
98
+ const hit = findExpansion(word, completions, ctx);
99
+ if (!hit) return false;
100
+ const { suggestion } = hit;
101
+ if (!suggestion.expansion || suggestion.expansion === word) return false;
102
+
103
+ const replacement = suggestion.expansion + triggerChar;
104
+ textarea.setSelectionRange(wordStart, caret);
105
+ document.execCommand("insertText", false, replacement);
106
+
107
+ lastExpansion = {
108
+ textarea,
109
+ startOffset: wordStart,
110
+ originalWord: word,
111
+ triggerChar,
112
+ expansion: suggestion.expansion,
113
+ };
114
+ suppressNextExpansion = true;
115
+ return true;
116
+ };
117
+
118
+ /**
119
+ * Backspace IMMEDIATELY after an expansion reverts to the original
120
+ * short form + trigger. Returns true if a restore happened — caller
121
+ * should `preventDefault` to suppress the native backspace.
122
+ */
123
+ export const tryRestore = (textarea: HTMLTextAreaElement): boolean => {
124
+ const last = lastExpansion;
125
+ if (!last || last.textarea !== textarea) return false;
126
+
127
+ const value = textarea.value;
128
+ const tail = last.startOffset + last.expansion.length + last.triggerChar.length;
129
+ if (textarea.selectionStart !== tail || textarea.selectionEnd !== tail) return false;
130
+
131
+ const expected = last.expansion + last.triggerChar;
132
+ if (value.slice(last.startOffset, tail) !== expected) return false;
133
+
134
+ suppressNextExpansion = true;
135
+ textarea.setSelectionRange(last.startOffset, tail);
136
+ document.execCommand("insertText", false, last.originalWord + last.triggerChar);
137
+
138
+ lastExpansion = null;
139
+ return true;
140
+ };
141
+
142
+ /**
143
+ * Insert a selected suggestion at the query position. Replaces the
144
+ * typed prefix with either `suggestion.expansion` (when present —
145
+ * abbreviation-style: Tab is shortcut for "type the rest + space")
146
+ * or `suggestion.text` (triggered completions).
147
+ *
148
+ * Always appends a space UNLESS the next char is already a literal
149
+ * space / tab. Records `lastExpansion` for direct expansions so
150
+ * Backspace afterwards reverts to the short form, matching the
151
+ * contract of manual word-boundary expansion.
152
+ *
153
+ * The expansion is done as a SINGLE `execCommand` rather than
154
+ * inserting the abbreviation and waiting for `tryExpand` to fire on
155
+ * the trailing space — nested execCommand inside an input handler is
156
+ * unreliable in some browsers (the inner call's selection-replace can
157
+ * be silently dropped, leaving the abbreviation selected).
158
+ */
159
+ export const applySuggestion = (
160
+ textarea: HTMLTextAreaElement,
161
+ ctx: QueryContext,
162
+ suggestion: Suggestion,
163
+ options: { trackExpansion?: boolean } = {},
164
+ ): boolean => {
165
+ if (suggestion.textEdit) {
166
+ const { start, end, text } = suggestion.textEdit;
167
+ if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end < start || end > textarea.value.length) {
168
+ return false;
169
+ }
170
+ if (textarea.value.slice(start, end) === text) return false;
171
+ textarea.setSelectionRange(start, end);
172
+ document.execCommand("insertText", false, text);
173
+ return true;
174
+ }
175
+
176
+ const baseText = suggestion.expansion ?? suggestion.text;
177
+ if (baseText === ctx.text) return false;
178
+
179
+ // Don't append a space when the suggestion ends with an opening
180
+ // bracket — that's a signal the user wants to keep typing INSIDE
181
+ // (e.g. `=SUM(` → cursor right after `(`, not after a space). A
182
+ // trailing space would also break chained completions: `=SUM( r`
183
+ // can't activate the `(` trigger because the trigger char is no
184
+ // longer directly before the word.
185
+ const nextChar = textarea.value[ctx.end];
186
+ const alreadySeparated = nextChar === " " || nextChar === "\t";
187
+ const opensScope = /[([{]$/.test(baseText);
188
+ const shouldAppendSpace = suggestion.appendSpace ?? true;
189
+ const insertText = alreadySeparated || opensScope || !shouldAppendSpace ? baseText : baseText + " ";
190
+
191
+ textarea.setSelectionRange(ctx.start, ctx.end);
192
+ document.execCommand("insertText", false, insertText);
193
+
194
+ if ((options.trackExpansion ?? true) && suggestion.expansion !== undefined && suggestion.expansion !== suggestion.text) {
195
+ lastExpansion = {
196
+ textarea,
197
+ startOffset: ctx.start,
198
+ originalWord: suggestion.text,
199
+ triggerChar: alreadySeparated || opensScope || !shouldAppendSpace ? "" : " ",
200
+ expansion: suggestion.expansion,
201
+ };
202
+ suppressNextExpansion = true;
203
+ }
204
+ return true;
205
+ };