create-grist-widget 0.2.10 → 0.2.12

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-grist-widget",
3
- "version": "0.2.10",
3
+ "version": "0.2.12",
4
4
  "description": "Scaffold a new Grist custom widget from the grist-widget-sdk Vite template",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -1,7 +1,7 @@
1
1
  import { useEffect, useState } from "react"
2
2
  import { GristHandshakeProvider, useGristHandshakeContext } from "grist-widget-sdk/advanced"
3
3
 
4
- import { deriveDisplayStatus } from "@/lib/grist-handshake-status"
4
+ import { deriveDisplayStatus, type HandshakeDisplayStatus } from "@/lib/grist-handshake-status"
5
5
  import { cn } from "@/lib/utils"
6
6
 
7
7
  // Fixed, approximate countdown shown while retrying -- not a mirror of the
@@ -49,12 +49,8 @@ function StatusDot({ pulsing, className }: { pulsing: boolean; className: string
49
49
  )
50
50
  }
51
51
 
52
- function ChipContents() {
53
- const { snapshot } = useGristHandshakeContext()
54
- const display = deriveDisplayStatus(snapshot.lifecycle)
55
- const retryAttempts = display.kind === "retrying" ? display.attempts : 0
56
- const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
57
-
52
+ /** Pure render of a display status -- shared by the real (context-driven) and demo (cycling) chips. */
53
+ function ChipVisual({ display, secondsLeft }: { display: HandshakeDisplayStatus; secondsLeft: number }) {
58
54
  switch (display.kind) {
59
55
  case "connecting":
60
56
  return (
@@ -87,19 +83,89 @@ function ChipContents() {
87
83
  }
88
84
  }
89
85
 
86
+ function RealChipContents() {
87
+ const { snapshot } = useGristHandshakeContext()
88
+ const display = deriveDisplayStatus(snapshot.lifecycle)
89
+ const retryAttempts = display.kind === "retrying" ? display.attempts : 0
90
+ const secondsLeft = useRetryCountdown(retryAttempts, display.kind === "retrying")
91
+ return <ChipVisual display={display} secondsLeft={secondsLeft} />
92
+ }
93
+
94
+ // A fixed, looping sequence for the showcase hub -- there's no real Grist to
95
+ // connect to on that page (it's shown precisely because a real widget isn't
96
+ // embedded), so this is a cosmetic demo of the same visual states a real
97
+ // widget's chip goes through, not a real handshake attempt.
98
+ const DEMO_SEQUENCE: readonly { display: HandshakeDisplayStatus; ms: number }[] = [
99
+ { display: { kind: "connecting" }, ms: 1500 },
100
+ { display: { kind: "retrying", attempts: 1 }, ms: 3000 },
101
+ { display: { kind: "connected" }, ms: 3500 },
102
+ ]
103
+
104
+ function DemoChipContents() {
105
+ const [step, setStep] = useState(0)
106
+ const [countdownState, setCountdownState] = useState({ step, secondsLeft: 3 })
107
+ const current = DEMO_SEQUENCE[step]
108
+
109
+ // Reset the mini countdown the moment the demo cycle enters "retrying" --
110
+ // an "adjust state during render" pattern, not a setState-in-effect.
111
+ if (current.display.kind === "retrying" && countdownState.step !== step) {
112
+ setCountdownState({ step, secondsLeft: 3 })
113
+ }
114
+
115
+ useEffect(() => {
116
+ const advance = setTimeout(() => {
117
+ setStep((s) => (s + 1) % DEMO_SEQUENCE.length)
118
+ }, current.ms)
119
+ return () => clearTimeout(advance)
120
+ }, [step, current.ms])
121
+
122
+ useEffect(() => {
123
+ if (current.display.kind !== "retrying") return
124
+ const id = setInterval(() => {
125
+ setCountdownState((s) => ({ ...s, secondsLeft: s.secondsLeft > 0 ? s.secondsLeft - 1 : 0 }))
126
+ }, 1000)
127
+ return () => clearInterval(id)
128
+ }, [step, current.display.kind])
129
+
130
+ return <ChipVisual display={current.display} secondsLeft={countdownState.secondsLeft} />
131
+ }
132
+
90
133
  /**
91
- * Small pill showing live Grist handshake status. Mounts its own
92
- * `<GristHandshakeProvider>` alongside `<GristWidgetProvider>` -- documented
93
- * (grist-widget-sdk/advanced) as safe to coexist, since both share the
94
- * page's `ensureGristReady()` singleton. This chip is purely observational:
95
- * it never drives the real widget's connection, just reflects it.
134
+ * Small pill showing Grist handshake status. Two modes:
135
+ *
136
+ * - Default: live status while actually embedded in Grist. Mounts its own
137
+ * `<GristHandshakeProvider>` alongside `<GristWidgetProvider>` --
138
+ * documented (grist-widget-sdk/advanced) as safe to coexist, since both
139
+ * share the page's `ensureGristReady()` singleton. Purely observational:
140
+ * it never drives the real widget's connection, just reflects it.
141
+ * - `demo`: a cosmetic, looping cycle through the same visual states, used
142
+ * on the showcase hub page where no real Grist connection is ever
143
+ * attempted -- a reference to the tech behind the template, not a status
144
+ * readout.
96
145
  */
97
- export function GristStatusChip() {
146
+ export function GristStatusChip({
147
+ demo = false,
148
+ className,
149
+ }: {
150
+ demo?: boolean
151
+ className?: string
152
+ }) {
153
+ const pill = (
154
+ <div
155
+ className={cn(
156
+ "flex items-center gap-1.5 rounded-full border bg-background/90 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur",
157
+ className,
158
+ )}
159
+ >
160
+ {demo ? <DemoChipContents /> : <RealChipContents />}
161
+ </div>
162
+ )
163
+
164
+ if (demo) return pill
165
+
98
166
  return (
99
167
  <GristHandshakeProvider>
100
- <div className="pointer-events-none fixed top-2 right-2 z-50 flex items-center gap-1.5 rounded-full border bg-background/90 px-2.5 py-1 text-xs text-muted-foreground shadow-sm backdrop-blur">
101
- <ChipContents />
102
- </div>
168
+ <div className="pointer-events-none fixed top-2 right-2 z-50">{pill}</div>
103
169
  </GristHandshakeProvider>
104
170
  )
105
171
  }
@@ -1,4 +1,8 @@
1
+ import { useState } from "react"
2
+
3
+ import { GristStatusChip } from "@/components/grist-status-chip"
1
4
  import { Button } from "@/components/ui/button"
5
+ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
2
6
  import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-versions"
3
7
 
4
8
  // The showcase "hub" page: rendered whenever this deploy's URL has no
@@ -11,12 +15,64 @@ import { DEV_URL, formatDate, useVersions, versionUrl } from "@/lib/showcase-ver
11
15
  // for this repo's own showcase as for a real scaffolded widget that happens
12
16
  // to have something deployed at its own bare root; the only per-deploy
13
17
  // difference is which /v<version>/ dirs actually exist upstream.
18
+
19
+ // A ready-to-paste instruction for an AI coding agent, not a human. Written
20
+ // to work first-shot: it explains *why* to scaffold into a subdirectory
21
+ // (create-grist-widget's CLI refuses a non-empty target -- and a repo
22
+ // Claude Code is already connected to always has a .git folder) rather than
23
+ // just saying "scaffold and push", which left room for the agent to try
24
+ // (and fail) `npm create grist-widget .` in place.
25
+ const CLAUDE_CODE_PROMPT = `Scaffold a new Grist custom widget here using \`npm create grist-widget\` (https://www.npmjs.com/package/create-grist-widget). This repo is an empty git clone already connected to GitHub, so scaffold into a new subdirectory first -- the CLI refuses a non-empty target directory, and this one already has a .git folder. Then push the scaffold's \`main\` and \`dev\` branches to this repository's own \`origin\` remote, so the bundled GitHub Pages deploy workflow runs. Report back the /latest/ and /v<version>/ GitHub Pages URLs once it's live.`
26
+
27
+ function CopyableBlock({ text }: { text: string }) {
28
+ const [copied, setCopied] = useState(false)
29
+
30
+ function copy() {
31
+ navigator.clipboard.writeText(text).then(() => {
32
+ setCopied(true)
33
+ setTimeout(() => setCopied(false), 1500)
34
+ })
35
+ }
36
+
37
+ return (
38
+ <div className="relative">
39
+ <pre className="overflow-x-auto rounded-lg bg-muted p-4 pr-16 text-xs whitespace-pre-wrap">
40
+ <code>{text}</code>
41
+ </pre>
42
+ <Button
43
+ type="button"
44
+ variant="secondary"
45
+ size="sm"
46
+ className="absolute top-2 right-2"
47
+ onClick={copy}
48
+ >
49
+ {copied ? "Copied" : "Copy"}
50
+ </Button>
51
+ </div>
52
+ )
53
+ }
54
+
55
+ function CreateRepoButton() {
56
+ return (
57
+ <div className="mt-2 ml-5">
58
+ <Button asChild>
59
+ <a href="https://github.com/new" target="_blank" rel="noreferrer">
60
+ Create a new repository
61
+ </a>
62
+ </Button>
63
+ </div>
64
+ )
65
+ }
66
+
14
67
  export function TemplateLanding() {
15
68
  const { versions, error } = useVersions()
16
69
 
17
70
  return (
18
71
  <div className="mx-auto max-w-2xl px-6 py-16">
19
72
  <header className="mb-10 text-center">
73
+ <div className="mb-4 flex justify-center">
74
+ <GristStatusChip demo />
75
+ </div>
20
76
  <p className="mb-2 text-sm font-medium text-muted-foreground">
21
77
  grist-widget-template
22
78
  </p>
@@ -32,38 +88,85 @@ export function TemplateLanding() {
32
88
 
33
89
  <div className="mb-10 rounded-lg border p-4">
34
90
  <h2 className="mb-3 font-heading text-base font-semibold">
35
- Using Claude Code?
91
+ Get started
36
92
  </h2>
37
- <ol className="mb-4 list-inside list-decimal space-y-2 text-sm text-muted-foreground">
38
- <li>Create a new, empty GitHub repo for the widget.</li>
39
- <li>
40
- Install the{" "}
41
- <a
42
- href="https://github.com/apps/claude"
43
- className="underline underline-offset-4 hover:text-foreground"
44
- >
45
- Claude GitHub App
46
- </a>{" "}
47
- on that repo.
48
- </li>
49
- <li>
50
- Start a Claude Code session connected to it and ask it to
51
- scaffold and push.
52
- </li>
53
- </ol>
54
- <pre className="mb-4 overflow-x-auto rounded-lg bg-muted p-4 text-xs">
55
- <code>{`npm create grist-widget my-widget
56
- cd my-widget
93
+ <Tabs defaultValue="claude-code">
94
+ <TabsList className="mb-4">
95
+ <TabsTrigger value="claude-code">Using Claude Code?</TabsTrigger>
96
+ <TabsTrigger value="manual">Manual setup</TabsTrigger>
97
+ </TabsList>
98
+
99
+ <TabsContent value="claude-code">
100
+ <ol className="list-inside list-decimal space-y-3 text-sm text-muted-foreground">
101
+ <li>
102
+ Create a new, empty GitHub repo for the widget.
103
+ <CreateRepoButton />
104
+ </li>
105
+ <li>
106
+ Install the{" "}
107
+ <a
108
+ href="https://github.com/apps/claude"
109
+ target="_blank"
110
+ rel="noreferrer"
111
+ className="underline underline-offset-4 hover:text-foreground"
112
+ >
113
+ Claude GitHub App
114
+ </a>{" "}
115
+ on that repo.
116
+ </li>
117
+ <li>
118
+ Start a Claude Code session connected to it and paste this
119
+ prompt:
120
+ <div className="mt-2 ml-5">
121
+ <CopyableBlock text={CLAUDE_CODE_PROMPT} />
122
+ </div>
123
+ </li>
124
+ </ol>
125
+ </TabsContent>
57
126
 
58
- git remote add origin https://github.com/<owner>/<repo>.git
127
+ <TabsContent value="manual">
128
+ <ol className="list-inside list-decimal space-y-3 text-sm text-muted-foreground">
129
+ <li>
130
+ Scaffold it and try it locally:
131
+ <div className="mt-2 ml-5">
132
+ <pre className="overflow-x-auto rounded-lg bg-muted p-4 text-xs">
133
+ <code>{`npm create grist-widget my-widget
134
+ cd my-widget
135
+ pnpm install
136
+ pnpm dev`}</code>
137
+ </pre>
138
+ </div>
139
+ </li>
140
+ <li>
141
+ Create a new, empty GitHub repo for the widget.
142
+ <CreateRepoButton />
143
+ </li>
144
+ <li>
145
+ Push it:
146
+ <div className="mt-2 ml-5">
147
+ <pre className="overflow-x-auto rounded-lg bg-muted p-4 text-xs">
148
+ <code>{`git remote add origin https://github.com/<owner>/<repo>.git
59
149
  git push -u origin main
60
150
  git push origin dev`}</code>
61
- </pre>
62
- <Button asChild>
63
- <a href="https://github.com/new" target="_blank" rel="noreferrer">
64
- Create a new repository
65
- </a>
66
- </Button>
151
+ </pre>
152
+ </div>
153
+ </li>
154
+ <li>
155
+ Once that first push's workflow run completes (it creates a{" "}
156
+ <code className="text-foreground">gh-pages</code> branch),
157
+ enable Pages: Settings → Pages → Source → "Deploy from a
158
+ branch" → branch <code className="text-foreground">gh-pages</code>{" "}
159
+ → <code className="text-foreground">/ (root)</code>.
160
+ <p className="mt-2 text-xs text-muted-foreground">
161
+ If that first run fails with a permissions error pushing to{" "}
162
+ <code className="text-foreground">gh-pages</code>, Settings
163
+ → Actions → General → Workflow permissions → "Read and
164
+ write permissions" fixes it — most repos don't need this.
165
+ </p>
166
+ </li>
167
+ </ol>
168
+ </TabsContent>
169
+ </Tabs>
67
170
  </div>
68
171
 
69
172
  <section>
@@ -0,0 +1,88 @@
1
+ import * as React from "react"
2
+ import { cva, type VariantProps } from "class-variance-authority"
3
+ import { Tabs as TabsPrimitive } from "radix-ui"
4
+
5
+ import { cn } from "@/lib/utils"
6
+
7
+ function Tabs({
8
+ className,
9
+ orientation = "horizontal",
10
+ ...props
11
+ }: React.ComponentProps<typeof TabsPrimitive.Root>) {
12
+ return (
13
+ <TabsPrimitive.Root
14
+ data-slot="tabs"
15
+ data-orientation={orientation}
16
+ className={cn(
17
+ "group/tabs flex gap-2 data-horizontal:flex-col",
18
+ className
19
+ )}
20
+ {...props}
21
+ />
22
+ )
23
+ }
24
+
25
+ const tabsListVariants = cva(
26
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-lg p-[3px] text-muted-foreground group-data-horizontal/tabs:h-8 group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none",
27
+ {
28
+ variants: {
29
+ variant: {
30
+ default: "bg-muted",
31
+ line: "gap-1 bg-transparent",
32
+ },
33
+ },
34
+ defaultVariants: {
35
+ variant: "default",
36
+ },
37
+ }
38
+ )
39
+
40
+ function TabsList({
41
+ className,
42
+ variant = "default",
43
+ ...props
44
+ }: React.ComponentProps<typeof TabsPrimitive.List> &
45
+ VariantProps<typeof tabsListVariants>) {
46
+ return (
47
+ <TabsPrimitive.List
48
+ data-slot="tabs-list"
49
+ data-variant={variant}
50
+ className={cn(tabsListVariants({ variant }), className)}
51
+ {...props}
52
+ />
53
+ )
54
+ }
55
+
56
+ function TabsTrigger({
57
+ className,
58
+ ...props
59
+ }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
60
+ return (
61
+ <TabsPrimitive.Trigger
62
+ data-slot="tabs-trigger"
63
+ className={cn(
64
+ "relative inline-flex h-[calc(100%-1px)] flex-1 items-center justify-center gap-1.5 rounded-md border border-transparent px-1.5 py-0.5 text-sm font-medium whitespace-nowrap text-foreground/60 transition-all group-data-vertical/tabs:w-full group-data-vertical/tabs:justify-start hover:text-foreground focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 focus-visible:outline-1 focus-visible:outline-ring disabled:pointer-events-none disabled:opacity-50 has-data-[icon=inline-end]:pr-1 has-data-[icon=inline-start]:pl-1 dark:text-muted-foreground dark:hover:text-foreground group-data-[variant=default]/tabs-list:data-active:shadow-sm group-data-[variant=line]/tabs-list:data-active:shadow-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
65
+ "group-data-[variant=line]/tabs-list:bg-transparent group-data-[variant=line]/tabs-list:data-active:bg-transparent dark:group-data-[variant=line]/tabs-list:data-active:border-transparent dark:group-data-[variant=line]/tabs-list:data-active:bg-transparent",
66
+ "data-active:bg-background data-active:text-foreground dark:data-active:border-input dark:data-active:bg-input/30 dark:data-active:text-foreground",
67
+ "after:absolute after:bg-foreground after:opacity-0 after:transition-opacity group-data-horizontal/tabs:after:inset-x-0 group-data-horizontal/tabs:after:bottom-[-5px] group-data-horizontal/tabs:after:h-0.5 group-data-vertical/tabs:after:inset-y-0 group-data-vertical/tabs:after:-right-1 group-data-vertical/tabs:after:w-0.5 group-data-[variant=line]/tabs-list:data-active:after:opacity-100",
68
+ className
69
+ )}
70
+ {...props}
71
+ />
72
+ )
73
+ }
74
+
75
+ function TabsContent({
76
+ className,
77
+ ...props
78
+ }: React.ComponentProps<typeof TabsPrimitive.Content>) {
79
+ return (
80
+ <TabsPrimitive.Content
81
+ data-slot="tabs-content"
82
+ className={cn("flex-1 text-sm outline-none", className)}
83
+ {...props}
84
+ />
85
+ )
86
+ }
87
+
88
+ export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }