@igstack/app-catalog-frontend-core 4.0.0-alpha-20260920174621 → 4.0.0-alpha-20260921191849

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.
@@ -0,0 +1 @@
1
+ export {};
@@ -20,6 +20,7 @@ function CommentsSection({ appSlug }) {
20
20
  const [draft, setDraft] = useState("");
21
21
  const [editingId, setEditingId] = useState(null);
22
22
  const [editDraft, setEditDraft] = useState("");
23
+ const [composerOpen, setComposerOpen] = useState(false);
23
24
  const refresh = () => queryClient.invalidateQueries({ queryKey: listOptions.queryKey });
24
25
  const add = useMutation(
25
26
  trpc.comments.add.mutationOptions({
@@ -49,6 +50,7 @@ function CommentsSection({ appSlug }) {
49
50
  if (body) edit.mutate({ id, body });
50
51
  };
51
52
  const failure = error ?? add.error ?? edit.error ?? remove.error;
53
+ const hasComments = !!comments && comments.length > 0;
52
54
  return /* @__PURE__ */ jsxs("div", { className: "mt-6", children: [
53
55
  /* @__PURE__ */ jsxs("h3", { className: "mb-2 text-sm font-medium", children: [
54
56
  "Comments",
@@ -130,39 +132,61 @@ function CommentsSection({ appSlug }) {
130
132
  ] })
131
133
  ] }) : /* @__PURE__ */ jsx("p", { className: "mt-0.5 whitespace-pre-wrap", children: comment.body })
132
134
  ] }, comment.id);
133
- }) }) : /* @__PURE__ */ jsx("p", { className: "text-muted-foreground text-xs", children: "No comments yet. Be the first." }),
134
- /* @__PURE__ */ jsxs("div", { className: "mt-3 space-y-2", children: [
135
- /* @__PURE__ */ jsx(
136
- "textarea",
135
+ }) }) : /* @__PURE__ */ jsxs("p", { className: "text-muted-foreground text-xs", children: [
136
+ "No comments yet.",
137
+ " ",
138
+ !composerOpen && /* @__PURE__ */ jsx(
139
+ "button",
137
140
  {
138
- rows: 3,
139
- className: cn(FIELD_CLASSES),
140
- placeholder: "Leave a comment…",
141
- value: draft,
142
- onChange: (event) => setDraft(event.target.value),
143
- onKeyDown: (event) => {
144
- if (isSubmitChord(event)) {
145
- event.preventDefault();
146
- post();
147
- }
148
- }
141
+ type: "button",
142
+ className: "hover:text-foreground underline",
143
+ onClick: () => setComposerOpen(true),
144
+ children: "Be the first."
149
145
  }
150
- ),
151
- /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
152
- /* @__PURE__ */ jsx(
153
- Button,
154
- {
155
- type: "button",
156
- size: "sm",
157
- disabled: !draft.trim() || add.isPending,
158
- onClick: post,
159
- children: add.isPending ? "Posting…" : "Post"
160
- }
146
+ )
147
+ ] }),
148
+ (hasComments || composerOpen) && /* @__PURE__ */ jsxs(
149
+ "div",
150
+ {
151
+ className: cn(
152
+ "mt-3 space-y-2",
153
+ composerOpen && "animate-in fade-in slide-in-from-top-2 duration-200"
161
154
  ),
162
- /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: "Posted under a nickname. You can edit or delete it for one hour." })
163
- ] }),
164
- failure && /* @__PURE__ */ jsx("p", { className: "text-destructive text-xs", children: errorMessage(failure) })
165
- ] })
155
+ children: [
156
+ /* @__PURE__ */ jsx(
157
+ "textarea",
158
+ {
159
+ autoFocus: composerOpen,
160
+ rows: 3,
161
+ className: cn(FIELD_CLASSES),
162
+ placeholder: "Leave a comment…",
163
+ value: draft,
164
+ onChange: (event) => setDraft(event.target.value),
165
+ onKeyDown: (event) => {
166
+ if (isSubmitChord(event)) {
167
+ event.preventDefault();
168
+ post();
169
+ }
170
+ }
171
+ }
172
+ ),
173
+ /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [
174
+ /* @__PURE__ */ jsx(
175
+ Button,
176
+ {
177
+ type: "button",
178
+ size: "sm",
179
+ disabled: !draft.trim() || add.isPending,
180
+ onClick: post,
181
+ children: add.isPending ? "Posting…" : "Post"
182
+ }
183
+ ),
184
+ /* @__PURE__ */ jsx("span", { className: "text-muted-foreground text-xs", children: "Posted under a nickname. You can edit or delete it for one hour." })
185
+ ] })
186
+ ]
187
+ }
188
+ ),
189
+ failure && /* @__PURE__ */ jsx("p", { className: "text-destructive mt-2 text-xs", children: errorMessage(failure) })
166
190
  ] });
