@valentinkolb/cloud 0.4.0 → 0.5.0
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.
- package/package.json +18 -6
- package/scripts/preload.ts +78 -23
- package/src/_internal/define-app.ts +53 -46
- package/src/api/accounts-entities.ts +4 -0
- package/src/api/admin-core-settings.ts +98 -0
- package/src/api/announcements.ts +131 -0
- package/src/api/auth/schemas.ts +24 -0
- package/src/api/auth.ts +113 -10
- package/src/api/index.ts +7 -2
- package/src/api/me.ts +203 -14
- package/src/api/search/schemas.ts +1 -0
- package/src/api/search.ts +62 -8
- package/src/config/ssr.ts +2 -9
- package/src/contracts/announcements.test.ts +37 -0
- package/src/contracts/announcements.ts +121 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +3 -2
- package/src/contracts/registry.ts +2 -0
- package/src/contracts/shared.ts +108 -1
- package/src/desktop/index.ts +704 -0
- package/src/desktop/solid.tsx +938 -0
- package/src/server/api/index.ts +1 -1
- package/src/server/api/respond.ts +50 -10
- package/src/server/index.ts +44 -38
- package/src/server/middleware/auth.ts +98 -9
- package/src/server/middleware/index.ts +2 -1
- package/src/server/middleware/settings.ts +26 -0
- package/src/server/services/access.test.ts +197 -0
- package/src/server/services/access.ts +254 -6
- package/src/server/services/index.ts +14 -11
- package/src/server/services/pagination.ts +22 -0
- package/src/server/time.ts +45 -0
- package/src/services/account-lifecycle/index.ts +142 -18
- package/src/services/accounts/app.ts +658 -170
- package/src/services/accounts/authz.test.ts +77 -0
- package/src/services/accounts/authz.ts +22 -0
- package/src/services/accounts/entities.ts +84 -5
- package/src/services/accounts/groups.ts +30 -24
- package/src/services/accounts/model.test.ts +30 -0
- package/src/services/accounts/switching.test.ts +14 -0
- package/src/services/accounts/switching.ts +15 -6
- package/src/services/accounts/users.ts +75 -52
- package/src/services/announcements/index.test.ts +32 -0
- package/src/services/announcements/index.ts +224 -0
- package/src/services/audit/index.test.ts +84 -0
- package/src/services/audit/index.ts +431 -0
- package/src/services/auth-flows/index.ts +9 -2
- package/src/services/auth-flows/ipa.ts +0 -2
- package/src/services/auth-flows/magic-link.ts +3 -2
- package/src/services/auth-flows/password-reset.ts +284 -0
- package/src/services/auth-flows/proxy-return.test.ts +24 -0
- package/src/services/auth-flows/proxy-return.ts +49 -0
- package/src/services/gateway.ts +162 -0
- package/src/services/index.ts +44 -2
- package/src/services/ipa/effective-groups.test.ts +33 -0
- package/src/services/ipa/effective-groups.ts +70 -0
- package/src/services/ipa/profile.ts +45 -3
- package/src/services/ipa/search.ts +3 -5
- package/src/services/ipa/service-account.ts +15 -0
- package/src/services/ipa/sync-planning.test.ts +32 -0
- package/src/services/ipa/sync-planning.ts +22 -0
- package/src/services/ipa/sync.ts +110 -38
- package/src/services/oauth-tokens.ts +104 -0
- package/src/services/postgres.ts +21 -6
- package/src/services/providers/local/auth.test.ts +22 -0
- package/src/services/providers/local/auth.ts +46 -3
- package/src/services/secrets.ts +10 -0
- package/src/services/service-account-credentials.test.ts +210 -0
- package/src/services/service-account-credentials.ts +715 -0
- package/src/services/service-accounts.ts +188 -0
- package/src/services/session/index.ts +7 -8
- package/src/services/settings/app.ts +4 -20
- package/src/services/settings/defaults.ts +64 -22
- package/src/services/settings/store.ts +47 -0
- package/src/services/weather/forecast.ts +40 -7
- package/src/services/webauthn.test.ts +36 -0
- package/src/services/webauthn.ts +384 -0
- package/src/shared/icons.ts +391 -100
- package/src/shared/index.ts +7 -0
- package/src/shared/markdown/extensions/code.ts +38 -1
- package/src/shared/markdown/extensions/images.ts +39 -3
- package/src/shared/markdown/extensions/info-blocks.ts +5 -5
- package/src/shared/markdown/extensions/mark.ts +48 -0
- package/src/shared/markdown/extensions/sub-sup.ts +60 -0
- package/src/shared/markdown/extensions/tables.ts +79 -58
- package/src/shared/markdown/formula.test.ts +1089 -0
- package/src/shared/markdown/formula.ts +1187 -0
- package/src/shared/markdown/index.ts +76 -2
- package/src/shared/mock-cover.ts +130 -0
- package/src/shared/redirect.test.ts +49 -0
- package/src/shared/redirect.ts +52 -0
- package/src/shared/theme.test.ts +24 -0
- package/src/shared/theme.ts +68 -0
- package/src/shared/time.ts +13 -0
- package/src/ssr/AdminLayout.tsx +7 -3
- package/src/ssr/AdminSidebar.tsx +115 -49
- package/src/ssr/AppLaunchpad.island.tsx +176 -0
- package/src/ssr/Footer.island.tsx +3 -8
- package/src/ssr/GlobalAnnouncements.island.tsx +141 -0
- package/src/ssr/GlobalSearchDialog.tsx +545 -117
- package/src/ssr/HotkeysHelpRail.island.tsx +3 -70
- package/src/ssr/Layout.tsx +74 -66
- package/src/ssr/LayoutBreadcrumbs.island.tsx +44 -0
- package/src/ssr/LayoutHelp.tsx +266 -0
- package/src/ssr/NavMenu.island.tsx +0 -39
- package/src/ssr/ThemeToggleRail.island.tsx +3 -3
- package/src/ssr/TimezoneCookie.island.tsx +23 -0
- package/src/ssr/islands/index.ts +13 -0
- package/src/styles/base-popover.css +5 -2
- package/src/styles/effects.css +87 -6
- package/src/styles/global.css +146 -9
- package/src/styles/input.css +3 -1
- package/src/styles/utilities-buttons.css +133 -27
- package/src/styles/utilities-code-display.css +67 -0
- package/src/styles/utilities-completion.css +223 -0
- package/src/styles/utilities-detail.css +73 -0
- package/src/styles/utilities-feedback.css +16 -15
- package/src/styles/utilities-layout.css +42 -2
- package/src/styles/utilities-markdown-editor.css +472 -0
- package/src/styles/utilities-navigation.css +63 -8
- package/src/styles/utilities-script.css +84 -0
- package/src/styles/utilities-table-tile.css +229 -0
- package/src/types/ambient.d.ts +9 -0
- package/src/ui/completion/behaviors.test.ts +95 -0
- package/src/ui/completion/behaviors.ts +205 -0
- package/src/ui/completion/engine.ts +368 -0
- package/src/ui/completion/index.ts +40 -0
- package/src/ui/completion/overlay.ts +92 -0
- package/src/ui/dialog-core.ts +173 -45
- package/src/ui/filter/FilterChip.tsx +42 -40
- package/src/ui/index.ts +11 -12
- package/src/ui/input/AutocompleteEditor.tsx +656 -0
- package/src/ui/input/CheckboxCard.tsx +91 -0
- package/src/ui/input/Combobox.tsx +375 -0
- package/src/ui/input/DatePicker.tsx +846 -0
- package/src/ui/input/DateTimeInput.tsx +29 -4
- package/src/ui/input/FileDropzone.tsx +116 -0
- package/src/ui/input/IconInput.tsx +116 -0
- package/src/ui/input/ImageInput.tsx +19 -2
- package/src/ui/input/MultiSelectInput.tsx +448 -0
- package/src/ui/input/NumberInput.tsx +417 -61
- package/src/ui/input/SegmentedControl.tsx +2 -2
- package/src/ui/input/Select.tsx +172 -10
- package/src/ui/input/Slider.tsx +3 -4
- package/src/ui/input/Switch.tsx +3 -2
- package/src/ui/input/TemplateEditor.tsx +212 -0
- package/src/ui/input/TextInput.tsx +144 -13
- package/src/ui/input/index.ts +53 -8
- package/src/ui/input/markdown/MarkdownEditor.tsx +774 -0
- package/src/ui/input/markdown/Toolbar.tsx +90 -0
- package/src/ui/input/markdown/actions.ts +233 -0
- package/src/ui/input/markdown/active-formats.ts +94 -0
- package/src/ui/input/markdown/behaviors.ts +193 -0
- package/src/ui/input/markdown/code-zone.ts +23 -0
- package/src/ui/input/markdown/highlight.ts +316 -0
- package/src/ui/layout.ts +22 -0
- package/src/ui/misc/AppOverview.tsx +105 -0
- package/src/ui/misc/AppWorkspace.tsx +607 -0
- package/src/ui/misc/Calendar.tsx +1291 -0
- package/src/ui/misc/Chart.tsx +162 -0
- package/src/ui/misc/CodeDisplay.tsx +54 -0
- package/src/ui/misc/ContextMenu.tsx +2 -2
- package/src/ui/misc/DataTable.tsx +269 -0
- package/src/ui/misc/DockWorkspace.tsx +425 -0
- package/src/ui/misc/Docs.tsx +153 -0
- package/src/ui/misc/Dropdown.tsx +2 -2
- package/src/ui/misc/EntitySearch.tsx +260 -129
- package/src/ui/misc/LinkCard.tsx +14 -2
- package/src/ui/misc/LogEntriesTable.tsx +34 -31
- package/src/ui/misc/Pagination.tsx +31 -12
- package/src/ui/misc/PanelDialog.tsx +109 -0
- package/src/ui/misc/Panes.tsx +873 -0
- package/src/ui/misc/PermissionEditor.tsx +358 -262
- package/src/ui/misc/Placeholder.tsx +40 -0
- package/src/ui/misc/ProgressBar.tsx +1 -1
- package/src/ui/misc/ResourceApiKeys.tsx +260 -0
- package/src/ui/misc/SettingsModal.tsx +150 -0
- package/src/ui/misc/StatCell.tsx +182 -40
- package/src/ui/misc/StatGrid.tsx +149 -0
- package/src/ui/misc/StructuredDataPreview.tsx +107 -0
- package/src/ui/misc/code-highlight.ts +213 -0
- package/src/ui/misc/index.ts +93 -12
- package/src/ui/prompts.tsx +362 -312
- package/src/ui/toast.ts +384 -0
- package/src/ui/widgets/Widget.tsx +12 -4
- package/src/ssr/MoreAppsDropdown.island.tsx +0 -61
- package/src/ui/ipa/GroupView.tsx +0 -36
- package/src/ui/ipa/LoginBtn.tsx +0 -16
- package/src/ui/ipa/UserView.tsx +0 -58
- package/src/ui/ipa/index.ts +0 -4
- package/src/ui/navigation.ts +0 -32
- package/src/ui/sidebar.tsx +0 -468
- /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
|
+
}
|
package/src/types/ambient.d.ts
CHANGED
|
@@ -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
|
+
};
|