@ripla/godd-mcp 1.0.7-canary.8 → 1.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.md +523 -124
  2. package/dist/godd.cjs +293 -244
  3. package/dist/godd.js +1005 -321
  4. package/dist/index.js +148 -119
  5. package/notes/app/DESIGN.md +5 -2
  6. package/notes/app/e2e/fixtures.ts +39 -0
  7. package/notes/app/e2e/ja-typography-visual.spec.ts +185 -0
  8. package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-desktop-1280-chromium-linux.png +0 -0
  9. package/notes/app/e2e/ja-typography-visual.spec.ts-snapshots/ja-typography-mobile-360-chromium-linux.png +0 -0
  10. package/notes/app/e2e/rtl-logical-properties.spec.ts +36 -0
  11. package/notes/app/package-lock.json +0 -8
  12. package/notes/app/package.json +0 -1
  13. package/notes/app/playwright.config.ts +7 -0
  14. package/notes/app/pnpm-lock.yaml +0 -8
  15. package/notes/app/src/components/BranchSelectorModal.tsx +8 -8
  16. package/notes/app/src/components/CommentPanel.tsx +5 -5
  17. package/notes/app/src/components/DiffPreview.tsx +4 -4
  18. package/notes/app/src/components/FileContentView.tsx +1 -1
  19. package/notes/app/src/components/FileContextMenu.tsx +1 -1
  20. package/notes/app/src/components/FileInfoPanel.branch-tab.test.tsx +3 -35
  21. package/notes/app/src/components/IssueCommentThread.test.tsx +40 -19
  22. package/notes/app/src/components/IssueDetailView.test.tsx +3 -40
  23. package/notes/app/src/components/IssueList.tsx +3 -3
  24. package/notes/app/src/components/LabelSelector.tsx +1 -1
  25. package/notes/app/src/components/MarkdownEditor.tsx +1 -1
  26. package/notes/app/src/components/Skeleton.tsx +2 -2
  27. package/notes/app/src/components/ToastContainer.tsx +1 -1
  28. package/notes/app/src/components/TreeNode.tsx +6 -6
  29. package/notes/app/src/contexts/ToastContext.tsx +1 -1
  30. package/notes/app/src/hooks/useComments.ts +1 -1
  31. package/notes/app/src/hooks/useEditSession.ts +1 -1
  32. package/notes/app/src/lib/api.ts +1 -13
  33. package/notes/app/src/lib/csv-utils.ts +1 -1
  34. package/notes/app/src/lib/postLoginRedirect.ts +1 -1
  35. package/notes/app/src/lib/rtl-logical-properties.test.ts +59 -0
  36. package/notes/app/src/lib/user-colors.ts +1 -1
  37. package/notes/app/src/pages/MainPage.tsx +2 -2
  38. package/notes/app/src/pages/SettingsPage.test.tsx +40 -10
  39. package/notes/app/src/pages/SettingsPage.tsx +1 -1
  40. package/notes/app/src/pages/UserManagementPage.tsx +7 -7
  41. package/notes/app/src/styles/globals.css +17 -0
  42. package/notes/app/src/test/mockPeriodicSync.ts +68 -0
  43. package/notes/app/src/test/mocks.ts +1 -1
  44. package/package.json +7 -3
  45. package/templates/feedback/feedback-lambda/index.mjs +342 -0
  46. package/templates/feedback/frontend/FeedbackWidget.tsx +574 -0
  47. package/templates/feedback/frontend/api.ts +38 -0
  48. package/templates/feedback/frontend/composePinImage.ts +80 -0
  49. package/templates/feedback/frontend/config.ts +14 -0
  50. package/templates/feedback/frontend/types.ts +41 -0
  51. package/templates/feedback/terraform/Makefile +43 -0
  52. package/templates/feedback/terraform/backend.tf +10 -0
  53. package/templates/feedback/terraform/lambda.tf +95 -0
  54. package/templates/feedback/terraform/local.tf +12 -0
  55. package/templates/feedback/terraform/output.tf +9 -0
  56. package/templates/feedback/terraform/provider.tf +24 -0
  57. package/templates/feedback/terraform/variables.tf +25 -0
  58. package/templates/github-actions/aws/deploy-notes.yml.hbs +193 -10
  59. package/templates/notes-docs/001_project/README.md +0 -2
  60. package/templates/notes-docs/002_business_flow/README.md +19 -0
  61. package/templates/notes-docs/003_requirements/README.md +20 -0
  62. package/templates/notes-docs/003_requirements/questions.csv +1 -0
  63. package/templates/notes-docs/003_requirements/spec/README.md +11 -0
  64. package/templates/notes-docs/004_pages/README.md +22 -0
  65. package/templates/notes-docs/005_architecture/README.md +20 -0
  66. package/templates/notes-docs/006_development_workflow/README.md +67 -0
  67. package/templates/notes-docs/006_development_workflow/daily_flow.template.md +303 -0
  68. package/templates/notes-docs/006_development_workflow/git.template.md +72 -20
  69. package/templates/notes-docs/006_development_workflow/judgment.template.md +182 -0
  70. package/templates/notes-docs/006_development_workflow/workflow.template.md +167 -192
  71. package/templates/notes-docs/007_guides/README.md +85 -0
  72. package/templates/notes-docs/007_guides/bot-token-setup.md +174 -0
  73. package/templates/notes-docs/007_guides/getting_started.md +112 -0
  74. package/templates/notes-docs/007_guides/glossary.md +145 -0
  75. package/templates/notes-docs/007_guides/godd_notes_features.md +105 -0
  76. package/templates/notes-docs/007_guides/godd_notes_usage.md +28 -19
  77. package/templates/notes-docs/007_guides/godd_tools.md +129 -0
  78. package/templates/notes-docs/007_guides/godd_usage.md +88 -95
  79. package/templates/notes-docs/007_guides/infra/README.md +57 -0
  80. package/templates/notes-docs/007_guides/{setup_aws_accounts.md → infra/setup_aws_accounts.md} +3 -2
  81. package/templates/notes-docs/007_guides/{setup_infra.template.md → infra/setup_infra.template.md} +15 -4
  82. package/templates/notes-docs/007_guides/setup_godd.md +73 -22
  83. package/templates/notes-docs/007_guides/setup_godd_notes.md +97 -42
  84. package/templates/notes-docs/007_guides/setup_godd_notes.md.styles.json +2 -2
  85. package/templates/notes-docs/007_guides/setup_local_environment.template.md +81 -20
  86. package/templates/notes-docs/007_guides/troubleshooting.md +225 -0
  87. package/templates/notes-docs/999_ref/README.md +23 -0
  88. package/templates/notes-docs/GENERATED.md +1 -1
  89. package/templates/notes-docs/README.md +64 -34
  90. package/templates/terraform/aws/iam.tf.hbs +361 -4
  91. /package/templates/notes-docs/007_guides/{setup_aws_cost_alert.md → infra/setup_aws_cost_alert.md} +0 -0
  92. /package/templates/notes-docs/007_guides/{setup_aws_guard_duty.md → infra/setup_aws_guard_duty.md} +0 -0