167
191
  }
168
192
  export {
@@ -1 +1 @@
1
- {"version":3,"file":"CommentsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommentsSection.tsx"],"sourcesContent":["import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { Button } from '~/ui/button'\nimport { cn } from '~/lib/utils'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\n\n/**\n * Feedback on one app, from anyone browsing the catalog.\n *\n * Nobody has to log in to leave a comment, so the server attributes each one to a\n * pseudonym derived from an httpOnly cookie (\"Curious Ferret\"). That cookie is also\n * the only thing that authorises editing or deleting, and only for the first hour —\n * `canEditUntil` arrives from the server as an absolute instant so a skewed client\n * clock cannot show controls the server would refuse.\n */\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : 'Something went wrong'\n}\n\n/** Cmd/Ctrl+Enter submits; plain Enter keeps making paragraphs. */\nfunction isSubmitChord(event: React.KeyboardEvent): boolean {\n return event.key === 'Enter' && (event.metaKey || event.ctrlKey)\n}\n\nexport function CommentsSection({ appSlug }: { appSlug: string }) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const listOptions = trpc.comments.list.queryOptions({ resourceSlug: appSlug })\n const { data: comments, isLoading, error } = useQuery(listOptions)\n\n const [draft, setDraft] = useState('')\n const [editingId, setEditingId] = useState<string | null>(null)\n const [editDraft, setEditDraft] = useState('')\n\n const refresh = () =>\n queryClient.invalidateQueries({ queryKey: listOptions.queryKey })\n\n const add = useMutation(\n trpc.comments.add.mutationOptions({\n onSuccess: () => {\n setDraft('')\n void refresh()\n },\n }),\n )\n const edit = useMutation(\n trpc.comments.edit.mutationOptions({\n onSuccess: () => {\n setEditingId(null)\n void refresh()\n },\n }),\n )\n const remove = useMutation(\n trpc.comments.remove.mutationOptions({ onSuccess: () => void refresh() }),\n )\n\n const post = () => {\n const body = draft.trim()\n if (body) add.mutate({ resourceSlug: appSlug, body })\n }\n\n const saveEdit = (id: string) => {\n const body = editDraft.trim()\n if (body) edit.mutate({ id, body })\n }\n\n // One line for every failed action: three mutations and one query, one place to look.\n const failure = error ?? add.error ?? edit.error ?? remove.error\n\n return (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Comments\n {comments && comments.length > 0 && (\n <span className=\"text-muted-foreground ml-1 font-normal\">\n ({comments.length})\n </span>\n )}\n </h3>\n\n {isLoading ? (\n <p className=\"text-muted-foreground text-xs\">Loading comments…</p>\n ) : comments && comments.length > 0 ? (\n <ul className=\"space-y-3\">\n {comments.map((comment) => {\n const canEdit =\n comment.isMine &&\n !!comment.canEditUntil &&\n Date.parse(comment.canEditUntil) > Date.now()\n\n return (\n <li key={comment.id} className=\"text-xs\">\n <div className=\"text-muted-foreground flex items-center gap-2\">\n <span className=\"text-foreground font-medium\">\n {comment.authorName}\n </span>\n <span>{formatRelativeTime(comment.createdAt)}</span>\n {comment.editedAt && <span>(edited)</span>}\n {canEdit && editingId !== comment.id && (\n <>\n <button\n type=\"button\"\n className=\"hover:text-foreground underline\"\n onClick={() => {\n setEditingId(comment.id)\n setEditDraft(comment.body)\n }}\n >\n Edit\n </button>\n <button\n type=\"button\"\n className=\"hover:text-destructive underline\"\n onClick={() => remove.mutate({ id: comment.id })}\n >\n Delete\n </button>\n </>\n )}\n </div>\n\n {editingId === comment.id ? (\n <div className=\"mt-1 space-y-2\">\n <textarea\n autoFocus\n rows={3}\n className={cn(FIELD_CLASSES)}\n value={editDraft}\n onChange={(event) => setEditDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n saveEdit(comment.id)\n }\n }}\n />\n <div className=\"flex gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!editDraft.trim() || edit.isPending}\n onClick={() => saveEdit(comment.id)}\n >\n Save\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditingId(null)}\n >\n Cancel\n </Button>\n </div>\n </div>\n ) : (\n <p className=\"mt-0.5 whitespace-pre-wrap\">{comment.body}</p>\n )}\n </li>\n )\n })}\n </ul>\n ) : (\n <p className=\"text-muted-foreground text-xs\">\n No comments yet. Be the first.\n </p>\n )}\n\n <div className=\"mt-3 space-y-2\">\n <textarea\n rows={3}\n className={cn(FIELD_CLASSES)}\n placeholder=\"Leave a comment…\"\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n post()\n }\n }}\n />\n <div className=\"flex items-center gap-3\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!draft.trim() || add.isPending}\n onClick={post}\n >\n {add.isPending ? 'Posting…' : 'Post'}\n </Button>\n <span className=\"text-muted-foreground text-xs\">\n Posted under a nickname. You can edit or delete it for one hour.\n </span>\n </div>\n {failure && (\n <p className=\"text-destructive text-xs\">{errorMessage(failure)}</p>\n )}\n </div>\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"CommentsSection.js","sources":["../../../../../../src/modules/appCatalog/ui/detail/CommentsSection.tsx"],"sourcesContent":["import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'\nimport { useState } from 'react'\nimport { useTRPC } from '~/api/infra/trpc'\nimport { Button } from '~/ui/button'\nimport { cn } from '~/lib/utils'\nimport { formatRelativeTime } from '../../utils/formatRelativeTime'\n\n/**\n * Feedback on one app, from anyone browsing the catalog.\n *\n * Nobody has to log in to leave a comment, so the server attributes each one to a\n * pseudonym derived from an httpOnly cookie (\"Curious Ferret\"). That cookie is also\n * the only thing that authorises editing or deleting, and only for the first hour —\n * `canEditUntil` arrives from the server as an absolute instant so a skewed client\n * clock cannot show controls the server would refuse.\n */\n\n/** Same shape the input primitive uses, minus the fixed height. */\nconst FIELD_CLASSES =\n 'placeholder:text-muted-foreground dark:bg-input/30 border-input w-full rounded-md border bg-transparent px-3 py-2 text-sm shadow-xs transition-[color,box-shadow] outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50'\n\nfunction errorMessage(error: unknown): string {\n return error instanceof Error ? error.message : 'Something went wrong'\n}\n\n/** Cmd/Ctrl+Enter submits; plain Enter keeps making paragraphs. */\nfunction isSubmitChord(event: React.KeyboardEvent): boolean {\n return event.key === 'Enter' && (event.metaKey || event.ctrlKey)\n}\n\nexport function CommentsSection({ appSlug }: { appSlug: string }) {\n const trpc = useTRPC()\n const queryClient = useQueryClient()\n const listOptions = trpc.comments.list.queryOptions({ resourceSlug: appSlug })\n const { data: comments, isLoading, error } = useQuery(listOptions)\n\n const [draft, setDraft] = useState('')\n const [editingId, setEditingId] = useState<string | null>(null)\n const [editDraft, setEditDraft] = useState('')\n /**\n * Most apps have no comments, and an empty form under \"no comments yet\" is just\n * noise. So the composer stays behind the invitation until someone accepts it —\n * which is also what earns the caret: focus follows the click, never the mount.\n */\n const [composerOpen, setComposerOpen] = useState(false)\n\n const refresh = () =>\n queryClient.invalidateQueries({ queryKey: listOptions.queryKey })\n\n const add = useMutation(\n trpc.comments.add.mutationOptions({\n onSuccess: () => {\n setDraft('')\n void refresh()\n },\n }),\n )\n const edit = useMutation(\n trpc.comments.edit.mutationOptions({\n onSuccess: () => {\n setEditingId(null)\n void refresh()\n },\n }),\n )\n const remove = useMutation(\n trpc.comments.remove.mutationOptions({ onSuccess: () => void refresh() }),\n )\n\n const post = () => {\n const body = draft.trim()\n if (body) add.mutate({ resourceSlug: appSlug, body })\n }\n\n const saveEdit = (id: string) => {\n const body = editDraft.trim()\n if (body) edit.mutate({ id, body })\n }\n\n // One line for every failed action: three mutations and one query, one place to look.\n const failure = error ?? add.error ?? edit.error ?? remove.error\n const hasComments = !!comments && comments.length > 0\n\n return (\n <div className=\"mt-6\">\n <h3 className=\"mb-2 text-sm font-medium\">\n Comments\n {comments && comments.length > 0 && (\n <span className=\"text-muted-foreground ml-1 font-normal\">\n ({comments.length})\n </span>\n )}\n </h3>\n\n {isLoading ? (\n <p className=\"text-muted-foreground text-xs\">Loading comments…</p>\n ) : comments && comments.length > 0 ? (\n <ul className=\"space-y-3\">\n {comments.map((comment) => {\n const canEdit =\n comment.isMine &&\n !!comment.canEditUntil &&\n Date.parse(comment.canEditUntil) > Date.now()\n\n return (\n <li key={comment.id} className=\"text-xs\">\n <div className=\"text-muted-foreground flex items-center gap-2\">\n <span className=\"text-foreground font-medium\">\n {comment.authorName}\n </span>\n <span>{formatRelativeTime(comment.createdAt)}</span>\n {comment.editedAt && <span>(edited)</span>}\n {canEdit && editingId !== comment.id && (\n <>\n <button\n type=\"button\"\n className=\"hover:text-foreground underline\"\n onClick={() => {\n setEditingId(comment.id)\n setEditDraft(comment.body)\n }}\n >\n Edit\n </button>\n <button\n type=\"button\"\n className=\"hover:text-destructive underline\"\n onClick={() => remove.mutate({ id: comment.id })}\n >\n Delete\n </button>\n </>\n )}\n </div>\n\n {editingId === comment.id ? (\n <div className=\"mt-1 space-y-2\">\n <textarea\n autoFocus\n rows={3}\n className={cn(FIELD_CLASSES)}\n value={editDraft}\n onChange={(event) => setEditDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n saveEdit(comment.id)\n }\n }}\n />\n <div className=\"flex gap-2\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!editDraft.trim() || edit.isPending}\n onClick={() => saveEdit(comment.id)}\n >\n Save\n </Button>\n <Button\n type=\"button\"\n size=\"sm\"\n variant=\"ghost\"\n onClick={() => setEditingId(null)}\n >\n Cancel\n </Button>\n </div>\n </div>\n ) : (\n <p className=\"mt-0.5 whitespace-pre-wrap\">{comment.body}</p>\n )}\n </li>\n )\n })}\n </ul>\n ) : (\n <p className=\"text-muted-foreground text-xs\">\n No comments yet.{' '}\n {!composerOpen && (\n <button\n type=\"button\"\n className=\"hover:text-foreground underline\"\n onClick={() => setComposerOpen(true)}\n >\n Be the first.\n </button>\n )}\n </p>\n )}\n\n {(hasComments || composerOpen) && (\n <div\n className={cn(\n 'mt-3 space-y-2',\n composerOpen &&\n 'animate-in fade-in slide-in-from-top-2 duration-200',\n )}\n >\n <textarea\n autoFocus={composerOpen}\n rows={3}\n className={cn(FIELD_CLASSES)}\n placeholder=\"Leave a comment…\"\n value={draft}\n onChange={(event) => setDraft(event.target.value)}\n onKeyDown={(event) => {\n if (isSubmitChord(event)) {\n event.preventDefault()\n post()\n }\n }}\n />\n <div className=\"flex items-center gap-3\">\n <Button\n type=\"button\"\n size=\"sm\"\n disabled={!draft.trim() || add.isPending}\n onClick={post}\n >\n {add.isPending ? 'Posting…' : 'Post'}\n </Button>\n <span className=\"text-muted-foreground text-xs\">\n Posted under a nickname. You can edit or delete it for one hour.\n </span>\n </div>\n </div>\n )}\n\n {/* Outside the composer: a failed list query leaves nothing to collapse into. */}\n {failure && (\n <p className=\"text-destructive mt-2 text-xs\">{errorMessage(failure)}</p>\n )}\n </div>\n )\n}\n"],"names":[],"mappings":"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"}
package/dist/index.css CHANGED
@@ -11,7 +11,7 @@
11
11
  /* Other module imports this css, and we need to tell tailwind to scan our tsx files */
12
12
  @source ".";
13
13
 
14
- /* @import 'tw-animate-css'; */
14
+ @import 'tw-animate-css';
15
15
 
16
16
  @layer base {
17
17
  /* Warm "crayon" palette derived from the hand-drawn app-catalog logo
@@ -248,3 +248,16 @@
248
248
  list-style-type: disc;
249
249
  padding-left: 1.25rem;
250
250
  }
251
+
252
+ /* tw-animate-css ships no reduced-motion rules of its own, so honour the
253
+ preference here — the overlay animations it provides are all decorative. */
254
+ @media (prefers-reduced-motion: reduce) {
255
+ *,
256
+ *::before,
257
+ *::after {
258
+ animation-duration: 0.01ms !important;
259
+ animation-iteration-count: 1 !important;
260
+ transition-duration: 0.01ms !important;
261
+ scroll-behavior: auto !important;
262
+ }
263
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@igstack/app-catalog-frontend-core",
3
- "version": "4.0.0-alpha-20260920174621",
3
+ "version": "4.0.0-alpha-20260921191849",
4
4
  "description": "Frontend core library for App Catalog",
5
5
  "homepage": "https://github.com/lislon/app-catalog",
6
6
  "repository": {
@@ -113,7 +113,7 @@
113
113
  "tailwindcss": "^4.1.7",
114
114
  "tiptap-markdown": "^0.8.0",
115
115
  "tslib": "^2.3.0",
116
- "@igstack/app-catalog-shared-core": "4.0.0-alpha-20260920174621"
116
+ "@igstack/app-catalog-shared-core": "4.0.0-alpha-20260921191849"
117
117
  },
118
118
  "devDependencies": {
119
119
  "@tailwindcss/vite": "^4.0.5",
@@ -140,7 +140,7 @@
140
140
  "vite-plugin-static-copy": "^3.1.4",
141
141
  "vite-plugin-svgr": "^4.2.0",
142
142
  "vitest": "^4.1.2",
143
- "@igstack/app-catalog-backend-core": "4.0.0-alpha-20260920174621"
143
+ "@igstack/app-catalog-backend-core": "4.0.0-alpha-20260921191849"
144
144
  },
145
145
  "peerDependencies": {
146
146
  "react": "19.1.2",
@@ -149,7 +149,7 @@
149
149
  "vite": "^6.3.5",
150
150
  "vite-plugin-svgr": "^4.2.0"
151
151
  },
152
- "gitHead": "a297e008f756aa8b200884a84bcb43d999519546",
152
+ "gitHead": "47e97c12338210221f4ff7dc88159336865f6c34",
153
153
  "scripts": {
154
154
  "build": "vite build",
155
155
  "build:lenient": "vite build --mode lenient",
@@ -0,0 +1,64 @@
1
+ import { render, screen } from '@testing-library/react'
2
+ import userEvent from '@testing-library/user-event'
3
+ import { describe, expect, it, vi } from 'vitest'
4
+ import '@testing-library/jest-dom/vitest'
5
+
6
+ // The section only needs a comment list here; the whole tRPC/react-query stack is
7
+ // stubbed down to that one query so the empty state is the only thing under test.
8
+ const state = vi.hoisted(() => ({
9
+ comments: [] as unknown[],
10
+ }))
11
+
12
+ vi.mock('@tanstack/react-query', () => ({
13
+ useQuery: vi.fn(() => ({ data: state.comments, isLoading: false })),
14
+ useMutation: vi.fn(() => ({ mutate: vi.fn(), isPending: false })),
15
+ useQueryClient: vi.fn(() => ({ invalidateQueries: vi.fn() })),
16
+ }))
17
+ vi.mock('~/api/infra/trpc', () => ({
18
+ useTRPC: vi.fn(() => ({
19
+ comments: {
20
+ list: { queryOptions: () => ({ queryKey: ['comments'] }) },
21
+ add: { mutationOptions: (o: unknown) => o },
22
+ edit: { mutationOptions: (o: unknown) => o },
23
+ remove: { mutationOptions: (o: unknown) => o },
24
+ },
25
+ })),
26
+ }))
27
+
28
+ const { CommentsSection } =
29
+ await import('~/modules/appCatalog/ui/detail/CommentsSection')
30
+
31
+ describe('CommentsSection — empty state', () => {
32
+ it('keeps the composer behind "Be the first." until it is clicked', async () => {
33
+ state.comments = []
34
+ render(<CommentsSection appSlug="example-app" />)
35
+
36
+ const invite = screen.getByRole('button', { name: 'Be the first.' })
37
+ expect(screen.queryByPlaceholderText('Leave a comment…')).toBeNull()
38
+
39
+ await userEvent.click(invite)
40
+
41
+ const field = screen.getByPlaceholderText('Leave a comment…')
42
+ expect(field).toBeInTheDocument()
43
+ expect(field).toHaveFocus()
44
+ // One affordance at a time: the invite hides itself once it has done its job.
45
+ expect(screen.queryByRole('button', { name: 'Be the first.' })).toBeNull()
46
+ })
47
+
48
+ it('shows the composer straight away once comments exist, without stealing focus', () => {
49
+ state.comments = [
50
+ {
51
+ id: 'c1',
52
+ authorName: 'Curious Ferret',
53
+ body: 'Useful.',
54
+ createdAt: new Date().toISOString(),
55
+ },
56
+ ]
57
+ render(<CommentsSection appSlug="example-app" />)
58
+
59
+ const field = screen.getByPlaceholderText('Leave a comment…')
60
+ expect(field).toBeInTheDocument()
61
+ expect(field).not.toHaveFocus()
62
+ expect(screen.queryByRole('button', { name: 'Be the first.' })).toBeNull()
63
+ })
64
+ })
@@ -0,0 +1,22 @@
1
+ import { readFileSync } from 'node:fs'
2
+ import { describe, expect, it } from 'vitest'
3
+
4
+ /**
5
+ * The overlay primitives carry `animate-in` / `animate-out` classes, so a unit
6
+ * test that asserts the class is on the element passes whether or not the
7
+ * utilities exist — and this import sat commented out for months with nothing
8
+ * moving on screen. So assert the wiring, like stylesheetFonts does.
9
+ */
10
+ // The path is relative to the package root, which is where vitest runs.
11
+ const css = readFileSync('src/index.css', 'utf8')
12
+ const withoutComments = css.replace(/\/\*[\s\S]*?\*\//g, '')
13
+
14
+ describe('animation utilities', () => {
15
+ it('imports tw-animate-css outside a comment', () => {
16
+ expect(withoutComments).toMatch(/@import\s+['"]tw-animate-css['"]/)
17
+ })
18
+
19
+ it('disables animation for prefers-reduced-motion', () => {
20
+ expect(css).toContain('prefers-reduced-motion: reduce')
21
+ })
22
+ })
package/src/index.css CHANGED
@@ -11,7 +11,7 @@
11
11
  /* Other module imports this css, and we need to tell tailwind to scan our tsx files */
12
12
  @source ".";
13
13
 
14
- /* @import 'tw-animate-css'; */
14
+ @import 'tw-animate-css';
15
15
 
16
16
  @layer base {
17
17
  /* Warm "crayon" palette derived from the hand-drawn app-catalog logo
@@ -248,3 +248,16 @@
248
248
  list-style-type: disc;
249
249
  padding-left: 1.25rem;
250
250
  }
251
+
252
+ /* tw-animate-css ships no reduced-motion rules of its own, so honour the
253
+ preference here — the overlay animations it provides are all decorative. */
254
+ @media (prefers-reduced-motion: reduce) {
255
+ *,
256
+ *::before,
257
+ *::after {
258
+ animation-duration: 0.01ms !important;
259
+ animation-iteration-count: 1 !important;
260
+ transition-duration: 0.01ms !important;
261
+ scroll-behavior: auto !important;
262
+ }
263
+ }
@@ -37,6 +37,12 @@ export function CommentsSection({ appSlug }: { appSlug: string }) {
37
37
  const [draft, setDraft] = useState('')
38
38
  const [editingId, setEditingId] = useState<string | null>(null)
39
39
  const [editDraft, setEditDraft] = useState('')
40
+ /**
41
+ * Most apps have no comments, and an empty form under "no comments yet" is just
42
+ * noise. So the composer stays behind the invitation until someone accepts it —
43
+ * which is also what earns the caret: focus follows the click, never the mount.
44
+ */
45
+ const [composerOpen, setComposerOpen] = useState(false)
40
46
 
41
47
  const refresh = () =>
42
48
  queryClient.invalidateQueries({ queryKey: listOptions.queryKey })
@@ -73,6 +79,7 @@ export function CommentsSection({ appSlug }: { appSlug: string }) {
73
79
 
74
80
  // One line for every failed action: three mutations and one query, one place to look.
75
81
  const failure = error ?? add.error ?? edit.error ?? remove.error
82
+ const hasComments = !!comments && comments.length > 0
76
83
 
77
84
  return (
78
85
  <div className="mt-6">
@@ -169,41 +176,61 @@ export function CommentsSection({ appSlug }: { appSlug: string }) {
169
176
  </ul>
170
177
  ) : (
171
178
  <p className="text-muted-foreground text-xs">
172
- No comments yet. Be the first.
179
+ No comments yet.{' '}
180
+ {!composerOpen && (
181
+ <button
182
+ type="button"
183
+ className="hover:text-foreground underline"
184
+ onClick={() => setComposerOpen(true)}
185
+ >
186
+ Be the first.
187
+ </button>
188
+ )}
173
189
  </p>
174
190
  )}
175
191
 
176
- <div className="mt-3 space-y-2">
177
- <textarea
178
- rows={3}
179
- className={cn(FIELD_CLASSES)}
180
- placeholder="Leave a comment…"
181
- value={draft}
182
- onChange={(event) => setDraft(event.target.value)}
183
- onKeyDown={(event) => {
184
- if (isSubmitChord(event)) {
185
- event.preventDefault()
186
- post()
187
- }
188
- }}
189
- />
190
- <div className="flex items-center gap-3">
191
- <Button
192
- type="button"
193
- size="sm"
194
- disabled={!draft.trim() || add.isPending}
195
- onClick={post}
196
- >
197
- {add.isPending ? 'Posting…' : 'Post'}
198
- </Button>
199
- <span className="text-muted-foreground text-xs">
200
- Posted under a nickname. You can edit or delete it for one hour.
201
- </span>
192
+ {(hasComments || composerOpen) && (
193
+ <div
194
+ className={cn(
195
+ 'mt-3 space-y-2',
196
+ composerOpen &&
197
+ 'animate-in fade-in slide-in-from-top-2 duration-200',
198
+ )}
199
+ >
200
+ <textarea
201
+ autoFocus={composerOpen}
202
+ rows={3}
203
+ className={cn(FIELD_CLASSES)}
204
+ placeholder="Leave a comment…"
205
+ value={draft}
206
+ onChange={(event) => setDraft(event.target.value)}
207
+ onKeyDown={(event) => {
208
+ if (isSubmitChord(event)) {
209
+ event.preventDefault()
210
+ post()
211
+ }
212
+ }}
213
+ />
214
+ <div className="flex items-center gap-3">
215
+ <Button
216
+ type="button"
217
+ size="sm"
218
+ disabled={!draft.trim() || add.isPending}
219
+ onClick={post}
220
+ >
221
+ {add.isPending ? 'Posting…' : 'Post'}
222
+ </Button>
223
+ <span className="text-muted-foreground text-xs">
224
+ Posted under a nickname. You can edit or delete it for one hour.
225
+ </span>
226
+ </div>
202
227
  </div>
203
- {failure && (
204
- <p className="text-destructive text-xs">{errorMessage(failure)}</p>
205
- )}
206
- </div>
228
+ )}
229
+
230
+ {/* Outside the composer: a failed list query leaves nothing to collapse into. */}
231
+ {failure && (
232
+ <p className="text-destructive mt-2 text-xs">{errorMessage(failure)}</p>
233
+ )}
207
234
  </div>
208
235
  )
209
236
  }