create-grist-widget 0.3.25 → 0.3.26

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.
@@ -320,7 +320,7 @@ export function GristDataSheetGrid({
320
320
  }
321
321
 
322
322
  // Sequence: deletes → updates → creates. Errors are tracked by the SDK
323
- // and surfaced through `<GristSdkAlerts>`.
323
+ // and surfaced by the SDK's alerts (mountGristWidget).
324
324
  void (async () => {
325
325
  try {
326
326
  if (deletes.length > 0) await ops.destroy(deletes)
@@ -98,7 +98,7 @@ export function GristSchemaToolbar({
98
98
  setNewColId("")
99
99
  setNewType("Text")
100
100
  } catch {
101
- // Surfaced via <GristSdkAlerts>.
101
+ // Surfaced by the SDK's alerts (mountGristWidget).
102
102
  }
103
103
  }
104
104
 
@@ -109,7 +109,7 @@ export function GristSchemaToolbar({
109
109
  await w.applyActions([gristRenameColumnAction(tableId, oldId, newId)])
110
110
  await schema.reload()
111
111
  } catch {
112
- /* surfaced via <GristSdkAlerts> */
112
+ /* surfaced by the SDK's alerts */
113
113
  }
114
114
  }
115
115
 
@@ -118,7 +118,7 @@ export function GristSchemaToolbar({
118
118
  await w.applyActions([gristModifyColumnAction(tableId, colId, { type })])
119
119
  await schema.reload()
120
120
  } catch {
121
- /* surfaced via <GristSdkAlerts> */
121
+ /* surfaced by the SDK's alerts */
122
122
  }
123
123
  }
124
124
 
@@ -128,7 +128,7 @@ export function GristSchemaToolbar({
128
128
  await w.applyActions([gristRemoveColumnAction(tableId, colId)])
129
129
  await schema.reload()
130
130
  } catch {
131
- /* surfaced via <GristSdkAlerts> */
131
+ /* surfaced by the SDK's alerts */
132
132
  }
133
133
  }
134
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.3.25",
3
+ "version": "0.3.26",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -22,16 +22,17 @@ export function App() {
22
22
  }
23
23
  ```
24
24
 
25
- `src/main.tsx` wires it into Grist: `GristWidgetProvider` (reads `GRIST_OPTIONS`) →
26
- `GristBoundary` → `GristSdkAlerts` → `<App />`. Access level, column mapping, and
27
- rendering all live in `src/App.tsx` — that's the one file to edit.
25
+ `src/main.tsx` is one call, `mountGristWidget({ App, options: GRIST_OPTIONS, dev })` from
26
+ `grist-widget-sdk/app`: inside Grist it renders `<App />` in `GristWidgetProvider` and
27
+ `GristBoundary`, with the SDK's alerts and Grist's light/dark theme. Access level, column
28
+ mapping, and rendering all live in `src/App.tsx` — that's the one file to edit.
28
29
 
29
30
  ## Operating contract
30
31
 
31
32
  1. **Widgets use the SDK.** No direct `window.grist.*` calls — use `useGrist()` and the
32
33
  other `grist-widget-sdk` hooks.
33
34
  2. **`pnpm dev` is a bare shell, on purpose — not a seeded preview.** Opened outside Grist
34
- it renders `LocalDevNotice`; the widget itself only renders once actually embedded in a
35
+ it shows the SDK's local-dev page; the widget itself only renders once actually embedded in a
35
36
  Grist document. This is deliberate, not a gap: an emulator can't guarantee parity with
36
37
  real Grist, so a fake local preview would teach the wrong lesson. Trust `pnpm test`
37
38
  (headless, real assertions) for local feedback, and the `dev`-branch loop below for real
@@ -64,7 +65,7 @@ rendering all live in `src/App.tsx` — that's the one file to edit.
64
65
  | --- | --- |
65
66
  | The widget | `src/App.tsx` |
66
67
  | Widget test (the pattern to copy for new tests) | `src/App.test.tsx` |
67
- | Grist wiring / embed vs. showcase detection | `src/main.tsx` |
68
+ | Grist wiring (mountGristWidget, inside vs. outside Grist) | `src/main.tsx` |
68
69
  | ui primitives | `src/components/ui/` |
69
70
  | Deploy workflow | `.github/workflows/deploy.yml` |
70
71
  | Deploy script (version guard, channel logic) | `scripts/deploy.mjs` |
@@ -6,16 +6,16 @@ Base template to build a Grist widget with `grist-widget-sdk`.
6
6
  **Install URL**: written here by this widget's first release (its GitHub Pages `/latest/` URL).
7
7
  <!-- install-url:end -->
8
8
 
9
- `src/main.tsx` wraps the app with `GristWidgetProvider`, `GristBoundary`, and `GristSdkAlerts`. The latter maps `getGristSdkAlertDescriptors()` from the SDK to shadcn `Alert` (`src/components/grist-sdk-alerts.tsx` + `src/components/ui/alert.tsx`); keep them in sync with the playground when you change alert styling.
9
+ `src/main.tsx` is one call to `mountGristWidget()` from `grist-widget-sdk/app` ([reference](https://gristwidgets.com/docs/sdk/api/app)). Inside Grist it renders `App` in `GristWidgetProvider` and `GristBoundary`, under the SDK's alerts, in Grist's light/dark theme. `src/components/theme-provider.tsx` re-exports its `useTheme()`, for shadcn components that need the theme in JavaScript.
10
10
 
11
- Opened outside a Grist iframe, `main.tsx` picks between two components — never a live preview of the widget itself, since nothing here proves anything about real Grist behavior: local `pnpm dev` renders `LocalDevNotice`, a bare shell pointing at the real verification loop below (push to `dev`, open it in a real Grist document); any built/deployed URL (root, `/latest/`, `/dev/`, `/v<version>/` — all carrying the same build, see Deployment below) renders `ReleaseInfo` instead — which build this is, chips to jump to any other version/channel (hover one to see when it shipped; `src/lib/showcase-versions.ts`), and a copy-this-URL helper for pasting into Grist's custom widget field.
11
+ Opened outside a Grist iframe, the page is never a live preview of the widget itself, since nothing there proves anything about real Grist behavior. Local `pnpm dev` shows a local-dev page pointing at the real verification loop below (push to `dev`, open it in a real Grist document). Any built or deployed URL (root, `/latest/`, `/dev/`, `/v<version>/`, all carrying the same build, see Deployment below) shows a release page instead: which build this is, links to every other version and channel (hover one to see when it shipped), and the URL to paste into Grist's custom widget field. Both come from the SDK and follow this widget's shadcn colors; `mountGristWidget()` takes your own components instead (`LocalDevPage`, `ReleasePage`).
12
12
 
13
- When actually embedded, the widget shows connection state only when something is wrong: until Grist is connected, or if the connection is lost or fails, `GristBoundary` replaces the widget with a connecting / unavailable / error screen (the error one with a retry button); once the widget is showing, `GristSdkAlerts` reports what needs attention (column mapping, access level, a failed write…). Nothing is drawn over the widget while all is well.
13
+ When actually embedded, the widget shows connection state only when something is wrong: until Grist is connected, or if the connection is lost or fails, `GristBoundary` replaces the widget with a connecting / unavailable / error screen (the error one with a retry button); once the widget is showing, the SDK's alerts report what needs attention (column mapping, access level, a failed write…) above it. Nothing is drawn over the widget while all is well. Pass your own `Alerts` component to `mountGristWidget()` to render them differently, or `Alerts: null` for none.
14
14
 
15
15
  `src/App.tsx` uses `useGrist<TaskRow, TaskMapped>()` for the selected row (`w.record`, `w.mode`) and remounts the UI with `key={rowKey}` when the row changes — same pattern as `widgets/create-email-draft`. It ships with two mapped columns (`title`, `done`) and an editable form that saves back via `w.table.update(...)`/`w.mapBack(...)` — the smallest real starting point, not a placeholder. See `src/grist-types.example.ts` for typing patterns.
16
16
 
17
17
  - **ESLint** blocks direct `grist` global usage in `src/` — use the SDK only.
18
- - Edit `GRIST_OPTIONS.columns` in `App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `main.tsx` sets `GristBoundary gate="canRender"` whenever columns are declared. Mapping alerts use `GristSdkAlerts`.
18
+ - Edit `GRIST_OPTIONS.columns` in `App.tsx` to change the required columns (or remove the array entirely to read raw, unmapped records instead); `mountGristWidget()` gates the widget on `canRender` whenever columns are declared, so it renders once they're mapped.
19
19
 
20
20
  Run `pnpm test` — see `src/App.test.tsx` for the pattern (`renderWithGrist` + `presets` from `grist-widget-sdk/emulator/testing`, no browser or real Grist doc needed). Full guide: [Testing](https://gristwidgets.com/docs/emulator/testing).
21
21
 
@@ -17,7 +17,7 @@
17
17
  "@tailwindcss/vite": "^4.3.3",
18
18
  "class-variance-authority": "^0.7.1",
19
19
  "clsx": "^2.1.1",
20
- "grist-widget-sdk": "^0.3.25",
20
+ "grist-widget-sdk": "^0.3.26",
21
21
  "lucide-react": "^1.61.0",
22
22
  "radix-ui": "^1.8.1",
23
23
  "react": "^19.3.1",
@@ -226,11 +226,11 @@ export function injectSelfReload(html, sha) {
226
226
  /**
227
227
  * Scan siteDir for previously-released v<version>/showcase-meta.json files
228
228
  * and assemble the versions.json manifest, newest first. Read by the
229
- * showcase hub (TemplateLanding) and per-channel notice (ChannelNotice) to
230
- * show this widget's own release history -- not the grist-widget-sdk
229
+ * release page grist-widget-sdk/app shows outside Grist (useReleasedVersions)
230
+ * to list this widget's own release history -- not the grist-widget-sdk
231
231
  * monorepo's (found live: every scaffolded widget's version index showed
232
- * the monorepo's versions instead of its own, since showcase-versions.ts
233
- * used to hardcode that URL). Entries whose meta has no `repo` field, or a
232
+ * the monorepo's versions instead of its own, since the template's version
233
+ * list used to hardcode that URL). Entries whose meta has no `repo` field, or a
234
234
  * `repo` that doesn't match this repo, are skipped -- a `gh-pages` branch
235
235
  * manually seeded from another repo's export (found live) would otherwise
236
236
  * leak that repo's own version history into this one's index.
@@ -1,13 +1,12 @@
1
1
  /**
2
- * Mirrors `main.tsx`'s real wiring (`GristWidgetProvider` -> `GristBoundary`
3
- * -> the widget) without importing `main.tsx` itself, so this test exercises
4
- * the same gate logic the embedded widget actually runs under. See
5
- * `packages/core/tests/sdk/template-app.test.tsx` in the SDK repo for the
6
- * same pattern applied to a bare shell.
2
+ * Renders the widget the way `main.tsx` mounts it inside Grist
3
+ * (`GristWidgetApp`: provider, boundary gated on the mapped columns, alerts),
4
+ * against an emulated Grist, so this test exercises what the embedded widget
5
+ * actually runs under.
7
6
  */
8
7
  import "@testing-library/jest-dom/vitest"
9
8
  import { afterEach, describe, expect, it } from "vitest"
10
- import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
9
+ import { GristWidgetApp } from "grist-widget-sdk/app"
11
10
  import {
12
11
  act,
13
12
  actionsOf,
@@ -28,13 +27,7 @@ import App, { GRIST_OPTIONS } from "./App"
28
27
  afterEach(() => cleanup())
29
28
 
30
29
  function Wrapped() {
31
- return (
32
- <GristWidgetProvider options={GRIST_OPTIONS}>
33
- <GristBoundary gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}>
34
- <App />
35
- </GristBoundary>
36
- </GristWidgetProvider>
37
- )
30
+ return <GristWidgetApp App={App} options={GRIST_OPTIONS} />
38
31
  }
39
32
 
40
33
  describe("App", () => {
@@ -1,229 +1,5 @@
1
- import * as React from "react"
2
-
3
- type Theme = "dark" | "light" | "system"
4
- type ResolvedTheme = "dark" | "light"
5
-
6
- type ThemeProviderProps = {
7
- children: React.ReactNode
8
- defaultTheme?: Theme
9
- storageKey?: string
10
- disableTransitionOnChange?: boolean
11
- }
12
-
13
- type ThemeProviderState = {
14
- theme: Theme
15
- setTheme: (theme: Theme) => void
16
- }
17
-
18
- const COLOR_SCHEME_QUERY = "(prefers-color-scheme: dark)"
19
- const THEME_VALUES: Theme[] = ["dark", "light", "system"]
20
-
21
- const ThemeProviderContext = React.createContext<
22
- ThemeProviderState | undefined
23
- >(undefined)
24
-
25
- function isTheme(value: string | null): value is Theme {
26
- if (value === null) {
27
- return false
28
- }
29
-
30
- return THEME_VALUES.includes(value as Theme)
31
- }
32
-
33
- function getSystemTheme(): ResolvedTheme {
34
- if (window.matchMedia(COLOR_SCHEME_QUERY).matches) {
35
- return "dark"
36
- }
37
-
38
- return "light"
39
- }
40
-
41
- function disableTransitionsTemporarily() {
42
- const style = document.createElement("style")
43
- style.appendChild(
44
- document.createTextNode(
45
- "*,*::before,*::after{-webkit-transition:none!important;transition:none!important}"
46
- )
47
- )
48
- document.head.appendChild(style)
49
-
50
- return () => {
51
- window.getComputedStyle(document.body)
52
- requestAnimationFrame(() => {
53
- requestAnimationFrame(() => {
54
- style.remove()
55
- })
56
- })
57
- }
58
- }
59
-
60
- function isEditableTarget(target: EventTarget | null) {
61
- if (!(target instanceof HTMLElement)) {
62
- return false
63
- }
64
-
65
- if (target.isContentEditable) {
66
- return true
67
- }
68
-
69
- const editableParent = target.closest(
70
- "input, textarea, select, [contenteditable='true']"
71
- )
72
- if (editableParent) {
73
- return true
74
- }
75
-
76
- return false
77
- }
78
-
79
- export function ThemeProvider({
80
- children,
81
- defaultTheme = "system",
82
- storageKey = "theme",
83
- disableTransitionOnChange = true,
84
- ...props
85
- }: ThemeProviderProps) {
86
- const [theme, setThemeState] = React.useState<Theme>(() => {
87
- const storedTheme = localStorage.getItem(storageKey)
88
- if (isTheme(storedTheme)) {
89
- return storedTheme
90
- }
91
-
92
- return defaultTheme
93
- })
94
-
95
- const setTheme = React.useCallback(
96
- (nextTheme: Theme) => {
97
- localStorage.setItem(storageKey, nextTheme)
98
- setThemeState(nextTheme)
99
- },
100
- [storageKey]
101
- )
102
-
103
- const applyTheme = React.useCallback(
104
- (nextTheme: Theme) => {
105
- const root = document.documentElement
106
- const resolvedTheme =
107
- nextTheme === "system" ? getSystemTheme() : nextTheme
108
- const restoreTransitions = disableTransitionOnChange
109
- ? disableTransitionsTemporarily()
110
- : null
111
-
112
- root.classList.remove("light", "dark")
113
- root.classList.add(resolvedTheme)
114
-
115
- if (restoreTransitions) {
116
- restoreTransitions()
117
- }
118
- },
119
- [disableTransitionOnChange]
120
- )
121
-
122
- React.useEffect(() => {
123
- applyTheme(theme)
124
-
125
- if (theme !== "system") {
126
- return undefined
127
- }
128
-
129
- const mediaQuery = window.matchMedia(COLOR_SCHEME_QUERY)
130
- const handleChange = () => {
131
- applyTheme("system")
132
- }
133
-
134
- mediaQuery.addEventListener("change", handleChange)
135
-
136
- return () => {
137
- mediaQuery.removeEventListener("change", handleChange)
138
- }
139
- }, [theme, applyTheme])
140
-
141
- React.useEffect(() => {
142
- const handleKeyDown = (event: KeyboardEvent) => {
143
- if (event.repeat) {
144
- return
145
- }
146
-
147
- if (event.metaKey || event.ctrlKey || event.altKey) {
148
- return
149
- }
150
-
151
- if (isEditableTarget(event.target)) {
152
- return
153
- }
154
-
155
- if (event.key.toLowerCase() !== "d") {
156
- return
157
- }
158
-
159
- setThemeState((currentTheme) => {
160
- const nextTheme =
161
- currentTheme === "dark"
162
- ? "light"
163
- : currentTheme === "light"
164
- ? "dark"
165
- : getSystemTheme() === "dark"
166
- ? "light"
167
- : "dark"
168
-
169
- localStorage.setItem(storageKey, nextTheme)
170
- return nextTheme
171
- })
172
- }
173
-
174
- window.addEventListener("keydown", handleKeyDown)
175
-
176
- return () => {
177
- window.removeEventListener("keydown", handleKeyDown)
178
- }
179
- }, [storageKey])
180
-
181
- React.useEffect(() => {
182
- const handleStorageChange = (event: StorageEvent) => {
183
- if (event.storageArea !== localStorage) {
184
- return
185
- }
186
-
187
- if (event.key !== storageKey) {
188
- return
189
- }
190
-
191
- if (isTheme(event.newValue)) {
192
- setThemeState(event.newValue)
193
- return
194
- }
195
-
196
- setThemeState(defaultTheme)
197
- }
198
-
199
- window.addEventListener("storage", handleStorageChange)
200
-
201
- return () => {
202
- window.removeEventListener("storage", handleStorageChange)
203
- }
204
- }, [defaultTheme, storageKey])
205
-
206
- const value = React.useMemo(
207
- () => ({
208
- theme,
209
- setTheme,
210
- }),
211
- [theme, setTheme]
212
- )
213
-
214
- return (
215
- <ThemeProviderContext.Provider {...props} value={value}>
216
- {children}
217
- </ThemeProviderContext.Provider>
218
- )
219
- }
220
-
221
- export const useTheme = () => {
222
- const context = React.useContext(ThemeProviderContext)
223
-
224
- if (context === undefined) {
225
- throw new Error("useTheme must be used within a ThemeProvider")
226
- }
227
-
228
- return context
229
- }
1
+ // shadcn components (a toaster, a mode toggle) import the theme from here.
2
+ // grist-widget-sdk/app owns it: inside Grist the widget follows Grist's
3
+ // light/dark theme; on the dev server and the release page, the OS setting,
4
+ // a saved choice and the `d` key.
5
+ export { useTheme } from "grist-widget-sdk/app"
@@ -1,57 +1,11 @@
1
- import { StrictMode } from "react"
2
- import { createRoot } from "react-dom/client"
3
- import { GristBoundary, GristWidgetProvider } from "grist-widget-sdk"
1
+ import { mountGristWidget } from "grist-widget-sdk/app"
4
2
 
5
- import { GristSdkAlerts } from "@/components/grist-sdk-alerts"
6
- import { LocalDevNotice } from "@/components/local-dev-notice"
7
- import { ReleaseInfo } from "@/components/release-info"
8
- import { ThemeProvider } from "@/components/theme-provider.tsx"
9
- import { parseShowcasePath } from "@/lib/showcase-routing"
10
3
  import "./index.css"
11
4
  import App, { GRIST_OPTIONS } from "./App.tsx"
12
5
 
13
- // A Grist custom widget only ever runs embedded in Grist's own iframe.
14
- // Opened directly in a browser tab (window.self === window.top), there's no
15
- // Grist to connect to -- two different non-embedded experiences, told apart
16
- // by import.meta.env.DEV (a real embed always wins over either, checked
17
- // first):
18
- // - `pnpm dev` (DEV) -> LocalDevNotice: a bare shell pointing at the real
19
- // verification loop (push to `dev`, open it in a real Grist doc) --
20
- // deliberately not a live/seeded preview. An emulator can't guarantee
21
- // parity with real Grist, so pretending otherwise here would teach the
22
- // wrong lesson; `pnpm test` (renderWithGrist, headless) is the loop
23
- // worth trusting locally. See AGENTS.md.
24
- // - any built/deployed URL (root, /latest/, /dev/, /v<version>/, all
25
- // carrying the same build per scripts/deploy.mjs) -> ReleaseInfo: which
26
- // build this is, chips to jump to any other version/channel, and a
27
- // copy-this-URL helper for pasting into Grist's custom widget field.
28
- const isEmbedded = window.self !== window.top
29
- const { channel } = parseShowcasePath(window.location.pathname)
30
-
31
- createRoot(document.getElementById("root")!).render(
32
- <StrictMode>
33
- <ThemeProvider>
34
- {isEmbedded ? (
35
- <GristWidgetProvider options={GRIST_OPTIONS}>
36
- <GristBoundary
37
- gate={GRIST_OPTIONS.columns?.length ? "canRender" : "ready"}
38
- >
39
- <div className="min-h-full w-full bg-background text-foreground">
40
- <GristSdkAlerts>
41
- <App />
42
- </GristSdkAlerts>
43
- </div>
44
- </GristBoundary>
45
- </GristWidgetProvider>
46
- ) : import.meta.env.DEV ? (
47
- <div className="min-h-full w-full bg-background text-foreground">
48
- <LocalDevNotice />
49
- </div>
50
- ) : (
51
- <div className="min-h-full w-full bg-background text-foreground">
52
- <ReleaseInfo channel={channel} />
53
- </div>
54
- )}
55
- </ThemeProvider>
56
- </StrictMode>
57
- )
6
+ // Inside Grist: the widget, behind the SDK's connection screens and alerts,
7
+ // in Grist's light/dark theme. Opened anywhere else (`pnpm dev`, a deployed
8
+ // URL): a page saying where to see it, never a fake preview, since an
9
+ // emulator can't stand in for real Grist (see AGENTS.md). `pnpm test` is the
10
+ // local loop to trust.
11
+ mountGristWidget({ App, options: GRIST_OPTIONS, dev: import.meta.env.DEV })
@@ -1,80 +0,0 @@
1
- import { useMemo, type ReactNode } from "react"
2
- import { AlertTriangle, Link2 } from "lucide-react"
3
-
4
- import {
5
- useGristSdkAlertDescriptors,
6
- useGristOptional,
7
- type GetGristSdkAlertDescriptorsOptions,
8
- type GristSdkAlertSeverity,
9
- type UseGristResult,
10
- } from "grist-widget-sdk"
11
-
12
- import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"
13
- import { cn } from "@/lib/utils"
14
-
15
- export type GristSdkAlertsProps = {
16
- /** Defaults to the nearest `GristWidgetProvider`. */
17
- widget?: UseGristResult
18
- children: ReactNode
19
- className?: string
20
- }
21
-
22
- const GRIST_ALERT_OPTIONS: GetGristSdkAlertDescriptorsOptions = {
23
- sectionNotLinkedHint:
24
- "In Grist, select this widget section on the page, then use Link to connect a table, card, or chart view as the selector.",
25
- }
26
-
27
- function alertSeverityClass(severity: GristSdkAlertSeverity): string | undefined {
28
- switch (severity) {
29
- case "warning":
30
- return "border-amber-500/40 bg-amber-500/10 text-amber-950 dark:border-amber-500/30 dark:bg-amber-500/10 dark:text-amber-100"
31
- case "info":
32
- return "border-muted-foreground/30 bg-muted/50"
33
- default:
34
- return undefined
35
- }
36
- }
37
-
38
- /**
39
- * Maps {@link getGristSdkAlertDescriptors} from `grist-widget-sdk` to
40
- * shadcn {@link Alert} (styling follows this app’s `components.json`).
41
- */
42
- export function GristSdkAlerts({ widget, children, className }: GristSdkAlertsProps) {
43
- const fromContext = useGristOptional()
44
- const w = widget ?? fromContext
45
-
46
- if (!w) {
47
- throw new Error(
48
- "GristSdkAlerts requires a `widget` prop or <GristWidgetProvider>"
49
- )
50
- }
51
-
52
- const alertOptions = useMemo(() => GRIST_ALERT_OPTIONS, [])
53
- const alerts = useGristSdkAlertDescriptors(w, alertOptions)
54
-
55
- if (alerts.length === 0) {
56
- return <>{children}</>
57
- }
58
-
59
- return (
60
- <div className={cn("flex flex-col gap-3", className)}>
61
- {alerts.map((a) => (
62
- <Alert
63
- key={a.id}
64
- role={a.ariaRole}
65
- variant={a.severity === "error" ? "destructive" : "default"}
66
- className={alertSeverityClass(a.severity)}
67
- >
68
- {a.kind === "section-not-linked" || a.kind === "source-not-wired" ? (
69
- <Link2 className="size-4" aria-hidden />
70
- ) : (
71
- <AlertTriangle className="size-4" aria-hidden />
72
- )}
73
- <AlertTitle>{a.title}</AlertTitle>
74
- <AlertDescription>{a.message}</AlertDescription>
75
- </Alert>
76
- ))}
77
- {children}
78
- </div>
79
- )
80
- }
@@ -1,55 +0,0 @@
1
- import {
2
- Card,
3
- CardDescription,
4
- CardHeader,
5
- CardTitle,
6
- } from "@/components/ui/card"
7
-
8
- // Shown at the root of a local `pnpm dev` server, never inside Grist.
9
- // Deliberately a bare shell, not a seeded live preview: an emulator can't
10
- // guarantee parity with real Grist, so the loop worth trusting here is
11
- // `pnpm test` (renderWithGrist, headless) plus the real `dev`-branch loop
12
- // this points at below -- see AGENTS.md's note on why this isn't a fake
13
- // preview instead.
14
- export function LocalDevNotice() {
15
- return (
16
- <div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
17
- <p className="mb-2 text-center text-sm font-medium text-muted-foreground">
18
- grist-widget-template · local dev
19
- </p>
20
- <h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
21
- Not embedded in Grist
22
- </h1>
23
- <p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
24
- This is a local dev server — there's no Grist here to render against.
25
- </p>
26
-
27
- <Card className="mb-4">
28
- <CardHeader>
29
- <CardTitle>Verify against real Grist</CardTitle>
30
- <CardDescription>
31
- Commit and push to <code className="text-foreground">dev</code>,
32
- then open the printed{" "}
33
- <code className="text-foreground">/dev/</code> URL in a real
34
- Grist document — every later push self-reloads inside it within
35
- seconds. See the README's Deployment section for the full loop.
36
- </CardDescription>
37
- </CardHeader>
38
- </Card>
39
-
40
- <Card>
41
- <CardHeader>
42
- <CardTitle>Not building this widget?</CardTitle>
43
- <CardDescription>
44
- <code className="text-foreground">npx grist-widget list</code> to
45
- see other starting points,{" "}
46
- <code className="text-foreground">
47
- npx grist-widget use &lt;example&gt;
48
- </code>{" "}
49
- to swap this one out.
50
- </CardDescription>
51
- </CardHeader>
52
- </Card>
53
- </div>
54
- )
55
- }
@@ -1,111 +0,0 @@
1
- import { useState } from "react"
2
-
3
- import { Button } from "@/components/ui/button"
4
- import {
5
- Card,
6
- CardContent,
7
- CardDescription,
8
- CardHeader,
9
- CardTitle,
10
- } from "@/components/ui/card"
11
- import { Input } from "@/components/ui/input"
12
- import { channelLabel, type Channel } from "@/lib/showcase-routing"
13
- import { devUrl, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
14
- import { cn } from "@/lib/utils"
15
-
16
- // Shown on every non-embedded, built/deployed URL of this widget -- root,
17
- // /latest/, /dev/, /v<version>/, all carrying the same build per
18
- // scripts/deploy.mjs's plan() -- when opened outside Grist. Local `pnpm dev`
19
- // renders `LocalDevNotice` instead (see src/main.tsx): this component's
20
- // "paste this URL into Grist" instruction only makes sense for a real,
21
- // publicly reachable URL, never a bare localhost one.
22
- function VersionChips({ current }: { current: Channel }) {
23
- const { versions } = useVersions()
24
-
25
- function chipClass(active: boolean) {
26
- return cn(
27
- "rounded-full border px-3 py-1 text-xs font-medium transition-colors",
28
- active
29
- ? "border-primary bg-primary text-primary-foreground"
30
- : "border-border text-muted-foreground hover:text-foreground",
31
- )
32
- }
33
-
34
- return (
35
- <div className="mb-8 flex flex-wrap justify-center gap-2">
36
- {versions !== null && versions.length > 0 && (
37
- <a
38
- href={versionUrl(versions[0].version)}
39
- title={formatDate(versions[0].publishedAt)}
40
- className={chipClass(
41
- current.kind === "version" && current.version === versions[0].version,
42
- )}
43
- >
44
- latest
45
- </a>
46
- )}
47
- <a href={devUrl()} className={chipClass(current.kind === "dev")}>
48
- dev
49
- </a>
50
- {versions?.map((v) => (
51
- <a
52
- key={v.version}
53
- href={versionUrl(v.version)}
54
- title={formatDate(v.publishedAt)}
55
- className={chipClass(
56
- current.kind === "version" && current.version === v.version,
57
- )}
58
- >
59
- v{v.version}
60
- </a>
61
- ))}
62
- </div>
63
- )
64
- }
65
-
66
- export function ReleaseInfo({ channel }: { channel: Channel }) {
67
- const [copied, setCopied] = useState(false)
68
- const url = window.location.href
69
-
70
- function copyUrl() {
71
- navigator.clipboard.writeText(url).then(() => {
72
- setCopied(true)
73
- setTimeout(() => setCopied(false), 1500)
74
- })
75
- }
76
-
77
- return (
78
- <div className="mx-auto flex min-h-full max-w-md flex-col justify-center px-6 py-16">
79
- <p className="mb-2 text-center text-sm font-medium text-muted-foreground">
80
- grist-widget-template · {channelLabel(channel)}
81
- </p>
82
- <h1 className="text-center font-heading text-2xl font-semibold tracking-tight">
83
- Grist isn't loaded here
84
- </h1>
85
- <p className="mx-auto mt-3 mb-8 max-w-sm text-center text-sm text-muted-foreground">
86
- You're viewing the <span className="font-mono">{channelLabel(channel)}</span>{" "}
87
- build directly, outside of Grist. Hover a chip below to see when it
88
- shipped.
89
- </p>
90
-
91
- <VersionChips current={channel} />
92
-
93
- <Card>
94
- <CardHeader>
95
- <CardTitle>Use this as a Grist custom widget</CardTitle>
96
- <CardDescription>
97
- Paste this URL into Grist's custom widget URL field.
98
- </CardDescription>
99
- </CardHeader>
100
- <CardContent>
101
- <div className="flex gap-2">
102
- <Input readOnly value={url} onFocus={(e) => e.target.select()} />
103
- <Button type="button" onClick={copyUrl}>
104
- {copied ? "Copied" : "Copy"}
105
- </Button>
106
- </div>
107
- </CardContent>
108
- </Card>
109
- </div>
110
- )
111
- }
@@ -1,59 +0,0 @@
1
- // Parses the deployed URL shape every channel of this template (or a real
2
- // scaffolded widget) uses -- /latest/, /dev/, /v<version>/ -- so main.tsx and
3
- // ReleaseInfo can label the current build using nothing but
4
- // window.location.pathname. No router needed.
5
- export type Channel =
6
- | { kind: "latest" }
7
- | { kind: "dev" }
8
- | { kind: "version"; version: string }
9
-
10
- export function channelLabel(channel: Channel): string {
11
- switch (channel.kind) {
12
- case "latest":
13
- return "latest"
14
- case "dev":
15
- return "dev"
16
- case "version":
17
- return `v${channel.version}`
18
- }
19
- }
20
-
21
- /**
22
- * `hubPath` is the current path with a recognized trailing channel segment
23
- * stripped, e.g. "/grist-widget-sdk/template/latest/" ->
24
- * "/grist-widget-sdk/template/" -- used to build root-relative URLs
25
- * (versions.json, /dev/, /v<version>/) regardless of how deep this deploy's
26
- * own base path is (see showcase-versions.ts).
27
- *
28
- * `channel` always resolves to something: `scripts/deploy.mjs` places the
29
- * same build at the bare deployed root as at `/latest/`, so a path with no
30
- * recognized suffix (the root itself, or a local `pnpm dev` server) defaults
31
- * to `{ kind: "latest" }` -- that's genuinely what's there.
32
- */
33
- export function parseShowcasePath(pathname: string): {
34
- channel: Channel
35
- hubPath: string
36
- } {
37
- const trimmed = pathname.replace(/\/+$/, "")
38
- const segments = trimmed.split("/")
39
- const last = segments[segments.length - 1] ?? ""
40
-
41
- let channel: Channel = { kind: "latest" }
42
- let matchedSuffix = false
43
- if (last === "latest") {
44
- channel = { kind: "latest" }
45
- matchedSuffix = true
46
- } else if (last === "dev") {
47
- channel = { kind: "dev" }
48
- matchedSuffix = true
49
- } else {
50
- const match = /^v(\d+\.\d+\.\d+(?:-[\w.]+)?)$/.exec(last)
51
- if (match) {
52
- channel = { kind: "version", version: match[1] }
53
- matchedSuffix = true
54
- }
55
- }
56
-
57
- const hubPath = matchedSuffix ? segments.slice(0, -1).join("/") + "/" : pathname
58
- return { channel, hubPath }
59
- }
@@ -1,63 +0,0 @@
1
- import { useEffect, useState } from "react"
2
-
3
- import { parseShowcasePath } from "@/lib/showcase-routing"
4
-
5
- // Used by ReleaseInfo, which needs the released-version list on every
6
- // non-embedded, deployed URL. URLs are root-relative to wherever THIS
7
- // deploy actually lives (derived from
8
- // window.location.pathname via parseShowcasePath's hubPath), not hardcoded
9
- // to the grist-widget-sdk monorepo's own showcase: a real scaffolded
10
- // widget has its own versions.json and its own v<version>/ + dev/ dirs,
11
- // and previously showed the monorepo's version history and links instead
12
- // of its own (found live on a real scaffold's landing page).
13
- function hubPath(): string {
14
- const path = parseShowcasePath(window.location.pathname).hubPath
15
- return path.endsWith("/") ? path : `${path}/`
16
- }
17
-
18
- export type ReleasedVersion = {
19
- version: string
20
- publishedAt: string
21
- }
22
-
23
- export function versionsUrl(): string {
24
- return `${hubPath()}versions.json`
25
- }
26
-
27
- export function devUrl(): string {
28
- return `${hubPath()}dev/`
29
- }
30
-
31
- export function versionUrl(version: string): string {
32
- return `${hubPath()}v${version}/`
33
- }
34
-
35
- export function useVersions() {
36
- const [versions, setVersions] = useState<ReleasedVersion[] | null>(null)
37
- const [error, setError] = useState(false)
38
-
39
- useEffect(() => {
40
- let cancelled = false
41
- fetch(versionsUrl(), { cache: "no-store" })
42
- .then((res) => (res.ok ? res.json() : Promise.reject(res)))
43
- .then((data: ReleasedVersion[]) => {
44
- if (!cancelled) setVersions(data)
45
- })
46
- .catch(() => {
47
- if (!cancelled) setError(true)
48
- })
49
- return () => {
50
- cancelled = true
51
- }
52
- }, [])
53
-
54
- return { versions, error }
55
- }
56
-
57
- export function formatDate(iso: string) {
58
- return new Date(iso).toLocaleDateString(undefined, {
59
- year: "numeric",
60
- month: "short",
61
- day: "numeric",
62
- })
63
- }