@@ -27,6 +27,10 @@ import IssueCommentThread from "./IssueCommentThread";
27
27
  const mockList = vi.mocked(listIssueCommentsApi);
28
28
  const mockCreate = vi.mocked(createIssueCommentApi);
29
29
 
30
+ // Docker のフルスイート実行では 47 ファイル並列でリソースが競合し、既定の 1000ms を
31
+ // 超えて非同期解決することがある(#1143)。CI に耐える余裕を持たせるため明示指定する。
32
+ const WAIT_FOR_TIMEOUT = 3000;
33
+
30
34
  const COMMENT = {
31
35
  id: 1001,
32
36
  body: "Hello from GitHub",
@@ -45,49 +49,66 @@ describe("IssueCommentThread", () => {
45
49
  it("コメント一覧を時系列で表示する", async () => {
46
50
  mockList.mockResolvedValue({ comments: [COMMENT] });
47
51
  render(<IssueCommentThread issueNumber={42} canEdit={true} />);
48
- await waitFor(() => {
49
- expect(screen.getByText("alice")).toBeTruthy();
50
- expect(screen.getByTestId("comment-body").textContent).toBe("Hello from GitHub");
51
- });
52
+ await waitFor(
53
+ () => {
54
+ expect(screen.getByText("alice")).toBeTruthy();
55
+ expect(screen.getByTestId("comment-body").textContent).toBe("Hello from GitHub");
56
+ },
57
+ { timeout: WAIT_FOR_TIMEOUT },
58
+ );
52
59
  });
53
60
 
54
61
  it("canEdit=true のとき投稿フォームを表示する", async () => {
55
62
  render(<IssueCommentThread issueNumber={42} canEdit={true} />);
56
- await waitFor(() => {
57
- expect(screen.getByPlaceholderText("Markdown でコメントを入力…")).toBeTruthy();
58
- expect(screen.getByText("コメント投稿")).toBeTruthy();
59
- });
63
+ await waitFor(
64
+ () => {
65
+ expect(screen.getByPlaceholderText("Markdown でコメントを入力…")).toBeTruthy();
66
+ expect(screen.getByText("コメント投稿")).toBeTruthy();
67
+ },
68
+ { timeout: WAIT_FOR_TIMEOUT },
69
+ );
60
70
  });
61
71
 
62
72
  it("canEdit=false のとき投稿フォームを非表示にする", async () => {
63
73
  render(<IssueCommentThread issueNumber={42} canEdit={false} />);
64
- await waitFor(() => {
65
- expect(screen.queryByPlaceholderText("Markdown でコメントを入力…")).toBeNull();
66
- });
74
+ await waitFor(
75
+ () => {
76
+ expect(screen.queryByPlaceholderText("Markdown でコメントを入力…")).toBeNull();
77
+ },
78
+ { timeout: WAIT_FOR_TIMEOUT },
79
+ );
67
80
  });
68
81
 
69
82
  it("コメント投稿成功後に一覧へ追加する", async () => {
70
83
  const newComment = { ...COMMENT, id: 1002, body: "Posted" };
71
84
  mockCreate.mockResolvedValue(newComment);
72
85
  render(<IssueCommentThread issueNumber={42} canEdit={true} />);
73
- await waitFor(() => expect(screen.getByText("コメント投稿")).toBeTruthy());
86
+ await waitFor(() => expect(screen.getByText("コメント投稿")).toBeTruthy(), {
87
+ timeout: WAIT_FOR_TIMEOUT,
88
+ });
74
89
 
75
90
  fireEvent.change(screen.getByPlaceholderText("Markdown でコメントを入力…"), {
76
91
  target: { value: "Posted" },
77
92
  });
78
93
  fireEvent.click(screen.getByText("コメント投稿"));
79
94
 
80
- await waitFor(() => {
81
- expect(mockCreate).toHaveBeenCalledWith(42, "Posted");
82
- expect(screen.getByText("Posted")).toBeTruthy();
83
- });
95
+ await waitFor(
96
+ () => {
97
+ expect(mockCreate).toHaveBeenCalledWith(42, "Posted");
98
+ expect(screen.getByText("Posted")).toBeTruthy();
99
+ },
100
+ { timeout: WAIT_FOR_TIMEOUT },
101
+ );
84
102
  });
85
103
 
86
104
  it("読み込み失敗時にエラーメッセージを表示する", async () => {
87
105
  mockList.mockRejectedValue(new Error("GitHub API unavailable"));
88
106
  render(<IssueCommentThread issueNumber={42} canEdit={true} />);
89
- await waitFor(() => {
90
- expect(screen.getByText("GitHub API unavailable")).toBeTruthy();
91
- });
107
+ await waitFor(
108
+ () => {
109
+ expect(screen.getByText("GitHub API unavailable")).toBeTruthy();
110
+ },
111
+ { timeout: WAIT_FOR_TIMEOUT },
112
+ );
92
113
  });
93
114
  });
@@ -22,47 +22,10 @@ vi.mock("@/contexts/ToastContext", () => ({
22
22
 
23
23
  const triggerPeriodicSync = vi.hoisted(() => vi.fn());
24
24
  vi.mock("@/hooks/usePeriodicSync", () => {
25
- // 実フック(usePeriodicSync.ts)の挙動を忠実に再現する(#1006 / FileInfoPanel.branch-tab.test.tsx と同方式)。
26
- // onSync を ref 捕捉し、effect 依存は実フックと同じ [enabled, intervalMs, skipInitialSync]。
27
- // IssueDetailView は skipInitialSync=true(#929)を渡すため、本番同様マウント時の即時 poll は抑制され、
28
- // mount では foreground loadIssue のみが走る。これにより従来の mock が注入していた「phantom mount poll」
29
- // (本番には存在しない 2 本目の in-flight fetch)が無くなり、triggered poll との競合(#980 / #1031 で
30
- // setTimeout flush 緩和していた flaky の根本原因)が解消する。
31
- // 非モデル化(意図的): 実フックの isSyncing(in-flight 中の tick スキップ)/ visibilitychange(非表示中停止)/
32
- // setInterval は再現しない。poll は triggerPeriodicSync で明示発火するテストシームのため不要。これらの
33
- // 挙動に依存するテストを足す場合は本 mock の拡張が必要(現行テストはいずれも未依存)。
25
+ // vi.mock factory は hoist されるため、共有ヘルパも factory 内で解決する。
34
26
  // eslint-disable-next-line @typescript-eslint/no-require-imports -- mock factory 制約
35
- const { useEffect, useRef } = require("react") as typeof import("react");
36
- return {
37
- usePeriodicSync: ({
38
- onSync,
39
- enabled,
40
- intervalMs,
41
- skipInitialSync,
42
- }: {
43
- onSync: () => void;
44
- enabled: boolean;
45
- intervalMs: number;
46
- skipInitialSync?: boolean;
47
- }) => {
48
- const onSyncRef = useRef(onSync);
49
- const isInitialMountRef = useRef(true);
50
- useEffect(() => {
51
- onSyncRef.current = onSync;
52
- }, [onSync]);
53
- useEffect(() => {
54
- // trigger は常に最新 onSync を呼ぶ(mock 差し替え後の closed 応答を確実に拾う)。
55
- triggerPeriodicSync.mockImplementation(() => {
56
- if (enabled) onSyncRef.current();
57
- });
58
- const isInitialMount = isInitialMountRef.current;
59
- isInitialMountRef.current = false;
60
- if (!enabled || intervalMs <= 0) return;
61
- if (skipInitialSync && isInitialMount) return;
62
- onSyncRef.current();
63
- }, [enabled, intervalMs, skipInitialSync]);
64
- },
65
- };
27
+ const { faithfulPeriodicSyncMock } = require("../test/mockPeriodicSync.ts") as typeof import("../test/mockPeriodicSync");
28
+ return faithfulPeriodicSyncMock(triggerPeriodicSync);
66
29
  });
67
30
 
68
31
  vi.mock("@/components/FileContentView", () => ({
@@ -223,7 +223,7 @@ export default function IssueList({
223
223
  : "text-gray-400 hover:text-gray-200"
224
224
  }`}
225
225
  >
226
- <CircleDot className="w-3 h-3 inline mr-1" />
226
+ <CircleDot className="w-3 h-3 inline me-1" />
227
227
  Open
228
228
  </button>
229
229
  <button
@@ -235,7 +235,7 @@ export default function IssueList({
235
235
  : "text-gray-400 hover:text-gray-200"
236
236
  }`}
237
237
  >
238
- <CircleCheck className="w-3 h-3 inline mr-1" />
238
+ <CircleCheck className="w-3 h-3 inline me-1" />
239
239
  Closed
240
240
  </button>
241
241
  </div>
@@ -318,7 +318,7 @@ export default function IssueList({
318
318
  key={issue.number}
319
319
  type="button"
320
320
  onClick={() => onSelect(issue.number)}
321
- className={`w-full text-left px-2 rounded text-xs sm:text-xs mb-0.5 transition-colors ${ISSUE_MOBILE_LIST_ITEM} ${
321
+ className={`w-full text-start px-2 rounded text-xs sm:text-xs mb-0.5 transition-colors ${ISSUE_MOBILE_LIST_ITEM} ${
322
322
  selectedIssue === issue.number
323
323
  ? "bg-blue-600/20 text-blue-300"
324
324
  : "text-gray-300 hover:bg-gray-700/50"
@@ -307,7 +307,7 @@ export default function LabelSelector({
307
307
  style={{ backgroundColor: `#${label.color}` }}
308
308
  />
309
309
  <span
310
- className="flex-1 text-left truncate"
310
+ className="flex-1 text-start truncate"
311
311
  style={{ color: `#${label.color}` }}
312
312
  >
313
313
  {label.name}
@@ -770,7 +770,7 @@ export default function MarkdownEditor({
770
770
  <button
771
771
  type="button"
772
772
  onClick={() => setShowSearch(false)}
773
- className="text-gray-500 hover:text-gray-300 text-xs ml-auto"
773
+ className="text-gray-500 hover:text-gray-300 text-xs ms-auto"
774
774
  aria-label="閉じる"
775
775
  >
776
776
  <X className="w-3 h-3" />
@@ -11,13 +11,13 @@ export function TreeSkeleton() {
11
11
  return (
12
12
  <div className="space-y-2 p-2">
13
13
  <SkeletonLine width="60%" />
14
- <div className="ml-3 space-y-2">
14
+ <div className="ms-3 space-y-2">
15
15
  <SkeletonLine width="80%" />
16
16
  <SkeletonLine width="70%" />
17
17
  <SkeletonLine width="75%" />
18
18
  </div>
19
19
  <SkeletonLine width="55%" />
20
- <div className="ml-3 space-y-2">
20
+ <div className="ms-3 space-y-2">
21
21
  <SkeletonLine width="65%" />
22
22
  <SkeletonLine width="85%" />
23
23
  </div>
@@ -16,7 +16,7 @@ export default function ToastContainer() {
16
16
  if (toasts.length === 0) return null;
17
17
 
18
18
  return (
19
- <div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm">
19
+ <div className="fixed bottom-4 end-4 z-50 flex flex-col gap-2 max-w-sm">
20
20
  {toasts.map((toast) => {
21
21
  const style = STYLE_MAP[toast.type];
22
22
  return (
@@ -2,8 +2,8 @@ import { useState, useEffect } from "react";
2
2
  import { ChevronRight, ChevronDown, Folder, FolderOpen, File } from "lucide-react";
3
3
  import type { TreeEntry } from "@/types";
4
4
 
5
- export const DND_MIME = "application/x-notes-filepath";
6
- export const DND_TYPE_MIME = "application/x-notes-entrytype";
5
+ const DND_MIME = "application/x-notes-filepath";
6
+ const DND_TYPE_MIME = "application/x-notes-entrytype";
7
7
 
8
8
  const HIDDEN_FILES = new Set([".gitkeep"]);
9
9
 
@@ -95,14 +95,14 @@ export default function TreeNode({
95
95
  onDragOver={handleDragOver}
96
96
  onDragLeave={handleDragLeave}
97
97
  onDrop={handleDrop}
98
- className={`w-full text-left py-1 px-2 text-sm rounded flex items-center gap-1 transition-colors ${
98
+ className={`w-full text-start py-1 px-2 text-sm rounded flex items-center gap-1 transition-colors ${
99
99
  dropOver
100
100
  ? "bg-blue-700/40 ring-1 ring-blue-500"
101
101
  : isActiveDir
102
102
  ? "bg-blue-600/30 text-blue-200"
103
103
  : "hover:bg-gray-700"
104
104
  }`}
105
- style={{ paddingLeft: pl }}
105
+ style={{ paddingInlineStart: pl }}
106
106
  >
107
107
  {open
108
108
  ? <ChevronDown className="w-3.5 h-3.5 text-gray-400 shrink-0" />
@@ -142,10 +142,10 @@ export default function TreeNode({
142
142
  onDragStart={canDrag ? handleDragStart : undefined}
143
143
  onClick={() => onSelect(entry.path)}
144
144
  onContextMenu={handleCtx}
145
- className={`w-full text-left py-1 px-2 text-sm rounded flex items-center gap-1 ${
145
+ className={`w-full text-start py-1 px-2 text-sm rounded flex items-center gap-1 ${
146
146
  isSelected ? "bg-blue-600 text-white" : "hover:bg-gray-700"
147
147
  }`}
148
- style={{ paddingLeft: pl }}
148
+ style={{ paddingInlineStart: pl }}
149
149
  >
150
150
  <span className="w-3.5 shrink-0" />
151
151
  <File className="w-4 h-4 text-gray-400 shrink-0" />
@@ -10,7 +10,7 @@ import type { ReactNode } from "react";
10
10
 
11
11
  export type ToastType = "success" | "error" | "warning" | "info";
12
12
 
13
- export type Toast = {
13
+ type Toast = {
14
14
  id: string;
15
15
  message: string;
16
16
  type: ToastType;
@@ -12,7 +12,7 @@ import type { Comment, CommentCreateInput } from "@/lib/api";
12
12
 
13
13
  export type { Comment };
14
14
 
15
- export type CommentsState = {
15
+ type CommentsState = {
16
16
  comments: Comment[];
17
17
  loading: boolean;
18
18
  error: string | null;
@@ -4,7 +4,7 @@ import { usePeriodicSync } from "@/hooks/usePeriodicSync";
4
4
  import { GITHUB_SYNC_POLL_INTERVAL_MS } from "@/lib/github-sync";
5
5
 
6
6
  /** `/pr/my-session` が GitHub から付与する PR のライブ状態(DB の prNumber/prUrl は保持) */
7
- export type SessionPrGithub = {
7
+ type SessionPrGithub = {
8
8
  number: number;
9
9
  url: string;
10
10
  state: string;
@@ -268,18 +268,6 @@ export async function fetchHistoryApi(
268
268
  return res.json();
269
269
  }
270
270
 
271
- export async function fetchFileAtRefApi(
272
- path: string,
273
- ref: string,
274
- ): Promise<{ content: string; sha: string }> {
275
- const res = await apiFetch(`/files/${encodePath(path)}?ref=${encodeURIComponent(ref)}`);
276
- if (!res.ok) {
277
- const body = await res.json().catch(() => null);
278
- throw new Error(formatApiDetail(body, `Failed to fetch file at ref: ${res.status}`));
279
- }
280
- return res.json();
281
- }
282
-
283
271
  export async function updateSettingApi(
284
272
  key: string,
285
273
  value: unknown,
@@ -783,7 +771,7 @@ export function isAbortError(error: unknown): boolean {
783
771
  return false;
784
772
  }
785
773
 
786
- export function formatApiDetail(body: unknown, fallback: string): string {
774
+ function formatApiDetail(body: unknown, fallback: string): string {
787
775
  if (!body || typeof body !== "object") return fallback;
788
776
  const detail = (body as Record<string, unknown>).detail;
789
777
  if (typeof detail === "string") return detail;
@@ -2,7 +2,7 @@ import type { IWorkbookData, IWorksheetData, ICellData, IStyleData } from "@univ
2
2
 
3
3
  const LOCALE_EN_US = "enUS";
4
4
 
5
- export type SheetFreeze = {
5
+ type SheetFreeze = {
6
6
  xSplit: number;
7
7
  ySplit: number;
8
8
  startRow: number;
@@ -1,7 +1,7 @@
1
1
  import type { Location } from "react-router-dom";
2
2
 
3
3
  /** Passed from RequireAuth via react-router location.state */
4
- export type LoginRedirectState = { from?: { pathname: string; search?: string } };
4
+ type LoginRedirectState = { from?: { pathname: string; search?: string } };
5
5
 
6
6
  /** Reject protocol-relative paths and non-internal targets (open-redirect hardening). */
7
7
  function isSafeAppPath(pathname: string): boolean {
@@ -0,0 +1,59 @@
1
+ import { readdirSync, readFileSync } from "node:fs";
2
+ import { dirname, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { describe, expect, it } from "vitest";
5
+
6
+ const SOURCE_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "..");
7
+ const CLASS_DELIMITER = String.fromCharCode(96);
8
+ const PHYSICAL_INLINE_CLASS = new RegExp(
9
+ "(?:^|[\\s\\\"" +
10
+ CLASS_DELIMITER +
11
+ "])" +
12
+ "(?:-?(?:pl|pr|ml|mr)-[^\\s\\\"" +
13
+ CLASS_DELIMITER +
14
+ "}]+|border-[lr](?:-[^\\s\\\"" +
15
+ CLASS_DELIMITER +
16
+ "}]+)?|rounded-[lr](?:-[^\\s\\\"" +
17
+ CLASS_DELIMITER +
18
+ "}]+)?|text-(?:left|right)|(?:left|right)-(?:auto|full|0|px|[0-9]|\\[))" +
19
+ "(?=$|[\\s\\\"" +
20
+ CLASS_DELIMITER +
21
+ "}])",
22
+ "gm",
23
+ );
24
+
25
+ function findTsxFiles(directory: string): string[] {
26
+ return readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
27
+ const path = join(directory, entry.name);
28
+ if (entry.isDirectory()) return findTsxFiles(path);
29
+ if (!entry.name.endsWith(".tsx") || entry.name.endsWith(".test.tsx")) return [];
30
+ return [path];
31
+ });
32
+ }
33
+
34
+ describe("Notes app の inline 方向クラス", () => {
35
+ it("コンテンツ要素へ物理 inline プロパティを再導入しない", () => {
36
+ const violations: string[] = [];
37
+
38
+ for (const file of findTsxFiles(SOURCE_DIRECTORY)) {
39
+ const source = readFileSync(file, "utf8");
40
+ for (const match of source.matchAll(PHYSICAL_INLINE_CLASS)) {
41
+ violations.push(file + ": " + match[0].trim());
42
+ }
43
+ }
44
+
45
+ expect(violations).toEqual([]);
46
+ });
47
+
48
+ it("TreeNode の階層インデントは inline-start で指定する", () => {
49
+ const source = readFileSync(join(SOURCE_DIRECTORY, "components/TreeNode.tsx"), "utf8");
50
+ expect(source).toContain("paddingInlineStart");
51
+ expect(source).not.toContain("paddingLeft");
52
+ });
53
+
54
+ it("inline-end に配置する Toast の入場方向も RTL で反転する", () => {
55
+ const styles = readFileSync(join(SOURCE_DIRECTORY, "styles/globals.css"), "utf8");
56
+ expect(styles).toContain("@keyframes slide-in-rtl");
57
+ expect(styles).toMatch(/:dir\(rtl\) \.animate-slide-in\s*\{\s*animation-name:\s*slide-in-rtl/);
58
+ });
59
+ });
@@ -1,4 +1,4 @@
1
- export const USER_COLORS = [
1
+ const USER_COLORS = [
2
2
  "#ef4444", "#f97316", "#eab308", "#22c55e",
3
3
  "#06b6d4", "#3b82f6", "#8b5cf6", "#ec4899",
4
4
  ] as const;
@@ -979,7 +979,7 @@ export default function MainPage() {
979
979
  <button
980
980
  type="button"
981
981
  onClick={logout}
982
- className="text-gray-500 hover:text-gray-300 ml-1"
982
+ className="text-gray-500 hover:text-gray-300 ms-1"
983
983
  title="ログアウト"
984
984
  aria-label="ログアウト"
985
985
  >
@@ -990,7 +990,7 @@ export default function MainPage() {
990
990
  <button
991
991
  type="button"
992
992
  onClick={() => setSidebarOpen(false)}
993
- className="text-gray-500 hover:text-gray-300 ml-2 md:hidden"
993
+ className="text-gray-500 hover:text-gray-300 ms-2 md:hidden"
994
994
  title="閉じる"
995
995
  aria-label="閉じる"
996
996
  >
@@ -12,6 +12,10 @@ const MOCK_SETTINGS = {
12
12
  lockout_duration_min: 15,
13
13
  };
14
14
 
15
+ // CI の並列負荷で設定ロード・React の再描画が 1 秒を超えても、テストを早期に
16
+ // timeout させない。Vitest のテスト単位 timeout(既定 5 秒)内で失敗を返せる値にする。
17
+ const SETTINGS_WAIT_TIMEOUT_MS = 3_000;
18
+
15
19
  const mockGetSettings = vi.fn().mockResolvedValue(MOCK_SETTINGS);
16
20
  const mockUpdateSetting = vi.fn().mockImplementation(async (key: string, value: unknown) => ({
17
21
  key,
@@ -46,8 +50,15 @@ function renderPage() {
46
50
  * ので、タイムゾーンの反映が確認できれば他フィールドも反映済み。
47
51
  */
48
52
  async function waitForSettledSettings() {
49
- const tz = (await screen.findByLabelText("タイムゾーン")) as HTMLInputElement;
50
- await waitFor(() => expect(tz.value).toBe("Asia/Tokyo"));
53
+ const tz = (await screen.findByLabelText(
54
+ "タイムゾーン",
55
+ {},
56
+ { timeout: SETTINGS_WAIT_TIMEOUT_MS },
57
+ )) as HTMLInputElement;
58
+ await waitFor(
59
+ () => expect(tz.value).toBe("Asia/Tokyo"),
60
+ { timeout: SETTINGS_WAIT_TIMEOUT_MS },
61
+ );
51
62
  return tz;
52
63
  }
53
64
 
@@ -59,17 +70,31 @@ describe("SettingsPage", () => {
59
70
 
60
71
  it("renders settings after loading", async () => {
61
72
  renderPage();
62
- await waitFor(() => expect(screen.getByText("一般設定")).toBeTruthy());
73
+ await waitFor(
74
+ () => expect(screen.getByText("一般設定")).toBeTruthy(),
75
+ { timeout: SETTINGS_WAIT_TIMEOUT_MS },
76
+ );
63
77
  expect(screen.getByText("セキュリティ設定")).toBeTruthy();
64
78
  expect(screen.getByLabelText("タイムゾーン")).toBeTruthy();
65
79
  });
66
80
 
67
81
  it("shows back link to main page", async () => {
68
82
  renderPage();
69
- await waitFor(() => expect(screen.getByText("戻る")).toBeTruthy());
83
+ await waitFor(
84
+ () => expect(screen.getByText("戻る")).toBeTruthy(),
85
+ { timeout: SETTINGS_WAIT_TIMEOUT_MS },
86
+ );
70
87
  });
71
88
 
72
89
  it("calls updateSettingApi on save", async () => {
90
+ // 設定ロードが waitFor の既定 timeout(1 秒)をわずかに超える CI 負荷を再現する。
91
+ // 待機側に十分な明示 timeout がなければ、入力操作へ到達する前にテストが失敗する。
92
+ mockGetSettings.mockImplementationOnce(
93
+ () =>
94
+ new Promise((resolve) => {
95
+ setTimeout(() => resolve({ ...MOCK_SETTINGS }), 1_100);
96
+ }),
97
+ );
73
98
  renderPage();
74
99
  const input = await waitForSettledSettings();
75
100
 
@@ -77,17 +102,22 @@ describe("SettingsPage", () => {
77
102
  fireEvent.change(input, { target: { value: "UTC" } });
78
103
  });
79
104
 
80
- await waitFor(() => {
81
- const btn = screen.getAllByText("保存")[0] as HTMLButtonElement;
82
- expect(btn.disabled).toBe(false);
83
- });
105
+ await waitFor(
106
+ () => {
107
+ const btn = screen.getAllByText("保存")[0] as HTMLButtonElement;
108
+ expect(btn.disabled).toBe(false);
109
+ },
110
+ { timeout: SETTINGS_WAIT_TIMEOUT_MS },
111
+ );
84
112
 
85
113
  await act(async () => {
86
114
  fireEvent.click(screen.getAllByText("保存")[0]);
87
115
  });
88
116
 
89
- await waitFor(() =>
90
- expect(mockUpdateSetting).toHaveBeenCalledWith("timezone", "UTC"),
117
+ await waitFor(
118
+ () =>
119
+ expect(mockUpdateSetting).toHaveBeenCalledWith("timezone", "UTC"),
120
+ { timeout: SETTINGS_WAIT_TIMEOUT_MS },
91
121
  );
92
122
  });
93
123
 
@@ -201,7 +201,7 @@ export default function SettingsPage() {
201
201
  </Link>
202
202
  <h1 className="text-base font-bold tracking-wide">設定</h1>
203
203
  {!isAdmin && (
204
- <span className="ml-auto text-xs text-yellow-400 bg-yellow-900/30 border border-yellow-700/50 rounded px-2 py-0.5">
204
+ <span className="ms-auto text-xs text-yellow-400 bg-yellow-900/30 border border-yellow-700/50 rounded px-2 py-0.5">
205
205
  閲覧のみ
206
206
  </span>
207
207
  )}
@@ -303,7 +303,7 @@ function UserRow({
303
303
  <span className="text-sm font-medium text-gray-100">
304
304
  {user.displayName}
305
305
  {isSelf && (
306
- <span className="ml-2 text-xs text-green-400 bg-green-900/30 border border-green-700/50 rounded px-1.5 py-0.5">
306
+ <span className="ms-2 text-xs text-green-400 bg-green-900/30 border border-green-700/50 rounded px-1.5 py-0.5">
307
307
  自分
308
308
  </span>
309
309
  )}
@@ -467,7 +467,7 @@ export default function UserManagementPage() {
467
467
  <ArrowLeft className="inline w-4 h-4" /> 戻る
468
468
  </Link>
469
469
  <h1 className="text-base font-bold tracking-wide">ユーザー管理</h1>
470
- <div className="ml-auto">
470
+ <div className="ms-auto">
471
471
  <button
472
472
  type="button"
473
473
  onClick={() => setShowCreateModal(true)}
@@ -492,19 +492,19 @@ export default function UserManagementPage() {
492
492
  <table className="w-full min-w-[600px]">
493
493
  <thead>
494
494
  <tr className="border-b border-gray-700 bg-gray-800/80">
495
- <th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
495
+ <th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
496
496
  ユーザー
497
497
  </th>
498
- <th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
498
+ <th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
499
499
  ロール
500
500
  </th>
501
- <th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
501
+ <th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
502
502
  状態
503
503
  </th>
504
- <th className="px-4 py-2.5 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">
504
+ <th className="px-4 py-2.5 text-start text-xs font-semibold text-gray-400 uppercase tracking-wider">
505
505
  作成日
506
506
  </th>
507
- <th className="px-4 py-2.5 text-right text-xs font-semibold text-gray-400 uppercase tracking-wider">
507
+ <th className="px-4 py-2.5 text-end text-xs font-semibold text-gray-400 uppercase tracking-wider">
508
508
  操作
509
509
  </th>
510
510
  </tr>
@@ -88,6 +88,23 @@
88
88
  animation: slide-in 0.25s ease-out;
89
89
  }
90
90
 
91
+ /* Toast は inline-end に配置するため、RTL では入場方向も反転し、
92
+ 論理位置と同じ物理端からスライドさせる。 */
93
+ @keyframes slide-in-rtl {
94
+ from {
95
+ transform: translateX(-100%);
96
+ opacity: 0;
97
+ }
98
+ to {
99
+ transform: translateX(0);
100
+ opacity: 1;
101
+ }
102
+ }
103
+
104
+ :dir(rtl) .animate-slide-in {
105
+ animation-name: slide-in-rtl;
106
+ }
107
+
91
108
  /* Syntax highlighting — Atom One Dark inspired */
92
109
  .hljs-keyword,
93
110
  .hljs-selector-tag { color: #c678dd; }