Forráskód Böngészése

-init:初始化项目

LuoChinWen 3 hónapja
commit
53564ecbf7
100 módosított fájl, 8134 hozzáadás és 0 törlés
  1. 669 0
      .cursor/rules/cypress-测试规范.mdc
  2. 128 0
      .cursor/rules/react-规范.mdc
  3. 81 0
      .cursor/rules/tailwind-规范.mdc
  4. 57 0
      .cursor/rules/typescript-规范.mdc
  5. 127 0
      .gitignore
  6. 805 0
      .kiro/specs/annotation-platform/design.md
  7. 161 0
      .kiro/specs/annotation-platform/requirements.md
  8. 387 0
      .kiro/specs/annotation-platform/tasks.md
  9. 2 0
      .vscode/settings.json
  10. 9 0
      backend/.env.example
  11. 62 0
      backend/README.md
  12. BIN
      backend/__pycache__/database.cpython-311.pyc
  13. BIN
      backend/annotation_platform.db
  14. 92 0
      backend/database.py
  15. 62 0
      backend/main.py
  16. 102 0
      backend/models.py
  17. 4 0
      backend/requirements.txt
  18. 3 0
      backend/routers/__init__.py
  19. 3 0
      backend/schemas/__init__.py
  20. 3 0
      backend/services/__init__.py
  21. 13 0
      web/.editorconfig
  22. 127 0
      web/.gitignore
  23. 5 0
      web/.prettierignore
  24. 3 0
      web/.prettierrc
  25. 1 0
      web/.stylelintignore
  26. 54 0
      web/.stylelintrc.json
  27. 99 0
      web/README.md
  28. 4 0
      web/__mocks__/@adobe/css-tools.js
  29. 0 0
      web/apps/.gitkeep
  30. 11 0
      web/apps/labelstudio-e2e/cypress.config.ts
  31. 26 0
      web/apps/labelstudio-e2e/project.json
  32. 1 0
      web/apps/labelstudio-e2e/src/e2e/app.cy.ts
  33. 0 0
      web/apps/labelstudio-e2e/src/support/commands.ts
  34. 17 0
      web/apps/labelstudio-e2e/src/support/e2e.ts
  35. 10 0
      web/apps/labelstudio-e2e/tsconfig.json
  36. 11 0
      web/apps/labelstudio/.babelrc
  37. 119 0
      web/apps/labelstudio/README.md
  38. 14 0
      web/apps/labelstudio/jest.config.ts
  39. 92 0
      web/apps/labelstudio/project.json
  40. 95 0
      web/apps/labelstudio/src/app/App.jsx
  41. 90 0
      web/apps/labelstudio/src/app/App.scss
  42. 272 0
      web/apps/labelstudio/src/app/AsyncPage/AsyncPage.jsx
  43. 92 0
      web/apps/labelstudio/src/app/ErrorBoundary.jsx
  44. 21 0
      web/apps/labelstudio/src/app/RootPage.jsx
  45. 72 0
      web/apps/labelstudio/src/app/StaticContent/StaticContent.jsx
  46. 7 0
      web/apps/labelstudio/src/assets/images/heidi-ai.svg
  47. 4 0
      web/apps/labelstudio/src/assets/images/heidi-speaking.svg
  48. 3 0
      web/apps/labelstudio/src/assets/images/index.js
  49. 17 0
      web/apps/labelstudio/src/assets/images/logo.svg
  50. 98 0
      web/apps/labelstudio/src/components/Breadcrumbs/Breadcrumbs.jsx
  51. 112 0
      web/apps/labelstudio/src/components/Breadcrumbs/Breadcrumbs.scss
  52. 22 0
      web/apps/labelstudio/src/components/Card/Card.jsx
  53. 25 0
      web/apps/labelstudio/src/components/Card/Card.scss
  54. 26 0
      web/apps/labelstudio/src/components/Columns/Columns.jsx
  55. 17 0
      web/apps/labelstudio/src/components/Columns/Columns.scss
  56. 22 0
      web/apps/labelstudio/src/components/CopyableTooltip/CopyableTooltip.jsx
  57. 32 0
      web/apps/labelstudio/src/components/DescriptionList/DescriptionList.jsx
  58. 31 0
      web/apps/labelstudio/src/components/DescriptionList/DescriptionList.scss
  59. 3 0
      web/apps/labelstudio/src/components/Divider/Divider.jsx
  60. 60 0
      web/apps/labelstudio/src/components/DraftGuard/DraftGuard.jsx
  61. 14 0
      web/apps/labelstudio/src/components/EmptyState/EmptyState.jsx
  62. 59 0
      web/apps/labelstudio/src/components/EmptyState/EmptyState.scss
  63. 131 0
      web/apps/labelstudio/src/components/Error/Error.jsx
  64. 104 0
      web/apps/labelstudio/src/components/Error/Error.scss
  65. 13 0
      web/apps/labelstudio/src/components/Error/InlineError.d.ts
  66. 23 0
      web/apps/labelstudio/src/components/Error/InlineError.jsx
  67. 0 0
      web/apps/labelstudio/src/components/Error/PauseError
  68. 176 0
      web/apps/labelstudio/src/components/Form/Elements/Counter/Counter.jsx
  69. 68 0
      web/apps/labelstudio/src/components/Form/Elements/Counter/Counter.scss
  70. 45 0
      web/apps/labelstudio/src/components/Form/Elements/Input/Input.jsx
  71. 68 0
      web/apps/labelstudio/src/components/Form/Elements/Input/Input.scss
  72. 70 0
      web/apps/labelstudio/src/components/Form/Elements/Label/Label.jsx
  73. 185 0
      web/apps/labelstudio/src/components/Form/Elements/Label/Label.scss
  74. 114 0
      web/apps/labelstudio/src/components/Form/Elements/RadioGroup/RadioGroup.jsx
  75. 159 0
      web/apps/labelstudio/src/components/Form/Elements/RadioGroup/RadioGroup.scss
  76. 80 0
      web/apps/labelstudio/src/components/Form/Elements/Select/Select.jsx
  77. 23 0
      web/apps/labelstudio/src/components/Form/Elements/TextArea/TextArea.jsx
  78. 71 0
      web/apps/labelstudio/src/components/Form/Elements/Toggle/Toggle.jsx
  79. 6 0
      web/apps/labelstudio/src/components/Form/Elements/index.ts
  80. 573 0
      web/apps/labelstudio/src/components/Form/Form.jsx
  81. 98 0
      web/apps/labelstudio/src/components/Form/Form.scss
  82. 16 0
      web/apps/labelstudio/src/components/Form/FormContext.js
  83. 114 0
      web/apps/labelstudio/src/components/Form/FormField.js
  84. 15 0
      web/apps/labelstudio/src/components/Form/Utils.ts
  85. 29 0
      web/apps/labelstudio/src/components/Form/Validation/Validation.scss
  86. 40 0
      web/apps/labelstudio/src/components/Form/Validation/Validators.js
  87. 2 0
      web/apps/labelstudio/src/components/Form/index.js
  88. 14 0
      web/apps/labelstudio/src/components/Hamburger/Hamburger.jsx
  89. 54 0
      web/apps/labelstudio/src/components/Hamburger/Hamburger.scss
  90. 63 0
      web/apps/labelstudio/src/components/HeidiTips/HeidiTip.scss
  91. 59 0
      web/apps/labelstudio/src/components/HeidiTips/HeidiTip.tsx
  92. 10 0
      web/apps/labelstudio/src/components/HeidiTips/HeidiTips.tsx
  93. 270 0
      web/apps/labelstudio/src/components/HeidiTips/content.ts
  94. 19 0
      web/apps/labelstudio/src/components/HeidiTips/hooks.ts
  95. 288 0
      web/apps/labelstudio/src/components/HeidiTips/liveContent.json
  96. 30 0
      web/apps/labelstudio/src/components/HeidiTips/types.ts
  97. 151 0
      web/apps/labelstudio/src/components/HeidiTips/utils.ts
  98. 128 0
      web/apps/labelstudio/src/components/LeaveBlocker/LeaveBlocker.tsx
  99. 90 0
      web/apps/labelstudio/src/components/Menu/Menu.jsx
  100. 177 0
      web/apps/labelstudio/src/components/Menu/Menu.scss

+ 669 - 0
.cursor/rules/cypress-测试规范.mdc

@@ -0,0 +1,669 @@
+---
+description: 为 Label Studio 编写和更新 Cypress 集成测试
+globs: 
+alwaysApply: false
+---
+## Label Studio 的 Cypress 测试生成规则
+
+### 项目结构和组织
+
+**测试文件结构:**
+- 测试应放置在 `web/libs/editor/tests/integration/e2e/` 中,采用语义化文件夹组织
+- 遵循现有文件夹结构:`core/`、`image_segmentation/`、`control_tags/`、`audio/`、`video/`、`timeseries/`、`relations/`、`outliner/`、`bulk_mode/`、`config/`、`drafts/`、`linking_modes/`、`ner/`、`sync/`、`view_all/`
+- 测试文件应以 `.cy.ts` 扩展名结尾
+- 测试数据应放置在 `web/libs/editor/tests/integration/data/` 中,遵循相同的文件夹结构
+
+**文件命名约定:**
+- 使用反映被测试功能的描述性名称
+- 使用 kebab-case 命名文件(例如 `audio-regions.cy.ts`、`image-segmentation.cy.ts`)
+- 将相关测试分组到逻辑文件夹中
+
+### 导入标准
+
+**必需的导入:**
+始终从集中的辅助库导入辅助函数:
+```typescript
+import { LabelStudio, ImageView, Sidebar, Labels, Hotkeys } from "@humansignal/frontend-test/helpers/LSF";
+```
+
+**测试数据导入:**
+使用相对路径从数据文件夹导入测试数据:
+```typescript
+import { configName, dataName, resultName } from "../../data/folder_name/file_name";
+```
+
+**可用的辅助函数:**
+- `LabelStudio` - 核心初始化和控制
+- `ImageView` - 图像交互和绘制
+- `VideoView` - 视频播放和交互
+- `AudioView` - 音频播放和区域
+- `Sidebar` - 大纲视图和区域管理
+- `Labels` - 标签选择和管理
+- `Hotkeys` - 跨平台键盘快捷键(Mac/PC 兼容性)
+- `Taxonomy`、`Choices`、`DateTime`、`Number`、`Rating`、`Textarea` - 控制标签辅助函数
+- `Relations` - 关系管理
+- `ToolBar` - 工具栏交互
+- `Modals` - 模态对话框处理
+- `Tooltip` - 工具提示验证
+
+### 测试结构标准
+
+**基本测试结构:**
+```typescript
+describe("功能名称 - 特定区域", () => {
+  it("应该执行特定操作", () => {
+    // 测试实现
+  });
+});
+```
+
+**嵌套 Describes:**
+使用嵌套的 describe 块进行逻辑分组:
+```typescript
+describe("图像分割", () => {
+  describe("矩形工具", () => {
+    it("应该绘制矩形", () => {
+      // 测试实现
+    });
+  });
+});
+```
+
+### LabelStudio 初始化模式
+
+**简单初始化:**
+```typescript
+LabelStudio.init({
+  config: configString,
+  task: {
+    id: 1,
+    annotations: [{ id: 1001, result: [] }],
+    predictions: [],
+    data: { image: "url" },
+  },
+});
+```
+
+**流式 API 初始化(推荐):**
+```typescript
+LabelStudio.params()
+  .config(configString)
+  .data(dataObject)
+  .withResult(expectedResult)
+  .init();
+```
+
+**带附加参数:**
+```typescript
+LabelStudio.params()
+  .config(config)
+  .data(data)
+  .withResult([])
+  .withInterface("panel")
+  .withEventListener("eventName", handlerFunction)
+  .withParam("customParam", value)
+  .init();
+```
+
+### 必需的测试准备步骤
+
+**始终包含:**
+1. LabelStudio 初始化
+2. 等待对象就绪:`LabelStudio.waitForObjectsReady();`
+3. (可选,通常 waitForObjectsReady 就足够了)等待媒体加载(用于图像/视频/音频):`ImageView.waitForImage();`
+4. 初始状态验证:`Sidebar.hasNoRegions();`
+5. (可选,如果可能)操作后的某些状态验证,例如:`Sidebar.hasRegions(count);`
+
+### 交互模式
+
+**图像交互:**
+```typescript
+// 等待图像加载
+ImageView.waitForImage();
+
+// 选择工具
+ImageView.selectRectangleToolByButton();
+ImageView.selectPolygonToolByButton();
+
+// 绘制操作
+ImageView.drawRect(x, y, width, height);
+ImageView.drawRectRelative(0.1, 0.1, 0.4, 0.8); // 推荐
+
+// 点击交互
+ImageView.clickAt(x, y);
+ImageView.clickAtRelative(0.5, 0.5); // 推荐
+
+// 截图比较
+ImageView.capture("screenshot_name");
+ImageView.canvasShouldChange("screenshot_name", threshold);
+```
+
+**标签管理:**
+```typescript
+// 绘制前选择标签
+Labels.select("标签名称");
+
+// 验证标签选择
+Labels.isSelected("标签名称");
+```
+
+**侧边栏操作:**
+```typescript
+// 区域验证
+Sidebar.hasRegions(count);
+Sidebar.hasNoRegions();
+Sidebar.hasSelectedRegions(count);
+
+// 区域操作
+Sidebar.toggleRegionVisibility(index);
+Sidebar.toggleRegionSelection(index);
+```
+
+### 断言模式
+
+**标准 Cypress 断言:**
+```typescript
+cy.get(selector).should("be.visible");
+cy.get(selector).should("have.text", "期望文本");
+cy.get(selector).should("have.class", "class-name");
+```
+
+**自定义辅助断言:**
+```typescript
+Sidebar.hasRegions(expectedCount);
+Sidebar.hasSelectedRegions(expectedCount);
+ImageView.canvasShouldChange("screenshot", threshold);
+```
+
+**访问 Window 对象:**
+```typescript
+cy.window().then((win) => {
+  expect(win.Htx.annotationStore.selected.names.get("image")).to.exist;
+});
+```
+
+### 测试数据结构
+
+**配置格式:**
+```typescript
+export const configName = `
+  <View>
+    <Image name="img" value="$image"/>
+    <RectangleLabels name="tag" toName="img">
+      <Label value="Planet"/>
+      <Label value="Moonwalker" background="blue"/>
+    </RectangleLabels>
+  </View>
+`;
+```
+
+**数据格式:**
+```typescript
+export const dataName = {
+  image: "https://htx-pub.s3.us-east-1.amazonaws.com/examples/images/example.jpg",
+  text: "用于处理的示例文本",
+};
+```
+
+**结果格式:**
+```typescript
+export const resultName = [
+  {
+    id: "unique_id",
+    type: "rectanglelabels",
+    value: {
+      x: 10.5,
+      y: 15.2,
+      width: 25.8,
+      height: 30.1,
+      rectanglelabels: ["Planet"]
+    },
+    origin: "manual",
+    to_name: "img",
+    from_name: "tag",
+  }
+];
+```
+
+### 功能标志管理
+
+**设置功能标志:**
+```typescript
+// 导航前
+LabelStudio.setFeatureFlagsOnPageLoad({
+  featureName: true,
+});
+
+// 导航后(通常不需要)
+LabelStudio.setFeatureFlags({
+  featureName: true,
+});
+
+// 验证
+LabelStudio.featureFlag("featureName").should("be.true");
+```
+
+### 错误处理和重试策略
+
+**对于不稳定的测试:**
+```typescript
+const suiteConfig = {
+  retries: {
+    runMode: 3,
+    openMode: 0,
+  },
+};
+
+describe("测试套件名称", suiteConfig, () => {
+  // 这里是测试
+});
+```
+
+### 日志和调试
+
+**始终包含描述性日志:**
+```typescript
+cy.log("使用图像分割配置初始化 LSF");
+cy.log("在相对位置绘制矩形");
+cy.log("验证区域已创建");
+```
+
+### 性能考虑
+
+**使用相对坐标:**
+- 优先使用 `*Relative()` 方法而非绝对坐标
+- 使用 `ImageView.drawRectRelative()` 而非 `ImageView.drawRect()`
+
+**高效等待:**
+- 使用 `LabelStudio.waitForObjectsReady()` 进行初始化检查
+- 使用 UI 状态检查确保操作已完成且 UI 已准备好进行下一个操作
+- **避免使用 `cy.wait(milliseconds)` - 改用基于事件或基于状态的等待**
+
+**等待最佳实践:**
+
+```typescript
+// ❌ 错误 - 任意时间等待不可靠且缓慢
+cy.wait(500); // 不知道操作是否真正完成
+cy.wait(300); // 在慢速机器上可能太短,在快速机器上太长
+
+// ✅ 正确 - 等待特定的 UI 状态变化
+Sidebar.hasRegions(1); // 等待区域出现
+Labels.isSelected("标签名称"); // 等待标签选择
+cy.get(".loading-spinner").should("not.exist"); // 等待加载完成
+
+// ✅ 正确 - 等待元素状态变化
+cy.get("[data-testid='submit-button']").should("be.enabled");
+cy.get(".htx-timeseries-channel svg").should("be.visible");
+cy.get(".region").should("have.class", "selected");
+
+// ✅ 正确 - 等待网络请求(必要时)
+cy.intercept("POST", "/api/annotations").as("saveAnnotation");
+cy.get("[data-testid='save-button']").click();
+cy.wait("@saveAnnotation"); // 等待特定的网络调用
+
+// ✅ 正确 - 等待动画完成
+cy.get(".modal").should("have.class", "fade-in-complete");
+cy.get(".tooltip").should("be.visible").and("not.have.class", "animating");
+```
+
+**何时基于时间的等待可能可以接受:**
+- 非常短的等待(50ms 或更少)用于 UI 防抖
+- 单个动画帧等待(16-32ms)用于快速重渲染
+- 当没有完成事件可用时等待 CSS 动画
+- 解决已知的浏览器时序问题(记录为临时修复)
+
+**使用常量而非魔法数字:**
+
+```typescript
+// 导入时序常量
+import { SINGLE_FRAME_TIMEOUT, TWO_FRAMES_TIMEOUT } from "../utils/constants";
+
+// ✅ 正确 - 使用常量表示帧时序
+cy.wait(SINGLE_FRAME_TIMEOUT); // 等待单个动画帧(60fps)
+cy.wait(TWO_FRAMES_TIMEOUT); // 等待 1-2 个动画帧用于快速但更复杂的重渲染
+
+// ❌ 错误 - 魔法数字不清楚
+cy.wait(16); // 16 是什么?为什么是 16?
+cy.wait(32); // 32 是什么?为什么是 32?
+
+// ✅ 正确 - 使用意图明确的常量
+ImageView.clickAt(100, 100);
+cy.wait(SINGLE_FRAME_TIMEOUT); // 允许画布完成重绘
+
+// 对于其他时序需求可接受,需要文档说明
+cy.wait(50); // 允许防抖输入稳定
+cy.wait(100); // TODO: 替换为适当的动画完成检查
+```
+
+**截图比较:**
+```typescript
+// 操作前捕获
+ImageView.capture("before_action");
+
+// 执行操作
+Labels.select("Label");
+ImageView.drawRectRelative(0.2, 0.2, 0.6, 0.6);
+
+// 验证视觉变化
+ImageView.canvasShouldChange("before_action", 0.1);
+```
+- 仅在必要时使用,因为它们可能会减慢测试速度
+
+### 常见测试模式
+
+**基本绘制测试:**
+```typescript
+it("应该绘制矩形区域", () => {
+  LabelStudio.params()
+    .config(imageConfig)
+    .data(imageData)
+    .withResult([])
+    .init();
+
+  LabelStudio.waitForObjectsReady();
+  ImageView.waitForImage();
+  Sidebar.hasNoRegions();
+
+  Labels.select("标签名称");
+  ImageView.drawRectRelative(0.1, 0.1, 0.4, 0.8);
+
+  Sidebar.hasRegions(1);
+});
+```
+
+**控制标签交互测试:**
+```typescript
+it("应该选择分类选项", () => {
+  LabelStudio.params()
+    .config(taxonomyConfig)
+    .data(simpleData)
+    .withResult([])
+    .init();
+
+  Taxonomy.open();
+  Taxonomy.findItem("选项 1").click();
+  Taxonomy.hasSelected("选项 1");
+});
+```
+
+**状态验证测试:**
+```typescript
+it("应该在交互后保持状态", () => {
+  LabelStudio.params()
+    .config(config)
+    .data(data)
+    .withResult(existingResult)
+    .init();
+
+  LabelStudio.waitForObjectsReady();
+  Sidebar.hasRegions(1);
+
+  // 执行操作
+  cy.contains("button", "更新").click();
+
+  // 验证状态
+  LabelStudio.serialize().then((results) => {
+    expect(results).to.have.length(1);
+    expect(results[0].value).to.deep.equal(expectedValue);
+  });
+});
+```
+
+### 无障碍性和用户体验
+
+**包含适当的 ARIA 标签:**
+```typescript
+cy.get('[aria-label="rectangle-tool"]').click();
+```
+
+**测试键盘交互:**
+```typescript
+// 始终优先使用 Hotkeys 辅助函数以实现跨平台兼容性
+Hotkeys.undo(); // 自动处理 Ctrl+Z/Cmd+Z
+Hotkeys.redo(); // 自动处理 Ctrl+Shift+Z/Cmd+Shift+Z
+Hotkeys.deleteRegion(); // Backspace
+Hotkeys.deleteAllRegions(); // Ctrl+Backspace/Cmd+Backspace
+Hotkeys.unselectAllRegions(); // Escape
+
+// 其他可用的特定辅助函数
+ImageView.zoomIn(); // 放大
+Labels.selectWithHotkey("1"); // 通过快捷键选择标签
+
+// 直接 Cypress 命令(在大多数情况下避免 - 仅作为最后手段使用)
+cy.get("body").type("{esc}"); // ❌ 优先使用 Hotkeys.unselectAllRegions()
+cy.get("body").type("{ctrl}{+}"); // ❌ 不跨平台,优先使用辅助函数
+cy.get("body").type("{cmd}{+}"); // ❌ 平台特定,优先使用辅助函数
+```
+
+**重要:** 在大多数情况下,你应该**避免**直接使用 Cypress 键盘命令(`cy.get("body").type()`)。改用辅助函数,因为:
+- `Hotkeys` 辅助函数自动处理 Mac(Cmd)与 PC(Ctrl)的差异
+- 辅助函数提供更好的抽象,更易于维护
+- 辅助函数不太容易出现跨平台问题
+- 仅在没有辅助函数存在且需要非常特定的键盘交互时使用直接命令
+
+### 创建新辅助函数
+
+**辅助函数架构模式:**
+
+Label Studio 使用两种主要模式创建辅助函数:
+
+1. **静态对象模式**(用于单例组件):
+```typescript
+export const ComponentName = {
+  get root() {
+    return cy.get(".component-selector");
+  },
+  
+  get subElement() {
+    return this.root.find(".sub-element");
+  },
+  
+  performAction() {
+    cy.log("在 ComponentName 上执行操作");
+    this.root.click();
+  },
+  
+  assertState(expectedValue: string) {
+    this.subElement.should("contain.text", expectedValue);
+  }
+};
+```
+
+2. **基于类的模式**(用于可参数化的组件):
+```typescript
+class ComponentHelper {
+  private get _baseRootSelector() {
+    return ".component-base";
+  }
+  
+  private _rootSelector: string;
+  
+  constructor(rootSelector: string) {
+    this._rootSelector = rootSelector.replace(/^\&/, this._baseRootSelector);
+  }
+  
+  get root() {
+    return cy.get(this._rootSelector);
+  }
+  
+  performAction() {
+    cy.log(`在 ${this._rootSelector} 上执行操作`);
+    this.root.click();
+  }
+}
+
+// 导出单例和工厂
+const ComponentName = new ComponentHelper("&:eq(0)");
+const useComponentName = (rootSelector: string) => {
+  return new ComponentHelper(rootSelector);
+};
+
+export { ComponentName, useComponentName };
+```
+
+**辅助函数创建规则:**
+
+1. **文件放置:**
+   - 将 UI 辅助函数放在 `web/libs/frontend-test/src/helpers/LSF/`
+   - 将工具函数放在 `web/libs/frontend-test/src/helpers/utils/`
+   - 文件名使用 PascalCase(例如 `MyComponent.ts`)
+
+2. **工具 vs 辅助函数决策:**
+   - **提取到 `utils/`** 如果函数:
+     - 不使用 Cypress 命令(`cy.*`)
+     - 不特定于任何 UI 组件
+     - 执行通用计算、解析或数据操作
+     - 可以独立进行单元测试
+   - **保留在辅助类中** 如果函数:
+     - 使用 Cypress 命令进行 UI 交互
+     - 特定于 UI 组件
+     - 管理元素状态或用户交互
+
+```typescript
+// ✅ 正确 - 提取到 utils/SVGTransformUtils.ts
+export class SVGTransformUtils {
+  static parseTransformString(transformStr: string): DOMMatrix {
+    // 纯工具 - 无 Cypress,无 UI 依赖
+    const matrix = new DOMMatrix();
+    // ... 实现
+    return matrix;
+  }
+}
+
+// ❌ 错误 - 不要将工具放在 UI 辅助函数中
+class TimeSeriesHelper {
+  // 这应该在工具中
+  private parseTransformString(transformStr: string): DOMMatrix { ... }
+}
+```
+
+3. **命名约定:**
+   - 使用与 UI 组件匹配的描述性名称
+   - 可参数化的辅助函数前缀使用 `use`(例如 `useChoices`)
+   - 使用一致的方法命名:
+     - `get propertyName()` 用于元素获取器
+     - `performAction()` 用于操作
+     - `assertState()` 用于断言
+     - `hasState()` 用于布尔检查
+
+4. **辅助函数内容指南:**
+   - **应该包含**:可复用的 UI 交互、元素获取器、简单状态验证
+   - **不应该包含**:复杂的多步骤场景、完整的测试工作流、一次性业务逻辑
+
+5. **必需元素:**
+   - 始终包含 `get root()` 作为主元素获取器
+   - 使用语义化子元素获取器(例如 `get submitButton()`)
+   - 使用 `cy.log()` 包含描述性日志
+
+6. **选择器最佳实践:**
+   - 尽可能使用 ARIA 属性而非其他方法
+
+7. **方法类型:**
+   - **获取器**:返回 Cypress 元素以供进一步链接
+   - **操作**:执行用户交互(点击、输入等)
+   - **断言**:使用 `.should()` 验证预期状态
+   - **简单工具**:特定 UI 操作的辅助方法(不是通用工具)
+
+**完整辅助函数示例:**
+
+```typescript
+import TriggerOptions = Cypress.TriggerOptions;
+
+class NewComponentHelper {
+  private get _baseRootSelector() {
+    return ".lsf-new-component";
+  }
+  
+  private _rootSelector: string;
+  
+  constructor(rootSelector: string) {
+    this._rootSelector = rootSelector.replace(/^\&/, this._baseRootSelector);
+  }
+  
+  get root() {
+    return cy.get(this._rootSelector);
+  }
+  
+  get input() {
+    return this.root.find('input[type="text"]');
+  }
+  
+  get submitButton() {
+    return this.root.find('[aria-label="submit"]');
+  }
+  
+  get items() {
+    return this.root.find('.item');
+  }
+  
+  fillInput(text: string) {
+    cy.log(`填充输入:${text}`);
+    this.input.clear().type(text);
+  }
+  
+  selectItem(index: number) {
+    cy.log(`选择索引为 ${index} 的项目`);
+    this.items.eq(index).click();
+  }
+  
+  findItem(text: string) {
+    return this.items.contains(text);
+  }
+  
+  submit() {
+    cy.log("提交表单");
+    this.submitButton.click();
+  }
+  
+  hasItem(text: string) {
+    this.findItem(text).should("be.visible");
+  }
+  
+  hasNoItems() {
+    this.items.should("not.exist");
+  }
+  
+  hasSelectedItem(text: string) {
+    this.findItem(text).should("have.class", "selected");
+  }
+}
+
+// 导出模式
+const NewComponent = new NewComponentHelper("&:eq(0)");
+const useNewComponent = (rootSelector: string) => {
+  return new NewComponentHelper(rootSelector);
+};
+
+export { NewComponent, useNewComponent };
+```
+
+**将辅助函数添加到索引:**
+
+创建辅助函数后,将其添加到索引文件:
+
+```typescript
+// 在 web/libs/frontend-test/src/helpers/LSF/index.ts 中
+export { NewComponent, useNewComponent } from "./NewComponent";
+```
+
+### 最佳实践总结
+
+1. **始终使用语义化文件夹组织**
+2. **从集中位置导入辅助函数**
+3. **使用流式 API 进行 LabelStudio 初始化**
+4. **包含适当的等待机制**
+5. **使用相对坐标进行响应式测试**
+6. **添加描述性日志**
+7. **单独构建测试数据**
+8. **包含正面和负面测试用例**
+9. **在可用时使用辅助方法而非原始 Cypress 命令**
+10. **避免直接使用 Cypress 键盘命令 - 使用 `Hotkeys` 辅助函数实现跨平台兼容性**
+11. **遵循现有命名约定**
+12. **为常见 UI 模式创建可复用的辅助函数**
+13. **对可参数化的组件使用基于类的模式**
+14. **在辅助函数中包含全面的错误处理**
+15. **使用 JSDoc 注释记录复杂的辅助方法**
+16. **在生产测试中使用辅助函数之前先测试它们**
+17. **当工具函数不使用 Cypress 时,将其提取到单独的 utils 文件**
+18. **将复杂的测试场景保留在测试文件中,而不是辅助函数中 - 辅助函数应该简单且可复用**
+19. **避免使用 cy.wait(time) - 使用基于事件或基于状态的等待以获得更可靠的测试**

+ 128 - 0
.cursor/rules/react-规范.mdc

@@ -0,0 +1,128 @@
+---
+description: LabelStudio 客户端应用的 React 编码标准和最佳实践
+globs: **/*.jsx,**/*.tsx
+---
+
+# React 最佳实践
+
+## 项目结构
+- 所有前端代码位于 `web` 目录
+- 主应用代码位于 `web/apps/labelstudio`
+- 共享库位于 `web/libs`
+- 遵循既定的目录结构:
+  - `components/`: 可复用的 UI 组件
+  - `pages/`: 顶层页面组件
+  - `utils/`: 工具函数
+  - `hooks/`: 自定义 React Hooks
+  - `atoms/`: Jotai 原子状态定义
+  - `providers/`: Context 提供者
+  - `services/`: API 和其他服务
+  - `types/`: TypeScript 类型定义
+  - `assets/`: 静态资源
+
+## 组件结构
+- 使用函数组件而非类组件
+- 保持组件小而专注
+- 将可复用逻辑提取到自定义 Hooks
+- 使用组合而非继承
+- 使用 TypeScript 实现适当的 prop 类型
+- 将大组件拆分为更小、更专注的组件
+- 遵循一致的文件组织模式:
+  ```
+  component-name/
+    component-name.tsx
+    component-name.module.scss
+    component-name.test.tsx
+    index.ts
+  ```
+
+## Hooks
+- 遵循 Hooks 规则
+- 使用自定义 Hooks 实现可复用逻辑
+- 保持 Hooks 专注和简单
+- 除非绝对必要,否则避免使用 useEffect
+- 在 useEffect 中使用适当的依赖数组
+- 在 useEffect 中实现清理逻辑
+- 避免嵌套 Hooks
+
+## 状态管理
+- 使用 useState 管理本地组件状态
+- 使用 Jotai 原子而非 Context API 管理共享状态
+- 使用 atomWithReducer 处理复杂状态逻辑
+- 使用 atomWithQuery 处理任何 API 数据请求
+- 将状态保持在尽可能接近使用位置的地方
+- 通过适当的状态管理避免 prop drilling
+- 仅使用 Jotai 作为全局状态管理的单一数据源
+
+## 性能
+- 实现适当的记忆化(useMemo, useCallback)
+- 对昂贵的组件使用 React.memo
+- 避免不必要的重渲染
+- 实现适当的懒加载
+- 在列表中使用适当的 key props
+- 分析和优化渲染性能
+
+## 工具
+- 使用 Biome 进行代码检查和格式化
+- 遵循 .stylelintrc.json 中定义的 CSS/SCSS 检查规则
+- 使用 TypeScript 确保类型安全
+- 通过监控导入来控制打包大小
+
+## 表单
+- 对表单输入使用受控组件
+- 实现适当的表单验证
+- 正确处理表单提交状态
+- 显示适当的加载和错误状态
+- 对复杂表单使用表单库
+- 为表单实现适当的无障碍功能
+
+## 错误处理
+- 实现错误边界
+- 正确处理异步错误
+- 显示用户友好的错误消息
+- 实现适当的后备 UI
+- 适当记录错误
+- 优雅处理边缘情况
+
+## 测试
+- 为组件编写单元测试
+- 为复杂流程实现集成测试
+- 使用 React Testing Library
+- 测试用户交互
+- 测试错误场景
+- 实现适当的模拟数据
+
+## 无障碍性
+- 确保组件符合 WCAG 2.1 AA 标准
+- 使用语义化 HTML 元素
+- 实现适当的 ARIA 属性
+- 确保键盘导航
+- 使用屏幕阅读器测试
+- 处理焦点管理
+- 为图片提供适当的 alt 文本
+
+## 代码组织
+- 使用适当的文件命名约定,采用 kebab-case,例如 ListItem -> `list-item.tsx`
+- 优先每个文件夹一个组件,但必要时将相关组件分组在一起,确保每个文件只有一个组件
+- 组件文件夹应包含一个 SCSS `.module.scss` 文件,文件名为组件的 kebab-case 形式,例如 ListItem -> `list-item.module.scss`
+- 实现适当的目录结构
+- UI 组件位于 `web/libs/ui`
+- 跨应用共享的应用组件(如某些页面级块)位于 `web/libs/app-common`
+- `web/apps` 中的代码只能从 `web/libs` 导入,`web/libs` 不能从 `web/apps` 导入
+- `web/libs/app-common` 中的代码只能从其他 `web/libs` 或 `web/apps` 导入。其他 `web/libs` 不能从 `web/libs/app-common` 导入
+- 将原子保存在全局 atoms 文件夹中,文件名与实体或状态意图匹配
+- 通过将 story 文件与组件文件放在一起,将所有组件及其状态添加到 Storybook,例如 `list-item.stories.tsx`
+- 使用 `@humansignal/ui` 包获取 UI 组件
+- 使用 `@humansignal/icons` 包获取图标
+- 使用 `@humansignal/core` 包获取核心工具/函数
+- 使用 `@humansignal/app-common` 包获取应用组件
+
+## 最佳实践
+- 禁止循环导入
+- 使用适当的导入/导出
+- 遵循既定的导入顺序
+- 组合组件而非扩展组件
+- 保持组件专注于单一职责
+- 用清晰的注释记录复杂逻辑
+- 遵循项目的文件夹结构和命名约定
+- 优先使用受控组件而非非受控组件

+ 81 - 0
.cursor/rules/tailwind-规范.mdc

@@ -0,0 +1,81 @@
+---
+description: LabelStudio 客户端应用的 Tailwind CSS 和 UI 组件最佳实践
+globs: **/*.css,tailwind.config.ts,tailwind.config.js,**/*.scss,**/*.tsx,**/*.jsx
+---
+
+# Tailwind CSS 最佳实践
+
+## 组件样式
+- 使用工具类而非自定义 CSS
+- 需要时使用 @apply 将相关工具类分组
+- 仅在必要时使用自定义组件样式作为 SCSS 模块,以提高组件的可读性和可维护性
+- 使用适当的响应式设计工具类
+- 使用适当的状态变体
+- 保持组件样式一致
+
+## 令牌(Tokens)
+- 所有来自 Figma 的令牌都是程序化生成的,定义在 `web/libs/ui/src/tokens/tokens.scss` 文件中
+- 可以通过使用 `Figma Variable Exporter` 插件从 Figma 导出,替换 `web/design-tokens.json` 的内容,然后使用 `cd web/ && yarn design-tokens` 命令重新生成令牌
+- 重新生成令牌时,确保运行项目根目录的 `make fmt-all` 命令,以确保所有文件都经过检查和格式化
+- 不要使用未通过 Figma Variable Exporter 插件定义并在 `web/libs/ui/src/tokens/tokens.scss` 文件中建立的令牌
+- 不要使用任何默认的 tailwind css 类,只使用通过 `web/libs/ui/src/tokens/tokens.js` 文件定义的类
+
+## 布局
+- 使用语义化间距工具类,例如 `p-tight` 而非 `p-200` 或 `--spacing-tight` 而非 `--spacing-200`
+- 有效使用 Flexbox 和 Grid 工具类
+- 需要时使用容器查询
+- 实现适当的响应式断点
+- 使用适当的 padding 和 margin 工具类
+- 实现适当的对齐工具类
+
+## 排版
+- 使用语义化排版工具类,例如 `text-body-medium` 而非 `text-16` 或 `--font-size-body-medium` 而非 `--font-size-16`
+- 使用适当的字体大小工具类
+- 实现适当的行高
+- 使用适当的字重工具类
+- 正确配置自定义字体
+- 使用适当的文本对齐
+- 实现适当的文本装饰
+
+## 颜色
+- 仅使用语义化颜色命名,确保深色模式兼容性,例如 `bg-primary-background` 而非 `bg-grape-000` 或 `--color-primary-background` 而非 `--color-grape-000`
+- 实现适当的颜色对比度
+- 有效使用不透明度工具类
+- 正确配置自定义颜色
+- 使用适当的渐变工具类
+- 实现适当的悬停状态
+
+## 组件
+- 从 `web/libs/ui/src/shad` 使用可用的 shadcn/ui 组件
+- 仅使用从 `web/libs/ui` 重新导出的 shadcn/ui 组件,而非 `web/libs/ui/src/shad` 中定义的原始组件
+- 在其他 libs 和 apps 中导入这些组件时应使用 `@humansignal/ui`
+- 正确组合组件
+- 保持组件变体一致
+- 实现适当的动画
+- 使用适当的过渡工具类
+- 牢记无障碍性
+- UI 组件应在 `web/libs/ui` 中定义和导出,并遵循代码组织的最佳实践
+
+## 响应式设计
+- 使用移动优先方法
+- 实现适当的断点
+- 有效使用容器查询
+- 正确处理不同屏幕尺寸
+- 实现适当的响应式排版
+- 使用适当的响应式间距
+
+## 性能
+- 使用适当的清除配置
+- 最小化自定义 CSS
+- 使用适当的缓存策略
+- 实现适当的代码分割
+- 为生产环境优化
+- 监控打包大小
+
+## 最佳实践
+- 遵循命名约定
+- 保持样式有序
+- 使用适当的文档
+- 实现适当的测试
+- 遵循无障碍性指南
+- 使用适当的版本控制

+ 57 - 0
.cursor/rules/typescript-规范.mdc

@@ -0,0 +1,57 @@
+---
+description: LabelStudio 客户端应用的 TypeScript 编码标准和最佳实践
+globs: **/*.ts,**/*.tsx,**/*.d.ts
+---
+
+# TypeScript 最佳实践
+
+## 类型系统
+- 对对象定义优先使用 interface 而非 type
+- 对联合类型、交叉类型和映射类型使用 type
+- 避免使用 `any`,对未知类型优先使用 `unknown`
+- 使用严格的 TypeScript 配置
+- 利用 TypeScript 的内置工具类型
+- 对可复用的类型模式使用泛型
+
+## 命名约定
+- 对类型名称和接口使用 PascalCase
+- 对变量和函数使用 camelCase
+- 对常量使用 UPPER_CASE
+- 使用带辅助动词的描述性名称(例如 isLoading, hasError)
+- React props 的接口前缀使用 'Props'(例如 ButtonProps)
+
+## 代码组织
+- 将类型定义保持在使用位置附近
+- 共享时从专用类型文件导出类型和接口
+- 使用桶导出(index.ts)组织导出
+- 将共享类型放在 `types` 目录中
+- 将组件 props 与其组件放在一起
+
+## 函数
+- 对公共函数使用显式返回类型
+- 对回调和方法使用箭头函数
+- 使用自定义错误类型实现适当的错误处理
+- 对复杂类型场景使用函数重载
+- 优先使用 async/await 而非 Promises
+
+## 最佳实践
+- 在 tsconfig.json 中启用严格模式
+- 对不可变属性使用 readonly
+- 利用可辨识联合类型确保类型安全
+- 使用类型守卫进行运行时类型检查
+- 实现适当的 null 检查
+- 除非必要,否则避免类型断言
+
+## 错误处理
+- 为特定领域的错误创建自定义错误类型
+- 对可能失败的操作使用 Result 类型
+- 实现适当的错误边界
+- 使用带类型的 catch 子句的 try-catch 块
+- 正确处理 Promise 拒绝
+
+## 模式
+- 对复杂对象创建使用建造者模式
+- 对数据访问实现仓储模式
+- 对对象创建使用工厂模式
+- 利用依赖注入
+- 使用模块模式进行封装

+ 127 - 0
.gitignore

@@ -0,0 +1,127 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# compiled output
+tmp
+/out-tsc
+
+# dependencies
+node_modules
+dist
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+.DS_Store
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# misc
+/.sass-cache
+/connect.lock
+/coverage
+/libpeerconnection.log
+npm-debug.log
+yarn-error.log
+testem.log
+/typings
+.nx/
+migrations.json
+
+# System Files
+.DS_Store
+Thumbs.db
+
+report.*.json
+.env*
+!.env.example
+!.env.build
+!.env.local
+!*.lock
+
+libs/version
+libs/**/package-lock.json
+libs/**/yarn.lock
+
+# editor ignored files
+
+libs/editor/__pycache__/
+
+# Logs
+libs/editor/logs
+libs/editor/*.log
+libs/editor/npm-debug.log*
+libs/editor/yarn-debug.log*
+libs/editor/yarn-error.log*
+libs/editor/report.*.json
+
+# Backend
+libs/editor/backend/env3/
+libs/editor/backend/__pycache__
+
+# MAC
+libs/editor/.DS_Store
+
+# Env
+libs/editor/.env.local
+libs/editor/.env.development.local
+libs/editor/.env.test.local
+libs/editor/.env.production.local
+
+# Testing
+libs/editor/coverage/
+libs/editor/tests/e2e/output/
+
+# codecept screenshots
+libs/editor/e2e/output
+
+
+# datamanager ignored files
+
+# Logs
+libs/datamanager/logs
+libs/datamanager/*.log
+libs/datamanager/npm-debug.log*
+libs/datamanager/yarn-debug.log*
+libs/datamanager/yarn-error.log*
+
+# Backend
+libs/datamanager/backend/env3/
+libs/datamanager/backend/__pycache__
+
+# MAC
+libs/datamanager/.DS_Store
+
+# Env
+libs/datamanager/.env
+libs/datamanager/.env.local
+libs/datamanager/.env.development.local
+libs/datamanager/.env.test.local
+libs/datamanager/.env.production.local
+
+libs/datamanager/report.*.json
+
+# Testing
+libs/datamanager/coverage/
+libs/datamanager/public/static/
+
+# Examples of local env
+libs/datamanager/src/examples
+
+# codecept screenshots
+libs/datamanager/e2e/output
+
+libs/datamanager/.cache
+
+dist
+.cursor/rules/nx-rules.mdc
+.github/instructions/nx.instructions.md

+ 805 - 0
.kiro/specs/annotation-platform/design.md

@@ -0,0 +1,805 @@
+# Design Document: Annotation Platform
+
+## Overview
+
+标注平台是一个完整的数据标注管理系统,支持从项目创建、任务分配到人员标注的完整工作流程。系统采用前后端分离架构:
+
+- **前端**: React + TypeScript + Nx 单体仓库,使用 Jotai 状态管理,集成 @humansignal/editor 标注编辑器
+- **后端**: Python FastAPI + SQLite,提供 RESTful API
+- **设计理念**: 组件化、模块化、可复用,视图组件独立以便其他平台集成
+
+## Architecture
+
+### System Architecture
+
+```mermaid
+graph TB
+    subgraph "Frontend (web/apps/lq_label)"
+        A[React App] --> B[Layout Component]
+        B --> C[Project View]
+        B --> D[Task View]
+        B --> E[Annotation View]
+        A --> F[Jotai State Management]
+        E --> G[@humansignal/editor]
+        A --> H[@humansignal/ui Components]
+    end
+    
+    subgraph "Backend (backend/)"
+        I[FastAPI Server] --> J[Project API]
+        I --> K[Task API]
+        I --> L[Annotation API]
+        J --> M[SQLite Database]
+        K --> M
+        L --> M
+    end
+    
+    A -->|HTTP/REST| I
+```
+
+### Frontend Architecture
+
+```mermaid
+graph TB
+    subgraph "App Structure"
+        A[main.tsx] --> B[App.tsx]
+        B --> C[Layout]
+        C --> D[Sidebar Navigation]
+        C --> E[Main Content Area]
+        E --> F[Router]
+        F --> G[ProjectListView]
+        F --> H[ProjectDetailView]
+        F --> I[TaskListView]
+        F --> J[AnnotationView]
+    end
+    
+    subgraph "State Management"
+        K[projectsAtom] --> G
+        K --> H
+        L[tasksAtom] --> I
+        M[currentAnnotationAtom] --> J
+    end
+    
+    subgraph "Shared Components"
+        N[@humansignal/ui]
+        O[Custom Components]
+    end
+```
+
+### Backend Architecture
+
+```mermaid
+graph TB
+    subgraph "API Layer"
+        A[main.py] --> B[Project Router]
+        A --> C[Task Router]
+        A --> D[Annotation Router]
+    end
+    
+    subgraph "Data Layer"
+        E[Database Models]
+        F[SQLite Connection]
+        E --> F
+    end
+    
+    subgraph "Business Logic"
+        G[Project Service]
+        H[Task Service]
+        I[Annotation Service]
+    end
+    
+    B --> G
+    C --> H
+    D --> I
+    G --> E
+    H --> E
+    I --> E
+```
+
+## Components and Interfaces
+
+### Frontend Components
+
+#### 1. Layout Component
+**Location**: `web/apps/lq_label/src/components/Layout/Layout.tsx`
+
+**Responsibility**: 提供后台管理平台样式的主布局
+
+**Interface**:
+```typescript
+interface LayoutProps {
+  children: React.ReactNode;
+}
+```
+
+**Structure**:
+- Sidebar navigation (fixed left)
+- Top header bar
+- Main content area (scrollable)
+- Responsive design
+
+#### 2. Sidebar Component
+**Location**: `web/apps/lq_label/src/components/Layout/Sidebar.tsx`
+
+**Responsibility**: 导航菜单
+
+**Interface**:
+```typescript
+interface SidebarProps {
+  activeRoute: string;
+}
+
+interface MenuItem {
+  id: string;
+  label: string;
+  icon: React.ReactNode;
+  path: string;
+}
+```
+
+**Menu Items**:
+- Projects (项目管理)
+- Tasks (任务管理)
+- Annotations (我的标注)
+
+#### 3. ProjectListView
+**Location**: `web/apps/lq_label/src/views/ProjectListView/ProjectListView.tsx`
+
+**Responsibility**: 显示项目列表,支持创建、编辑、删除项目
+
+**Interface**:
+```typescript
+interface ProjectListViewProps {}
+
+interface Project {
+  id: string;
+  name: string;
+  description: string;
+  config: string;
+  created_at: string;
+  task_count: number;
+}
+```
+
+**Features**:
+- 项目列表展示 (使用 DataTable 组件)
+- 创建项目按钮
+- 项目搜索和筛选
+- 项目操作 (查看详情、编辑、删除)
+
+#### 4. ProjectDetailView
+**Location**: `web/apps/lq_label/src/views/ProjectDetailView/ProjectDetailView.tsx`
+
+**Responsibility**: 显示项目详情和关联任务列表
+
+**Interface**:
+```typescript
+interface ProjectDetailViewProps {
+  projectId: string;
+}
+```
+
+**Features**:
+- 项目基本信息展示
+- 项目编辑功能
+- 关联任务列表
+- 创建任务按钮
+
+#### 5. TaskListView
+**Location**: `web/apps/lq_label/src/views/TaskListView/TaskListView.tsx`
+
+**Responsibility**: 显示任务列表,支持筛选和操作
+
+**Interface**:
+```typescript
+interface TaskListViewProps {}
+
+interface Task {
+  id: string;
+  project_id: string;
+  name: string;
+  data: any;
+  status: 'pending' | 'in_progress' | 'completed';
+  assigned_to: string | null;
+  created_at: string;
+  progress: number;
+}
+```
+
+**Features**:
+- 任务列表展示
+- 状态筛选
+- 任务操作 (开始标注、查看详情、删除)
+
+#### 6. AnnotationView
+**Location**: `web/apps/lq_label/src/views/AnnotationView/AnnotationView.tsx`
+
+**Responsibility**: 标注界面,集成 LabelStudio 编辑器
+
+**Interface**:
+```typescript
+interface AnnotationViewProps {
+  taskId: string;
+}
+
+interface AnnotationData {
+  id: string;
+  task_id: string;
+  user_id: string;
+  result: any;
+  created_at: string;
+  updated_at: string;
+}
+```
+
+**Features**:
+- LabelStudio 编辑器集成
+- 标注保存和提交
+- 跳过功能
+- 进度显示
+
+#### 7. ProjectForm Component
+**Location**: `web/apps/lq_label/src/components/ProjectForm/ProjectForm.tsx`
+
+**Responsibility**: 项目创建和编辑表单
+
+**Interface**:
+```typescript
+interface ProjectFormProps {
+  project?: Project;
+  onSubmit: (data: ProjectFormData) => void;
+  onCancel: () => void;
+}
+
+interface ProjectFormData {
+  name: string;
+  description: string;
+  config: string;
+}
+```
+
+#### 8. TaskForm Component
+**Location**: `web/apps/lq_label/src/components/TaskForm/TaskForm.tsx`
+
+**Responsibility**: 任务创建表单
+
+**Interface**:
+```typescript
+interface TaskFormProps {
+  projectId: string;
+  onSubmit: (data: TaskFormData) => void;
+  onCancel: () => void;
+}
+
+interface TaskFormData {
+  name: string;
+  data: any;
+  assigned_to: string | null;
+}
+```
+
+### Backend API Endpoints
+
+#### Project API
+**Router**: `backend/routers/project.py`
+
+**Endpoints**:
+```python
+GET    /api/projects              # List all projects
+POST   /api/projects              # Create project
+GET    /api/projects/{id}         # Get project by ID
+PUT    /api/projects/{id}         # Update project
+DELETE /api/projects/{id}         # Delete project
+```
+
+**Models**:
+```python
+class ProjectCreate(BaseModel):
+    name: str
+    description: str
+    config: str
+
+class ProjectUpdate(BaseModel):
+    name: Optional[str]
+    description: Optional[str]
+    config: Optional[str]
+
+class ProjectResponse(BaseModel):
+    id: str
+    name: str
+    description: str
+    config: str
+    created_at: datetime
+    task_count: int
+```
+
+#### Task API
+**Router**: `backend/routers/task.py`
+
+**Endpoints**:
+```python
+GET    /api/tasks                 # List all tasks (with filters)
+POST   /api/tasks                 # Create task
+GET    /api/tasks/{id}            # Get task by ID
+PUT    /api/tasks/{id}            # Update task
+DELETE /api/tasks/{id}            # Delete task
+GET    /api/projects/{id}/tasks   # Get tasks by project
+```
+
+**Models**:
+```python
+class TaskCreate(BaseModel):
+    project_id: str
+    name: str
+    data: dict
+    assigned_to: Optional[str]
+
+class TaskUpdate(BaseModel):
+    name: Optional[str]
+    data: Optional[dict]
+    status: Optional[str]
+    assigned_to: Optional[str]
+
+class TaskResponse(BaseModel):
+    id: str
+    project_id: str
+    name: str
+    data: dict
+    status: str
+    assigned_to: Optional[str]
+    created_at: datetime
+    progress: float
+```
+
+#### Annotation API
+**Router**: `backend/routers/annotation.py`
+
+**Endpoints**:
+```python
+GET    /api/annotations           # List annotations (with filters)
+POST   /api/annotations           # Create annotation
+GET    /api/annotations/{id}      # Get annotation by ID
+PUT    /api/annotations/{id}      # Update annotation
+GET    /api/tasks/{id}/annotations # Get annotations by task
+```
+
+**Models**:
+```python
+class AnnotationCreate(BaseModel):
+    task_id: str
+    user_id: str
+    result: dict
+
+class AnnotationUpdate(BaseModel):
+    result: dict
+
+class AnnotationResponse(BaseModel):
+    id: str
+    task_id: str
+    user_id: str
+    result: dict
+    created_at: datetime
+    updated_at: datetime
+```
+
+## Data Models
+
+### Database Schema
+
+```sql
+-- Projects table
+CREATE TABLE projects (
+    id TEXT PRIMARY KEY,
+    name TEXT NOT NULL,
+    description TEXT,
+    config TEXT NOT NULL,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+);
+
+-- Tasks table
+CREATE TABLE tasks (
+    id TEXT PRIMARY KEY,
+    project_id TEXT NOT NULL,
+    name TEXT NOT NULL,
+    data TEXT NOT NULL,  -- JSON string
+    status TEXT DEFAULT 'pending',
+    assigned_to TEXT,
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
+);
+
+-- Annotations table
+CREATE TABLE annotations (
+    id TEXT PRIMARY KEY,
+    task_id TEXT NOT NULL,
+    user_id TEXT NOT NULL,
+    result TEXT NOT NULL,  -- JSON string
+    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+    FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
+);
+```
+
+### State Management (Jotai Atoms)
+
+**Location**: `web/apps/lq_label/src/atoms/`
+
+```typescript
+// projectAtoms.ts
+export const projectsAtom = atom<Project[]>([]);
+export const currentProjectAtom = atom<Project | null>(null);
+export const projectLoadingAtom = atom<boolean>(false);
+export const projectErrorAtom = atom<string | null>(null);
+
+// taskAtoms.ts
+export const tasksAtom = atom<Task[]>([]);
+export const currentTaskAtom = atom<Task | null>(null);
+export const taskLoadingAtom = atom<boolean>(false);
+export const taskErrorAtom = atom<string | null>(null);
+export const taskFilterAtom = atom<TaskFilter>({
+  status: null,
+  projectId: null,
+});
+
+// annotationAtoms.ts
+export const currentAnnotationAtom = atom<AnnotationData | null>(null);
+export const annotationLoadingAtom = atom<boolean>(false);
+export const annotationErrorAtom = atom<string | null>(null);
+export const lsfInstanceAtom = atom<any>(null);
+```
+
+## Correctness Properties
+
+*A property is a characteristic or behavior that should hold true across all valid executions of a system—essentially, a formal statement about what the system should do. Properties serve as the bridge between human-readable specifications and machine-verifiable correctness guarantees.*
+
+### Property 1: Project creation adds to list
+*For any* valid project data (non-empty name and config), creating a project should result in the project appearing in the projects list with a unique ID.
+
+**Validates: Requirements 1.3**
+
+### Property 2: Empty project name rejection
+*For any* project creation attempt with an empty or whitespace-only name, the frontend should prevent submission and display a validation error.
+
+**Validates: Requirements 1.4**
+
+### Property 3: Project deletion cascades
+*For any* project with associated tasks, deleting the project should also delete all associated tasks and their annotations.
+
+**Validates: Requirements 1.7**
+
+### Property 4: Task creation associates with project
+*For any* valid task data with a valid project_id, creating a task should result in the task being associated with that project and appearing in the project's task list.
+
+**Validates: Requirements 2.2**
+
+### Property 5: Task status filtering
+*For any* task status filter value, the displayed task list should only contain tasks matching that status.
+
+**Validates: Requirements 2.4**
+
+### Property 6: Task completion updates status
+*For any* task where all data items have been annotated, the task status should automatically update to 'completed'.
+
+**Validates: Requirements 2.7**
+
+### Property 7: User task assignment filtering
+*For any* user, the task list view should only display tasks assigned to that user.
+
+**Validates: Requirements 3.1**
+
+### Property 8: Annotation saves update progress
+*For any* annotation save operation, the associated task's progress should be updated to reflect the number of completed annotations.
+
+**Validates: Requirements 3.3**
+
+### Property 9: Empty annotation rejection
+*For any* annotation submission attempt with empty or null result data, the frontend should prevent submission and display an error message.
+
+**Validates: Requirements 3.4**
+
+### Property 10: LabelStudio config initialization
+*For any* annotation view load, the LabelStudio editor should be initialized with the project's annotation config.
+
+**Validates: Requirements 3.7**
+
+### Property 11: API error responses
+*For any* invalid API request (missing required fields, invalid IDs, etc.), the backend should return a 4xx status code with a descriptive error message.
+
+**Validates: Requirements 5.6**
+
+### Property 12: Project ID validation on task creation
+*For any* task creation request, if the project_id does not exist in the database, the backend should reject the request with a 404 error.
+
+**Validates: Requirements 6.4**
+
+### Property 13: Task ID validation on annotation creation
+*For any* annotation creation request, if the task_id does not exist in the database, the backend should reject the request with a 404 error.
+
+**Validates: Requirements 6.5**
+
+### Property 14: JSON serialization round-trip
+*For any* valid annotation result object, serializing to JSON and deserializing should produce an equivalent object.
+
+**Validates: Requirements 6.7**
+
+### Property 15: Navigation menu highlighting
+*For any* active route, the corresponding menu item in the sidebar should be visually highlighted.
+
+**Validates: Requirements 7.3**
+
+### Property 16: Editor cleanup on unmount
+*For any* LabelStudio editor instance, when the annotation view unmounts, all editor resources should be properly cleaned up (event listeners, DOM references, MST subscriptions).
+
+**Validates: Requirements 8.8**
+
+## Error Handling
+
+### Frontend Error Handling
+
+1. **API Request Errors**
+   - Use try-catch blocks for all API calls
+   - Display user-friendly error messages using Toast notifications
+   - Log errors to console for debugging
+   - Set error atoms for component-level error display
+
+2. **Form Validation Errors**
+   - Validate inputs before submission
+   - Display inline validation errors
+   - Prevent form submission until valid
+
+3. **Editor Errors**
+   - Wrap LabelStudio initialization in try-catch
+   - Display error state if editor fails to load
+   - Implement cleanup to prevent memory leaks
+
+4. **Error Boundary**
+   - Implement React Error Boundary at app level
+   - Display fallback UI for unhandled errors
+   - Log errors for monitoring
+
+### Backend Error Handling
+
+1. **Validation Errors**
+   - Use Pydantic models for request validation
+   - Return 422 status code with validation details
+   - Provide clear error messages
+
+2. **Not Found Errors**
+   - Return 404 status code for missing resources
+   - Include resource type and ID in error message
+
+3. **Database Errors**
+   - Catch SQLite exceptions
+   - Return 500 status code for database errors
+   - Log errors for debugging
+
+4. **CORS Errors**
+   - Configure CORS middleware properly
+   - Allow frontend origin in development and production
+
+## Testing Strategy
+
+### Unit Testing
+
+**Frontend**:
+- Test individual components with React Testing Library
+- Test utility functions and hooks
+- Test form validation logic
+- Test state management atoms
+- Mock API calls with MSW (Mock Service Worker)
+
+**Backend**:
+- Test API endpoints with pytest
+- Test database operations
+- Test request/response models
+- Test error handling
+
+**Example Unit Tests**:
+- ProjectForm validates empty name
+- TaskListView filters by status
+- API returns 404 for invalid project ID
+- Database cascade deletes work correctly
+
+### Property-Based Testing
+
+**Configuration**:
+- Use fast-check for frontend property tests
+- Use Hypothesis for backend property tests
+- Run minimum 100 iterations per property test
+- Tag each test with feature name and property number
+
+**Frontend Property Tests**:
+- Property 2: Empty project name rejection
+- Property 5: Task status filtering
+- Property 7: User task assignment filtering
+- Property 9: Empty annotation rejection
+- Property 15: Navigation menu highlighting
+
+**Backend Property Tests**:
+- Property 1: Project creation adds to list
+- Property 3: Project deletion cascades
+- Property 4: Task creation associates with project
+- Property 6: Task completion updates status
+- Property 8: Annotation saves update progress
+- Property 11: API error responses
+- Property 12: Project ID validation on task creation
+- Property 13: Task ID validation on annotation creation
+- Property 14: JSON serialization round-trip
+
+**Integration Property Tests**:
+- Property 10: LabelStudio config initialization
+- Property 16: Editor cleanup on unmount
+
+**Test Tag Format**:
+```typescript
+// Frontend example
+test('Feature: annotation-platform, Property 2: Empty project name rejection', () => {
+  fc.assert(
+    fc.property(fc.string(), (name) => {
+      // Test that whitespace-only names are rejected
+    })
+  );
+});
+```
+
+```python
+# Backend example
+@given(st.text())
+def test_property_12_project_id_validation(project_id):
+    """Feature: annotation-platform, Property 12: Project ID validation on task creation"""
+    # Test that invalid project IDs are rejected
+```
+
+### Integration Testing
+
+- Test complete user flows (create project → create task → annotate)
+- Test API integration with frontend
+- Test database transactions
+- Test LabelStudio editor integration
+
+### End-to-End Testing
+
+- Use Cypress for E2E tests
+- Test critical user journeys
+- Test across different browsers
+- Test responsive design
+
+## Implementation Notes
+
+### Frontend Development
+
+1. **Initialize Nx App**
+   ```bash
+   cd web
+   nx generate @nx/react:application lq_label --style=scss --bundler=webpack
+   ```
+
+2. **Project Structure**
+   ```
+   web/apps/lq_label/
+   ├── src/
+   │   ├── app/
+   │   │   ├── App.tsx
+   │   │   └── App.module.scss
+   │   ├── components/
+   │   │   ├── Layout/
+   │   │   ├── ProjectForm/
+   │   │   └── TaskForm/
+   │   ├── views/
+   │   │   ├── ProjectListView/
+   │   │   ├── ProjectDetailView/
+   │   │   ├── TaskListView/
+   │   │   └── AnnotationView/
+   │   ├── atoms/
+   │   │   ├── projectAtoms.ts
+   │   │   ├── taskAtoms.ts
+   │   │   └── annotationAtoms.ts
+   │   ├── services/
+   │   │   └── api.ts
+   │   ├── utils/
+   │   │   └── helpers.ts
+   │   ├── main.tsx
+   │   └── index.html
+   ```
+
+3. **Key Dependencies**
+   - react-router-dom (routing)
+   - jotai (state management)
+   - @humansignal/ui (UI components)
+   - @humansignal/editor (annotation editor)
+   - axios (HTTP client)
+
+4. **Styling Approach**
+   - Use Tailwind CSS utility classes
+   - Use SCSS modules for component-specific styles
+   - Follow semantic token naming from design tokens
+
+### Backend Development
+
+1. **Project Structure**
+   ```
+   backend/
+   ├── main.py
+   ├── database.py
+   ├── models.py
+   ├── routers/
+   │   ├── project.py
+   │   ├── task.py
+   │   └── annotation.py
+   ├── services/
+   │   ├── project_service.py
+   │   ├── task_service.py
+   │   └── annotation_service.py
+   ├── schemas/
+   │   ├── project.py
+   │   ├── task.py
+   │   └── annotation.py
+   └── requirements.txt
+   ```
+
+2. **Key Dependencies**
+   - fastapi
+   - uvicorn
+   - pydantic
+   - sqlite3 (built-in)
+   - python-multipart (for file uploads)
+
+3. **Database Initialization**
+   - Create database on startup
+   - Run migrations if needed
+   - Use context manager for connections
+
+4. **CORS Configuration**
+   ```python
+   from fastapi.middleware.cors import CORSMiddleware
+   
+   app.add_middleware(
+       CORSMiddleware,
+       allow_origins=["http://localhost:4200"],  # Frontend dev server
+       allow_credentials=True,
+       allow_methods=["*"],
+       allow_headers=["*"],
+   )
+   ```
+
+### LabelStudio Integration
+
+1. **Dynamic Import**
+   - Import @humansignal/editor dynamically in AnnotationView
+   - Prevents loading editor code until needed
+
+2. **Instance Lifecycle**
+   - Create instance when task loads
+   - Destroy instance on unmount or task change
+   - Clean up MST subscriptions
+
+3. **Config Loading**
+   - Fetch project config from API
+   - Pass config to LabelStudio constructor
+   - Handle config errors gracefully
+
+4. **Annotation Serialization**
+   - Use MST onSnapshot to observe changes
+   - Serialize annotation to JSON
+   - Store in Jotai atom for display/save
+
+## Deployment Considerations
+
+### Frontend Deployment
+
+- Build with `nx build lq_label --prod`
+- Output to `dist/apps/lq_label`
+- Serve as static files
+- Configure base href if not at root
+
+### Backend Deployment
+
+- Run with `uvicorn main:app --host 0.0.0.0 --port 8000`
+- Use environment variables for configuration
+- Set up proper CORS for production domain
+- Consider using gunicorn for production
+
+### Database
+
+- SQLite file location configurable via environment variable
+- Backup strategy for production
+- Consider migration to PostgreSQL for scale

+ 161 - 0
.kiro/specs/annotation-platform/requirements.md

@@ -0,0 +1,161 @@
+# Requirements Document
+
+## Introduction
+
+本文档定义了一个完整的标注平台系统的需求规范。该平台支持从项目创建、任务分配到人员标注的完整工作流程。系统采用前后端分离架构,前端使用 React + TypeScript + Nx 单体仓库,后端使用 Python FastAPI + SQLite。
+
+## Glossary
+
+- **Annotation_Platform**: 标注平台系统,提供项目管理、任务管理和标注功能的完整解决方案
+- **Project**: 标注项目,包含多个标注任务的容器
+- **Task**: 标注任务,属于某个项目,包含待标注的数据和标注配置
+- **Annotation**: 标注结果,用户对任务数据的标注输出
+- **User**: 系统用户,可以是管理员或标注人员
+- **Frontend_App**: 前端应用,位于 web/apps/lq_label 目录
+- **Backend_API**: 后端 API 服务,位于 backend 目录
+- **Layout**: 后台管理平台样式的布局组件
+- **View**: 独立的页面视图组件,可被其他平台复用
+
+## Requirements
+
+### Requirement 1: 项目管理
+
+**User Story:** 作为管理员,我想要创建和管理标注项目,以便组织不同的标注工作。
+
+#### Acceptance Criteria
+
+1. WHEN 用户访问项目列表页面 THEN THE Frontend_App SHALL 显示所有项目的列表,包括项目名称、描述、创建时间和任务数量
+2. WHEN 用户点击创建项目按钮 THEN THE Frontend_App SHALL 显示项目创建表单,包含项目名称、描述和标注配置字段
+3. WHEN 用户提交有效的项目信息 THEN THE Backend_API SHALL 创建新项目并返回项目 ID
+4. WHEN 用户提交空的项目名称 THEN THE Frontend_App SHALL 阻止提交并显示验证错误
+5. WHEN 用户点击项目详情 THEN THE Frontend_App SHALL 导航到项目详情页面,显示项目信息和关联的任务列表
+6. WHEN 用户编辑项目信息 THEN THE Backend_API SHALL 更新项目数据并返回更新后的项目
+7. WHEN 用户删除项目 THEN THE Backend_API SHALL 删除项目及其所有关联任务
+
+### Requirement 2: 任务管理
+
+**User Story:** 作为管理员,我想要在项目中创建和管理标注任务,以便分配具体的标注工作。
+
+#### Acceptance Criteria
+
+1. WHEN 用户在项目详情页点击创建任务 THEN THE Frontend_App SHALL 显示任务创建表单,包含任务名称、数据源和分配人员字段
+2. WHEN 用户提交有效的任务信息 THEN THE Backend_API SHALL 创建新任务并关联到当前项目
+3. WHEN 用户查看任务列表 THEN THE Frontend_App SHALL 显示任务名称、状态、分配人员和完成进度
+4. WHEN 用户筛选任务状态 THEN THE Frontend_App SHALL 只显示匹配状态的任务
+5. WHEN 用户编辑任务信息 THEN THE Backend_API SHALL 更新任务数据
+6. WHEN 用户删除任务 THEN THE Backend_API SHALL 删除任务及其所有标注结果
+7. WHEN 任务的所有数据都被标注 THEN THE Backend_API SHALL 自动更新任务状态为已完成
+
+### Requirement 3: 人员标注
+
+**User Story:** 作为标注人员,我想要对分配给我的任务进行标注,以便完成标注工作。
+
+#### Acceptance Criteria
+
+1. WHEN 标注人员访问任务列表 THEN THE Frontend_App SHALL 只显示分配给该用户的任务
+2. WHEN 标注人员点击任务 THEN THE Frontend_App SHALL 打开标注界面,显示待标注数据和标注工具
+3. WHEN 标注人员完成标注 THEN THE Backend_API SHALL 保存标注结果并更新任务进度
+4. WHEN 标注人员提交空的标注结果 THEN THE Frontend_App SHALL 阻止提交并提示必须完成标注
+5. WHEN 标注人员查看已标注数据 THEN THE Frontend_App SHALL 显示之前的标注结果并允许修改
+6. WHEN 标注人员跳过当前数据 THEN THE Frontend_App SHALL 保存跳过状态并显示下一条数据
+7. WHEN 标注界面加载 THEN THE Frontend_App SHALL 使用项目的标注配置初始化 LabelStudio 编辑器
+
+### Requirement 4: 前端应用架构
+
+**User Story:** 作为开发者,我想要前端应用遵循组件化和模块化设计,以便代码可维护和可复用。
+
+#### Acceptance Criteria
+
+1. THE Frontend_App SHALL 使用 Nx 工作空间结构,位于 web/apps/lq_label 目录
+2. THE Frontend_App SHALL 使用 React 函数组件和 TypeScript
+3. THE Frontend_App SHALL 使用 Jotai 进行状态管理
+4. THE Frontend_App SHALL 从 @humansignal/ui 导入 UI 组件而非自行创建
+5. THE Frontend_App SHALL 使用后台管理平台样式的 Layout 组件
+6. WHEN 定义页面视图 THEN THE Frontend_App SHALL 将每个视图作为独立组件,以便其他平台复用
+7. THE Frontend_App SHALL 遵循 .cursor/rules 中定义的 React、TypeScript 和 Tailwind 规范
+8. THE Frontend_App SHALL 使用 SCSS 模块进行组件样式定义
+
+### Requirement 5: 后端 API 架构
+
+**User Story:** 作为开发者,我想要后端 API 提供 RESTful 接口,以便前端应用调用。
+
+#### Acceptance Criteria
+
+1. THE Backend_API SHALL 使用 Python FastAPI 框架
+2. THE Backend_API SHALL 使用 SQLite 作为数据存储
+3. THE Backend_API SHALL 提供项目的 CRUD 接口
+4. THE Backend_API SHALL 提供任务的 CRUD 接口
+5. THE Backend_API SHALL 提供标注结果的创建和查询接口
+6. WHEN 接收到无效请求 THEN THE Backend_API SHALL 返回 4xx 状态码和错误信息
+7. WHEN 发生服务器错误 THEN THE Backend_API SHALL 返回 5xx 状态码和错误信息
+8. THE Backend_API SHALL 支持 CORS 以允许前端跨域请求
+
+### Requirement 6: 数据模型
+
+**User Story:** 作为开发者,我想要定义清晰的数据模型,以便前后端数据交互一致。
+
+#### Acceptance Criteria
+
+1. THE Backend_API SHALL 定义 Project 模型,包含 id、name、description、config、created_at 字段
+2. THE Backend_API SHALL 定义 Task 模型,包含 id、project_id、name、data、status、assigned_to、created_at 字段
+3. THE Backend_API SHALL 定义 Annotation 模型,包含 id、task_id、user_id、result、created_at、updated_at 字段
+4. WHEN 创建任务 THEN THE Backend_API SHALL 验证 project_id 存在
+5. WHEN 创建标注 THEN THE Backend_API SHALL 验证 task_id 存在
+6. THE Backend_API SHALL 在删除项目时级联删除关联的任务和标注
+7. THE Backend_API SHALL 使用 JSON 格式存储标注配置和标注结果
+
+### Requirement 7: 用户界面布局
+
+**User Story:** 作为用户,我想要使用清晰的后台管理界面,以便快速访问各个功能模块。
+
+#### Acceptance Criteria
+
+1. THE Frontend_App SHALL 使用侧边栏导航布局
+2. WHEN 用户访问应用 THEN THE Frontend_App SHALL 显示包含项目、任务、标注三个主菜单项的侧边栏
+3. WHEN 用户点击菜单项 THEN THE Frontend_App SHALL 导航到对应的视图组件
+4. THE Frontend_App SHALL 在顶部显示应用标题和用户信息
+5. THE Frontend_App SHALL 使用响应式设计,支持不同屏幕尺寸
+6. WHEN 视图组件渲染 THEN THE Frontend_App SHALL 在主内容区域显示视图内容
+7. THE Frontend_App SHALL 使用 Tailwind CSS 语义化类名进行样式定义
+
+### Requirement 8: 标注编辑器集成
+
+**User Story:** 作为标注人员,我想要使用功能完整的标注编辑器,以便高效完成标注工作。
+
+#### Acceptance Criteria
+
+1. THE Frontend_App SHALL 集成 @humansignal/editor 作为标注编辑器
+2. WHEN 打开标注界面 THEN THE Frontend_App SHALL 使用项目的标注配置初始化编辑器
+3. WHEN 编辑器初始化完成 THEN THE Frontend_App SHALL 加载任务数据到编辑器
+4. WHEN 用户完成标注 THEN THE Frontend_App SHALL 从编辑器获取标注结果
+5. THE Frontend_App SHALL 处理编辑器的加载状态和错误状态
+6. WHEN 用户保存标注 THEN THE Frontend_App SHALL 序列化标注结果为 JSON 格式
+7. THE Frontend_App SHALL 在编辑器卸载时正确清理资源
+
+### Requirement 9: 数据持久化
+
+**User Story:** 作为系统,我想要可靠地存储和检索数据,以便保证数据完整性。
+
+#### Acceptance Criteria
+
+1. THE Backend_API SHALL 在应用启动时初始化 SQLite 数据库
+2. THE Backend_API SHALL 创建必要的数据表结构
+3. WHEN 接收到创建请求 THEN THE Backend_API SHALL 验证数据并插入数据库
+4. WHEN 接收到查询请求 THEN THE Backend_API SHALL 从数据库检索数据并返回
+5. WHEN 接收到更新请求 THEN THE Backend_API SHALL 验证数据并更新数据库记录
+6. WHEN 接收到删除请求 THEN THE Backend_API SHALL 从数据库删除记录
+7. THE Backend_API SHALL 使用事务确保数据一致性
+
+### Requirement 10: 错误处理和用户反馈
+
+**User Story:** 作为用户,我想要在操作失败时看到清晰的错误信息,以便了解问题并采取行动。
+
+#### Acceptance Criteria
+
+1. WHEN API 请求失败 THEN THE Frontend_App SHALL 显示用户友好的错误消息
+2. WHEN 表单验证失败 THEN THE Frontend_App SHALL 在相应字段旁显示验证错误
+3. WHEN 操作成功 THEN THE Frontend_App SHALL 显示成功提示消息
+4. WHEN 加载数据 THEN THE Frontend_App SHALL 显示加载指示器
+5. THE Frontend_App SHALL 实现错误边界以捕获组件错误
+6. WHEN 后端返回错误 THEN THE Backend_API SHALL 包含错误代码和描述性消息
+7. THE Frontend_App SHALL 记录错误到控制台以便调试

+ 387 - 0
.kiro/specs/annotation-platform/tasks.md

@@ -0,0 +1,387 @@
+# Implementation Plan: Annotation Platform
+
+## Overview
+
+本实现计划将标注平台分解为可执行的开发任务。任务按照从基础设施到核心功能再到集成的顺序组织,确保每一步都可以独立测试和验证。
+
+## Tasks
+
+- [x] 1. 初始化前端应用和项目结构
+  - 使用 Nx 生成 React 应用: `cd web && nx generate @nx/react:application lq_label --style=scss --bundler=webpack`
+  - 创建目录结构: components/, views/, atoms/, services/, utils/
+  - 安装依赖: react-router-dom, jotai, axios
+  - 配置 TypeScript 和 ESLint
+  - 创建基础的 App.tsx 和 main.tsx
+  - _Requirements: 4.1, 4.2, 4.8_
+
+- [x] 2. 初始化后端应用和数据库
+  - 创建 backend/ 目录结构
+  - 创建 requirements.txt 并安装依赖 (fastapi, uvicorn, pydantic)
+  - 创建 main.py 和 FastAPI 应用实例
+  - 配置 CORS 中间件
+  - 创建 database.py 和 SQLite 连接管理
+  - 创建数据库表结构 (projects, tasks, annotations)
+  - 实现数据库初始化逻辑
+  - _Requirements: 5.1, 5.2, 5.8, 6.1, 6.2, 6.3, 9.1, 9.2_
+
+- [ ]* 2.1 编写后端数据库初始化的单元测试
+  - 在 backend/test/ 目录创建测试文件
+  - 测试数据库表创建
+  - 测试连接管理
+  - _Requirements: 9.1, 9.2_
+
+- [ ] 3. 实现后端 Project API
+  - [ ] 3.1 创建 Project 数据模型和 Pydantic schemas
+    - 定义 ProjectCreate, ProjectUpdate, ProjectResponse schemas
+    - 创建数据库模型
+    - _Requirements: 6.1_
+
+  - [ ] 3.2 实现 Project CRUD 端点
+    - GET /api/projects (列表)
+    - POST /api/projects (创建)
+    - GET /api/projects/{id} (详情)
+    - PUT /api/projects/{id} (更新)
+    - DELETE /api/projects/{id} (删除,级联删除任务)
+    - _Requirements: 5.3, 1.3, 1.6, 1.7_
+
+  - [ ]* 3.3 编写 Property 1 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 1: Project creation adds to list**
+    - **Validates: Requirements 1.3**
+    - 使用 Hypothesis 生成随机项目数据
+    - 验证创建后项目出现在列表中且有唯一 ID
+    - _Requirements: 1.3_
+
+  - [ ]* 3.4 编写 Property 3 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 3: Project deletion cascades**
+    - **Validates: Requirements 1.7**
+    - 创建项目和关联任务,删除项目后验证任务也被删除
+    - _Requirements: 1.7_
+
+  - [ ]* 3.5 编写 Property 12 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 12: Project ID validation on task creation**
+    - **Validates: Requirements 6.4**
+    - 使用无效的 project_id 创建任务,验证返回 404
+    - _Requirements: 6.4_
+
+- [ ] 4. 实现后端 Task API
+  - [ ] 4.1 创建 Task 数据模型和 Pydantic schemas
+    - 定义 TaskCreate, TaskUpdate, TaskResponse schemas
+    - 创建数据库模型
+    - _Requirements: 6.2_
+
+  - [ ] 4.2 实现 Task CRUD 端点
+    - GET /api/tasks (列表,支持筛选)
+    - POST /api/tasks (创建)
+    - GET /api/tasks/{id} (详情)
+    - PUT /api/tasks/{id} (更新)
+    - DELETE /api/tasks/{id} (删除)
+    - GET /api/projects/{id}/tasks (按项目查询)
+    - _Requirements: 5.4, 2.2, 2.5, 2.6_
+
+  - [ ]* 4.3 编写 Property 4 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 4: Task creation associates with project**
+    - **Validates: Requirements 2.2**
+    - 创建任务并验证它关联到正确的项目
+    - _Requirements: 2.2_
+
+  - [ ]* 4.4 编写 Property 6 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 6: Task completion updates status**
+    - **Validates: Requirements 2.7**
+    - 标注所有数据后验证任务状态更新为 completed
+    - _Requirements: 2.7_
+
+- [ ] 5. 实现后端 Annotation API
+  - [ ] 5.1 创建 Annotation 数据模型和 Pydantic schemas
+    - 定义 AnnotationCreate, AnnotationUpdate, AnnotationResponse schemas
+    - 创建数据库模型
+    - _Requirements: 6.3_
+
+  - [ ] 5.2 实现 Annotation CRUD 端点
+    - GET /api/annotations (列表,支持筛选)
+    - POST /api/annotations (创建)
+    - GET /api/annotations/{id} (详情)
+    - PUT /api/annotations/{id} (更新)
+    - GET /api/tasks/{id}/annotations (按任务查询)
+    - _Requirements: 5.5, 3.3, 3.5_
+
+  - [ ]* 5.3 编写 Property 8 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 8: Annotation saves update progress**
+    - **Validates: Requirements 3.3**
+    - 保存标注后验证任务进度更新
+    - _Requirements: 3.3_
+
+  - [ ]* 5.4 编写 Property 13 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 13: Task ID validation on annotation creation**
+    - **Validates: Requirements 6.5**
+    - 使用无效的 task_id 创建标注,验证返回 404
+    - _Requirements: 6.5_
+
+  - [ ]* 5.5 编写 Property 14 的属性测试
+    - 在 backend/test/ 目录创建测试文件
+    - **Property 14: JSON serialization round-trip**
+    - **Validates: Requirements 6.7**
+    - 验证标注结果的 JSON 序列化和反序列化
+    - _Requirements: 6.7_
+
+- [ ]* 5.6 编写 Property 11 的属性测试
+  - 在 backend/test/ 目录创建测试文件
+  - **Property 11: API error responses**
+  - **Validates: Requirements 5.6**
+  - 测试各种无效请求返回正确的 4xx 状态码和错误消息
+  - _Requirements: 5.6_
+
+- [ ] 6. Checkpoint - 后端 API 完成
+  - 确保所有 API 端点正常工作
+  - 确保所有测试通过
+  - 询问用户是否有问题
+
+- [ ] 7. 实现前端状态管理 (Jotai Atoms)
+  - 创建 atoms/projectAtoms.ts (projectsAtom, currentProjectAtom, loading, error)
+  - 创建 atoms/taskAtoms.ts (tasksAtom, currentTaskAtom, taskFilterAtom, loading, error)
+  - 创建 atoms/annotationAtoms.ts (currentAnnotationAtom, lsfInstanceAtom, loading, error)
+  - _Requirements: 4.3_
+
+- [ ] 8. 实现前端 API 服务层
+  - 创建 services/api.ts
+  - 实现 axios 实例配置
+  - 实现 Project API 调用函数 (listProjects, createProject, getProject, updateProject, deleteProject)
+  - 实现 Task API 调用函数 (listTasks, createTask, getTask, updateTask, deleteTask)
+  - 实现 Annotation API 调用函数 (listAnnotations, createAnnotation, getAnnotation, updateAnnotation)
+  - 实现错误处理和响应拦截器
+  - _Requirements: 10.1_
+
+- [ ] 9. 实现 Layout 组件
+  - [ ] 9.1 创建 Layout 组件
+    - 使用 Tailwind CSS 创建后台管理布局
+    - 包含 Sidebar 和主内容区域
+    - 使用 @humansignal/ui 的组件
+    - _Requirements: 4.5, 7.1, 7.4, 7.7_
+
+  - [ ] 9.2 创建 Sidebar 组件
+    - 定义菜单项 (Projects, Tasks, Annotations)
+    - 实现导航功能
+    - 实现响应式设计
+    - _Requirements: 7.2, 7.5_
+
+  - [ ]* 9.3 编写 Property 15 的属性测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - **Property 15: Navigation menu highlighting**
+    - **Validates: Requirements 7.3**
+    - 验证当前路由对应的菜单项高亮显示
+    - _Requirements: 7.3_
+
+- [ ] 10. 实现 ProjectListView
+  - [ ] 10.1 创建 ProjectListView 组件
+    - 使用 @humansignal/ui 的 DataTable 显示项目列表
+    - 实现创建项目按钮
+    - 实现项目操作 (查看详情、编辑、删除)
+    - 集成 Jotai atoms 获取和更新项目数据
+    - _Requirements: 1.1, 1.2, 1.5_
+
+  - [ ]* 10.2 编写单元测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - 测试项目列表渲染
+    - 测试创建按钮点击
+    - 测试项目操作
+    - _Requirements: 1.1, 1.2_
+
+- [ ] 11. 实现 ProjectForm 组件
+  - [ ] 11.1 创建 ProjectForm 组件
+    - 使用 @humansignal/ui 的表单组件
+    - 实现表单验证 (name, description, config 必填)
+    - 实现提交和取消功能
+    - _Requirements: 1.2, 1.4_
+
+  - [ ]* 11.2 编写 Property 2 的属性测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - **Property 2: Empty project name rejection**
+    - **Validates: Requirements 1.4**
+    - 使用 fast-check 生成空白字符串,验证表单阻止提交
+    - _Requirements: 1.4_
+
+- [ ] 12. 实现 ProjectDetailView
+  - 创建 ProjectDetailView 组件
+  - 显示项目基本信息
+  - 显示关联任务列表
+  - 实现编辑项目功能
+  - 实现创建任务按钮
+  - _Requirements: 1.5, 1.6_
+
+- [ ]* 12.1 编写单元测试
+  - 在 web/apps/lq_label/test/ 目录创建测试文件
+  - 测试项目详情渲染
+  - 测试任务列表显示
+  - _Requirements: 1.5_
+
+- [ ] 13. 实现 TaskListView
+  - [ ] 13.1 创建 TaskListView 组件
+    - 使用 @humansignal/ui 的 DataTable 显示任务列表
+    - 实现状态筛选功能
+    - 实现任务操作 (开始标注、查看详情、删除)
+    - 集成 Jotai atoms 获取和更新任务数据
+    - _Requirements: 2.3, 2.4_
+
+  - [ ]* 13.2 编写 Property 5 的属性测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - **Property 5: Task status filtering**
+    - **Validates: Requirements 2.4**
+    - 验证筛选后只显示匹配状态的任务
+    - _Requirements: 2.4_
+
+  - [ ]* 13.3 编写 Property 7 的属性测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - **Property 7: User task assignment filtering**
+    - **Validates: Requirements 3.1**
+    - 验证用户只看到分配给自己的任务
+    - _Requirements: 3.1_
+
+- [ ] 14. 实现 TaskForm 组件
+  - 创建 TaskForm 组件
+  - 使用 @humansignal/ui 的表单组件
+  - 实现表单验证
+  - 实现提交和取消功能
+  - _Requirements: 2.1, 2.2_
+
+- [ ]* 14.1 编写单元测试
+  - 在 web/apps/lq_label/test/ 目录创建测试文件
+  - 测试表单验证
+  - 测试提交功能
+  - _Requirements: 2.1, 2.2_
+
+- [ ] 15. Checkpoint - 项目和任务管理完成
+  - 确保项目和任务的 CRUD 功能正常
+  - 确保所有测试通过
+  - 询问用户是否有问题
+
+- [ ] 16. 实现 AnnotationView (LabelStudio 集成)
+  - [ ] 16.1 创建 AnnotationView 组件基础结构
+    - 创建组件框架
+    - 实现加载状态和错误状态显示
+    - 添加保存和跳过按钮
+    - _Requirements: 3.2, 10.4_
+
+  - [ ] 16.2 集成 LabelStudio 编辑器
+    - 动态导入 @humansignal/editor
+    - 获取项目配置和任务数据
+    - 初始化 LabelStudio 实例
+    - 配置编辑器选项
+    - _Requirements: 8.1, 8.2, 8.3_
+
+  - [ ] 16.3 实现标注保存功能
+    - 使用 MST onSnapshot 监听标注变化
+    - 序列化标注结果为 JSON
+    - 调用 API 保存标注
+    - 更新任务进度
+    - _Requirements: 3.3, 8.4, 8.6_
+
+  - [ ] 16.4 实现编辑器清理逻辑
+    - 在组件卸载时销毁 LabelStudio 实例
+    - 清理 MST 订阅
+    - 清理 DOM 引用和事件监听器
+    - _Requirements: 8.8_
+
+  - [ ]* 16.5 编写 Property 9 的属性测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - **Property 9: Empty annotation rejection**
+    - **Validates: Requirements 3.4**
+    - 验证空标注结果被阻止提交
+    - _Requirements: 3.4_
+
+  - [ ]* 16.6 编写 Property 10 的集成测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - **Property 10: LabelStudio config initialization**
+    - **Validates: Requirements 3.7**
+    - 验证编辑器使用项目配置正确初始化
+    - _Requirements: 3.7, 8.2_
+
+  - [ ]* 16.7 编写 Property 16 的集成测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - **Property 16: Editor cleanup on unmount**
+    - **Validates: Requirements 8.8**
+    - 验证编辑器卸载时资源被正确清理
+    - _Requirements: 8.8_
+
+- [ ] 17. 实现路由配置
+  - 安装并配置 react-router-dom
+  - 定义路由: /, /projects, /projects/:id, /tasks, /tasks/:id/annotate
+  - 在 App.tsx 中集成路由
+  - 实现 404 页面
+  - _Requirements: 7.3, 7.6_
+
+- [ ] 18. 实现错误处理和用户反馈
+  - [ ] 18.1 实现 Toast 通知系统
+    - 使用 @humansignal/ui 的 Toast 组件
+    - 实现成功、错误、警告消息显示
+    - _Requirements: 10.1, 10.3_
+
+  - [ ] 18.2 实现 Error Boundary
+    - 创建 ErrorBoundary 组件
+    - 实现错误捕获和显示
+    - 实现错误日志记录
+    - _Requirements: 10.5, 10.7_
+
+  - [ ] 18.3 实现加载状态指示器
+    - 使用 @humansignal/ui 的 Spinner 组件
+    - 在数据加载时显示加载指示器
+    - _Requirements: 10.4_
+
+  - [ ]* 18.4 编写单元测试
+    - 在 web/apps/lq_label/test/ 目录创建测试文件
+    - 测试 Toast 通知显示
+    - 测试 Error Boundary 捕获错误
+    - 测试加载状态显示
+    - _Requirements: 10.1, 10.3, 10.4, 10.5_
+
+- [ ] 19. 实现表单验证
+  - 在 ProjectForm 中实现验证逻辑
+  - 在 TaskForm 中实现验证逻辑
+  - 显示内联验证错误
+  - _Requirements: 10.2_
+
+- [ ]* 19.1 编写单元测试
+  - 在 web/apps/lq_label/test/ 目录创建测试文件
+  - 测试表单验证规则
+  - 测试验证错误显示
+  - _Requirements: 10.2_
+
+- [ ] 20. 样式优化和响应式设计
+  - 使用 Tailwind CSS 优化所有组件样式
+  - 确保响应式设计在不同屏幕尺寸下正常工作
+  - 使用语义化 token 类名
+  - 遵循设计规范
+  - _Requirements: 4.7, 7.5, 7.7_
+
+- [ ] 21. 最终集成测试
+  - 测试完整的用户流程: 创建项目 → 创建任务 → 标注
+  - 测试错误场景
+  - 测试边缘情况
+  - _Requirements: All_
+
+- [ ] 22. 文档和部署准备
+  - 编写 README.md (前端和后端)
+  - 添加环境变量配置说明
+  - 添加开发和部署指南
+  - 创建 .env.example 文件
+  - _Requirements: All_
+
+- [ ] 23. Final Checkpoint - 完整功能验证
+  - 确保所有功能正常工作
+  - 确保所有测试通过
+  - 进行代码审查
+  - 询问用户是否满意
+
+## Notes
+
+- 标记 `*` 的任务为可选测试任务,可以跳过以加快 MVP 开发
+- 每个任务都引用了具体的需求编号以便追溯
+- Checkpoint 任务确保增量验证
+- 属性测试验证通用正确性属性
+- 单元测试验证具体示例和边缘情况
+- 前端和后端任务可以并行开发

+ 2 - 0
.vscode/settings.json

@@ -0,0 +1,2 @@
+{
+}

+ 9 - 0
backend/.env.example

@@ -0,0 +1,9 @@
+# Database configuration
+DATABASE_PATH=annotation_platform.db
+
+# API configuration
+API_HOST=0.0.0.0
+API_PORT=8000
+
+# CORS configuration (comma-separated origins)
+CORS_ORIGINS=http://localhost:4200,http://localhost:3000

+ 62 - 0
backend/README.md

@@ -0,0 +1,62 @@
+# Annotation Platform Backend
+
+FastAPI-based backend for the annotation platform.
+
+## Setup
+
+1. Install dependencies:
+```bash
+pip install -r requirements.txt
+```
+
+2. Configure environment variables (optional):
+```bash
+cp .env.example .env
+# Edit .env with your configuration
+```
+
+## Running the Server
+
+Development mode:
+```bash
+python main.py
+```
+
+Or with uvicorn directly:
+```bash
+uvicorn main:app --reload --host 0.0.0.0 --port 8000
+```
+
+## API Documentation
+
+Once the server is running, visit:
+- Swagger UI: http://localhost:8000/docs
+- ReDoc: http://localhost:8000/redoc
+
+## Project Structure
+
+```
+backend/
+├── main.py              # FastAPI application entry point
+├── database.py          # Database connection and initialization
+├── models.py            # Database models
+├── requirements.txt     # Python dependencies
+├── routers/            # API route handlers
+├── services/           # Business logic
+└── schemas/            # Pydantic schemas for validation
+```
+
+## Database
+
+The application uses SQLite for data storage. The database file is created automatically on first run.
+
+Default location: `annotation_platform.db`
+
+To use a custom location, set the `DATABASE_PATH` environment variable.
+
+## Testing
+
+Run tests with pytest:
+```bash
+pytest
+```

BIN
backend/__pycache__/database.cpython-311.pyc


BIN
backend/annotation_platform.db


+ 92 - 0
backend/database.py

@@ -0,0 +1,92 @@
+"""
+Database connection and initialization module.
+Manages SQLite database connection and table creation.
+"""
+import sqlite3
+import os
+from contextlib import contextmanager
+from typing import Generator
+
+# Database file path
+DB_PATH = os.getenv("DATABASE_PATH", "annotation_platform.db")
+
+
+@contextmanager
+def get_db_connection() -> Generator[sqlite3.Connection, None, None]:
+    """
+    Context manager for database connections.
+    Ensures proper connection cleanup.
+    """
+    conn = sqlite3.connect(DB_PATH)
+    conn.row_factory = sqlite3.Row  # Enable column access by name
+    try:
+        yield conn
+        conn.commit()
+    except Exception:
+        conn.rollback()
+        raise
+    finally:
+        conn.close()
+
+
+def init_database() -> None:
+    """
+    Initialize database and create tables if they don't exist.
+    Creates projects, tasks, and annotations tables with proper relationships.
+    """
+    with get_db_connection() as conn:
+        cursor = conn.cursor()
+        
+        # Enable foreign key constraints
+        cursor.execute("PRAGMA foreign_keys = ON")
+        
+        # Create projects table
+        cursor.execute("""
+            CREATE TABLE IF NOT EXISTS projects (
+                id TEXT PRIMARY KEY,
+                name TEXT NOT NULL,
+                description TEXT,
+                config TEXT NOT NULL,
+                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
+            )
+        """)
+        
+        # Create tasks table
+        cursor.execute("""
+            CREATE TABLE IF NOT EXISTS tasks (
+                id TEXT PRIMARY KEY,
+                project_id TEXT NOT NULL,
+                name TEXT NOT NULL,
+                data TEXT NOT NULL,
+                status TEXT DEFAULT 'pending',
+                assigned_to TEXT,
+                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
+            )
+        """)
+        
+        # Create annotations table
+        cursor.execute("""
+            CREATE TABLE IF NOT EXISTS annotations (
+                id TEXT PRIMARY KEY,
+                task_id TEXT NOT NULL,
+                user_id TEXT NOT NULL,
+                result TEXT NOT NULL,
+                created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
+                FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
+            )
+        """)
+        
+        conn.commit()
+
+
+def get_db() -> sqlite3.Connection:
+    """
+    Get a database connection.
+    Note: Caller is responsible for closing the connection.
+    """
+    conn = sqlite3.connect(DB_PATH)
+    conn.row_factory = sqlite3.Row
+    conn.execute("PRAGMA foreign_keys = ON")
+    return conn

+ 62 - 0
backend/main.py

@@ -0,0 +1,62 @@
+"""
+FastAPI application entry point.
+Provides RESTful API for the annotation platform.
+"""
+from fastapi import FastAPI
+from fastapi.middleware.cors import CORSMiddleware
+from contextlib import asynccontextmanager
+from database import init_database
+
+
+@asynccontextmanager
+async def lifespan(app: FastAPI):
+    """
+    Application lifespan manager.
+    Initializes database on startup.
+    """
+    # Startup: Initialize database
+    init_database()
+    yield
+    # Shutdown: cleanup if needed
+
+
+# Create FastAPI application instance
+app = FastAPI(
+    title="Annotation Platform API",
+    description="RESTful API for data annotation management",
+    version="1.0.0",
+    lifespan=lifespan
+)
+
+# Configure CORS middleware
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[
+        "http://localhost:4200",  # Frontend dev server
+        "http://localhost:3000",  # Alternative frontend port
+    ],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+
+@app.get("/")
+async def root():
+    """Root endpoint - health check"""
+    return {
+        "message": "Annotation Platform API",
+        "status": "running",
+        "version": "1.0.0"
+    }
+
+
+@app.get("/health")
+async def health_check():
+    """Health check endpoint"""
+    return {"status": "healthy"}
+
+
+if __name__ == "__main__":
+    import uvicorn
+    uvicorn.run(app, host="0.0.0.0", port=8000)

+ 102 - 0
backend/models.py

@@ -0,0 +1,102 @@
+"""
+Database models and data structures.
+Defines the core data models for projects, tasks, and annotations.
+"""
+from datetime import datetime
+from typing import Optional
+
+
+class Project:
+    """Project model representing a labeling project."""
+    
+    def __init__(
+        self,
+        id: str,
+        name: str,
+        description: str,
+        config: str,
+        created_at: datetime
+    ):
+        self.id = id
+        self.name = name
+        self.description = description
+        self.config = config
+        self.created_at = created_at
+    
+    @classmethod
+    def from_row(cls, row):
+        """Create Project instance from database row."""
+        return cls(
+            id=row["id"],
+            name=row["name"],
+            description=row["description"],
+            config=row["config"],
+            created_at=row["created_at"]
+        )
+
+
+class Task:
+    """Task model representing a labeling task."""
+    
+    def __init__(
+        self,
+        id: str,
+        project_id: str,
+        name: str,
+        data: str,
+        status: str,
+        assigned_to: Optional[str],
+        created_at: datetime
+    ):
+        self.id = id
+        self.project_id = project_id
+        self.name = name
+        self.data = data
+        self.status = status
+        self.assigned_to = assigned_to
+        self.created_at = created_at
+    
+    @classmethod
+    def from_row(cls, row):
+        """Create Task instance from database row."""
+        return cls(
+            id=row["id"],
+            project_id=row["project_id"],
+            name=row["name"],
+            data=row["data"],
+            status=row["status"],
+            assigned_to=row["assigned_to"],
+            created_at=row["created_at"]
+        )
+
+
+class Annotation:
+    """Annotation model representing a labeling result."""
+    
+    def __init__(
+        self,
+        id: str,
+        task_id: str,
+        user_id: str,
+        result: str,
+        created_at: datetime,
+        updated_at: datetime
+    ):
+        self.id = id
+        self.task_id = task_id
+        self.user_id = user_id
+        self.result = result
+        self.created_at = created_at
+        self.updated_at = updated_at
+    
+    @classmethod
+    def from_row(cls, row):
+        """Create Annotation instance from database row."""
+        return cls(
+            id=row["id"],
+            task_id=row["task_id"],
+            user_id=row["user_id"],
+            result=row["result"],
+            created_at=row["created_at"],
+            updated_at=row["updated_at"]
+        )

+ 4 - 0
backend/requirements.txt

@@ -0,0 +1,4 @@
+fastapi==0.109.0
+uvicorn[standard]==0.27.0
+pydantic==2.5.3
+python-multipart==0.0.6

+ 3 - 0
backend/routers/__init__.py

@@ -0,0 +1,3 @@
+"""
+API routers package.
+"""

+ 3 - 0
backend/schemas/__init__.py

@@ -0,0 +1,3 @@
+"""
+Pydantic schemas package.
+"""

+ 3 - 0
backend/services/__init__.py

@@ -0,0 +1,3 @@
+"""
+Business logic services package.
+"""

+ 13 - 0
web/.editorconfig

@@ -0,0 +1,13 @@
+# Editor configuration, see http://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+indent_style = space
+indent_size = 2
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.md]
+max_line_length = off
+trim_trailing_whitespace = false

+ 127 - 0
web/.gitignore

@@ -0,0 +1,127 @@
+# See http://help.github.com/ignore-files/ for more about ignoring files.
+
+# compiled output
+tmp
+/out-tsc
+
+# dependencies
+node_modules
+dist
+
+# IDEs and editors
+/.idea
+.project
+.classpath
+.c9/
+.DS_Store
+*.launch
+.settings/
+*.sublime-workspace
+
+# IDE - VSCode
+.vscode/*
+!.vscode/settings.json
+!.vscode/tasks.json
+!.vscode/launch.json
+!.vscode/extensions.json
+
+# misc
+/.sass-cache
+/connect.lock
+/coverage
+/libpeerconnection.log
+npm-debug.log
+yarn-error.log
+testem.log
+/typings
+.nx/
+migrations.json
+
+# System Files
+.DS_Store
+Thumbs.db
+
+report.*.json
+.env*
+!.env.example
+!.env.build
+!.env.local
+!*.lock
+
+libs/version
+libs/**/package-lock.json
+libs/**/yarn.lock
+
+# editor ignored files
+
+libs/editor/__pycache__/
+
+# Logs
+libs/editor/logs
+libs/editor/*.log
+libs/editor/npm-debug.log*
+libs/editor/yarn-debug.log*
+libs/editor/yarn-error.log*
+libs/editor/report.*.json
+
+# Backend
+libs/editor/backend/env3/
+libs/editor/backend/__pycache__
+
+# MAC
+libs/editor/.DS_Store
+
+# Env
+libs/editor/.env.local
+libs/editor/.env.development.local
+libs/editor/.env.test.local
+libs/editor/.env.production.local
+
+# Testing
+libs/editor/coverage/
+libs/editor/tests/e2e/output/
+
+# codecept screenshots
+libs/editor/e2e/output
+
+
+# datamanager ignored files
+
+# Logs
+libs/datamanager/logs
+libs/datamanager/*.log
+libs/datamanager/npm-debug.log*
+libs/datamanager/yarn-debug.log*
+libs/datamanager/yarn-error.log*
+
+# Backend
+libs/datamanager/backend/env3/
+libs/datamanager/backend/__pycache__
+
+# MAC
+libs/datamanager/.DS_Store
+
+# Env
+libs/datamanager/.env
+libs/datamanager/.env.local
+libs/datamanager/.env.development.local
+libs/datamanager/.env.test.local
+libs/datamanager/.env.production.local
+
+libs/datamanager/report.*.json
+
+# Testing
+libs/datamanager/coverage/
+libs/datamanager/public/static/
+
+# Examples of local env
+libs/datamanager/src/examples
+
+# codecept screenshots
+libs/datamanager/e2e/output
+
+libs/datamanager/.cache
+
+dist
+.cursor/rules/nx-rules.mdc
+.github/instructions/nx.instructions.md

+ 5 - 0
web/.prettierignore

@@ -0,0 +1,5 @@
+# Add files here to ignore them from prettier formatting
+/dist
+/coverage
+/.nx/cache
+/.nx/workspace-data

+ 3 - 0
web/.prettierrc

@@ -0,0 +1,3 @@
+{
+  "singleQuote": true
+}

+ 1 - 0
web/.stylelintignore

@@ -0,0 +1 @@
+libs/editor/src/assets/styles/antd-no-reset.css

+ 54 - 0
web/.stylelintrc.json

@@ -0,0 +1,54 @@
+{
+  "extends": ["stylelint-config-tailwindcss/scss", "stylelint-config-standard-scss"],
+  "rules": {
+    "selector-class-pattern": null,
+    "custom-property-pattern": null,
+    "no-descending-specificity": null,
+    "scss/no-global-function-names": null,
+    "scss/function-no-unknown": null,
+    "selector-pseudo-class-no-unknown": [
+      true,
+      {
+        "ignorePseudoClasses": ["global"]
+      }
+    ],
+    "scss/at-rule-no-unknown": [
+      true,
+      {
+        "ignoreAtRules": ["position-try"]
+      }
+    ],
+    "property-no-unknown": [
+      true,
+      {
+        "ignoreProperties": ["position-try-fallbacks", "anchor-name", "position-anchor", "position-visibility"]
+      }
+    ],
+    "function-no-unknown": [
+      true,
+      {
+        "ignoreFunctions": ["anchor-size", "anchor", "map-get", "unquote"]
+      }
+    ],
+    "declaration-block-no-redundant-longhand-properties": [
+      true,
+      {
+        "ignoreShorthands": ["inset"]
+      }
+    ],
+    "comment-empty-line-before": [
+      "always",
+      {
+        "except": ["first-nested"],
+        "ignore": ["stylelint-commands", "after-comment"]
+      }
+    ],
+    "declaration-empty-line-before": [
+      "always",
+      {
+        "except": ["first-nested", "after-comment", "after-declaration"],
+        "ignore": ["inside-single-line-block"]
+      }
+    ]
+  }
+}

+ 99 - 0
web/README.md

@@ -0,0 +1,99 @@
+# Label Studio
+
+Label Studio is a complex, NX-managed project divided into three main components:
+
+## [Main App (`apps/labelstudio`)][lso]
+This is the primary application that consolidates all frontend framework elements. It's the hub for integrating and managing the different libraries and functionalities of Label Studio.
+
+## [Library - Label Studio Frontend (`libs/editor`)][lsf]
+Label Studio Frontend, developed with React and mobx-state-tree, is a robust frontend library tailored for data annotation. It's designed for seamless integration into your applications, providing a rich set of features for data handling and visualization. Customization and extensibility are core aspects, allowing for tailored annotation experiences.
+
+## [Library - Datamanager (`libs/datamanager`)][dm]
+Datamanager is an advanced tool specifically for data exploration within Label Studio. Key features include:
+
+<img align="right" height="180" src="https://github.com/HumanSignal/label-studio/blob/develop/images/heartex_icon_opossum_green@2x.png?raw=true" />
+
+## Installation Instructions
+
+1 - **Dependencies Installation:**
+- Execute `yarn install --frozen-lockfile` to install all necessary dependencies.
+
+2 - **Environment Configuration (Optional for HMR):**
+- If you want to enable Hot Module Replacement (HMR), create an `.env` file in the root Label Studio directory.
+- Add the following configuration:
+  - `FRONTEND_HMR=true`: Enables Hot Module Replacement in Django.
+
+Optional configurations (defaults should work for most setups):
+  - `FRONTEND_HOSTNAME`: HMR server address (default: http://localhost:8010).
+  - `DJANGO_HOSTNAME`: Django server address (default: http://localhost:8080).
+
+If using Docker Compose with HMR:
+- Update the `env_file: .env` directive in `docker-compose.override.yml` under the app service.
+- Rerun the app or docker compose service from the project root for changes to take effect.
+
+To start the development server with HMR:
+- From the `web` directory: Run `yarn dev`
+- Or from the project root: Run `make frontend-dev`
+
+#### Custom Configuration for DataManager:
+- If you need to customize the configuration specifically for DataManager, follow these steps:
+  - Duplicate the `.env.example` file located in the DataManager directory and rename the copy to `.env`.
+  - Make your desired changes in this new `.env` file. The key configurations to consider are:
+      - `NX_API_GATEWAY`: Set this to your API root. For example, `http://localhost:8080/api/dm`.
+      - `LS_ACCESS_TOKEN`: This is the access token for Label Studio, which can be obtained from your Label Studio account page.
+- This process allows you to have a customized configuration for DataManager, separate from the default settings in the .env.local files.
+
+## Usage Instructions
+### Key Development and Build Commands
+- **Label Studio App:**
+    - `yarn ls:dev`: Build the main Label Studio app with Hot Module Reload for development.
+    - `yarn ls:watch`: Build the main Label Studio app continuously for development.
+    - `yarn ls:e2e`: Run end-to-end tests for the Label Studio app.
+    - `yarn ls:unit`: Run unit tests for the Label Studio app.
+- **Label Studio Frontend (Editor):**
+    - `yarn lsf:watch`: Continuously build the frontend editor.
+    - `yarn lsf:serve`: Run the frontend editor standalone.
+    - `yarn lsf:e2e`: Run end-to-end tests for the frontend editor.
+    - `yarn lsf:integration`: Run integration tests for the frontend editor.
+    - `yarn lsf:unit`: Run unit tests for the frontend editor.
+- **Datamanager**
+    - `yarn dm:watch`: Continuously build Datamanager.
+    - `yarn dm:unit`: Run unit tests for Datamanager.
+- **General**
+    - `yarn build`: Build all apps and libraries in the project.
+    - `yarn ui:serve`: Serve the Storybook instance for the shared UI library.
+    - `yarn test:e2e`: Run end-to-end tests for all apps and libraries.
+    - `yarn test:integration`: Run integration tests for all apps and libraries.
+    - `yarn test:unit`: Run unit tests for all apps and libraries.
+    - `yarn lint`: Run biome linter across all files with autofix.
+    - `yarn lint-scss`: Run stylelint linter across all scss files with autofix.
+
+### Git Hooks
+This project uses python `pre-commit` hooks to ensure code quality. To install the hooks, run `make configure-hooks` in the project root directory.
+This will install the hooks and run them on every pre-push to ensure pull requests will be aligned with linting for both python and javascript/typescript code.
+
+If for any reason you need to format or lint using the same `pre-commit` hooks directly, you can run `make fmt` or `make fmt-check` respectively from the project root directory.
+
+## Ecosystem
+
+| Project                          | Description |
+|----------------------------------|-|
+| [label-studio][lso]              | Server part, distributed as a pip package |
+| [label-studio-frontend][lsf]     | Frontend part, written in JavaScript and React, can be embedded into your application |
+| [label-studio-converter][lsc]    | Encode labels into the format of your favorite machine learning library |
+| [label-studio-transformers][lst] | Transformers library connected and configured for use with label studio |
+| [datamanager][dm]                | Data exploration tool for Label Studio |
+
+## License
+
+This software is licensed under the [Apache 2.0 LICENSE](../LICENSE) © [HumanSignal](https://www.humansignal.com/). 2020
+
+<img src="https://github.com/HumanSignal/label-studio/blob/develop/images/opossum_looking.png?raw=true" title="Hey everyone!" height="140" width="140" />
+
+[lsc]: https://github.com/HumanSignal/label-studio-converter
+[lst]: https://github.com/HumanSignal/label-studio-transformers
+
+[lsf]: libs/editor/README.md
+[dm]: libs/datamanager/README.md
+[lso]: apps/labelstudio/README.md
+

+ 4 - 0
web/__mocks__/@adobe/css-tools.js

@@ -0,0 +1,4 @@
+module.exports = {
+  parse: jest.fn(() => ({ stylesheet: { rules: [] } })),
+  stringify: jest.fn(() => ""),
+};

+ 0 - 0
web/apps/.gitkeep


+ 11 - 0
web/apps/labelstudio-e2e/cypress.config.ts

@@ -0,0 +1,11 @@
+import { defineConfig } from 'cypress';
+import { nxE2EPreset } from '@nx/cypress/plugins/cypress-preset';
+
+export default defineConfig({
+  e2e: {
+    ...nxE2EPreset(__dirname),
+    // Please ensure you use `cy.origin()` when navigating between domains and remove this option.
+    // See https://docs.cypress.io/app/references/migration-guide#Changes-to-cyorigin
+    injectDocumentDomain: true
+  }
+});

+ 26 - 0
web/apps/labelstudio-e2e/project.json

@@ -0,0 +1,26 @@
+{
+  "name": "labelstudio-e2e",
+  "$schema": "../../node_modules/nx/schemas/project-schema.json",
+  "sourceRoot": "apps/labelstudio-e2e/src",
+  "projectType": "application",
+  "targets": {
+    "e2e": {
+      "executor": "@nx/cypress:cypress",
+      "options": {
+        "cypressConfig": "apps/labelstudio-e2e/cypress.config.ts",
+        "baseUrl": "http://localhost:8080/",
+        "testingType": "e2e"
+      },
+      "configurations": {
+        "production": {
+          "devServerTarget": "labelstudio:serve:production"
+        },
+        "ci": {
+          "devServerTarget": "labelstudio:serve-static"
+        }
+      }
+    }
+  },
+  "tags": [],
+  "implicitDependencies": ["labelstudio"]
+}

+ 1 - 0
web/apps/labelstudio-e2e/src/e2e/app.cy.ts

@@ -0,0 +1 @@
+

+ 0 - 0
web/apps/labelstudio-e2e/src/support/commands.ts


+ 17 - 0
web/apps/labelstudio-e2e/src/support/e2e.ts

@@ -0,0 +1,17 @@
+// ***********************************************************
+// This example support/index.js is processed and
+// loaded automatically before your test files.
+//
+// This is a great place to put global configuration and
+// behavior that modifies Cypress.
+//
+// You can change the location of this file or turn off
+// automatically serving support files with the
+// 'supportFile' configuration option.
+//
+// You can read more here:
+// https://on.cypress.io/configuration
+// ***********************************************************
+
+// Import commands.js using ES2015 syntax:
+import './commands';

+ 10 - 0
web/apps/labelstudio-e2e/tsconfig.json

@@ -0,0 +1,10 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "sourceMap": false,
+    "outDir": "../../dist/out-tsc",
+    "allowJs": true,
+    "types": ["cypress", "node"]
+  },
+  "include": ["src/**/*.ts", "src/**/*.js", "cypress.config.ts"]
+}

+ 11 - 0
web/apps/labelstudio/.babelrc

@@ -0,0 +1,11 @@
+{
+  "presets": [
+    [
+      "@nx/react/babel",
+      {
+        "runtime": "automatic"
+      }
+    ]
+  ],
+  "plugins": []
+}

+ 119 - 0
web/apps/labelstudio/README.md

@@ -0,0 +1,119 @@
+# Label Studio App
+
+The Label Studio App is the cornerstone of the frontend aspect of Label Studio. This React-based application is where the magic of frontend development for Label Studio occurs. In this app, developers have the freedom and capability to create new pages and determine how libraries are utilized within the Label Studio environment. It's a purely frontend module, dedicated to crafting and refining the user interface and user experience aspects of Label Studio.
+
+### Usage Instructions
+
+_Important Note: These scripts must be executed within the web folder or its subfolders. This is crucial for the scripts to function correctly, as they are designed to work within the context of the web directory's structure and dependencies._
+
+- **`yarn ls:watch`: Build LSF continuously**
+    - Automatically builds the Label Studio on every change, providing a real-time development experience.
+- **`yarn ls:e2e`: Execute end-to-end (e2e) tests on LS**
+    - Executes comprehensive tests simulating user interactions from start to end, ensuring the frontend's overall functionality and integrity.
+- **`yarn ls:unit`: Run unit tests on LS**
+    - Runs tests on individual components to maintain high quality and reliability, crucial in collaborative development.
+
+### Creating pages
+
+Pages could be either Django templates or React components.
+
+#### Django
+
+Consider Django templates as a fallback if there's no proper React component for a page.
+
+To create a page using Django is simple and straightforward: select an app within `label_studio/` directory, add a url and create a view with a html template. React app will handle it automatically if there's no React page for a particular route.
+
+#### React
+
+**Important notice:** you still have to add url to `urls.py` under one of the Django apps so the backend won't throw a 404. It's up to you where to add it.
+
+All the pages live under `frontend/src/pages` and are self-hosted. It means every page defines it's route, title, breadcrumb item and content.
+
+Pages organized as page sets: every folder under `frontend/src/pages` is a page set that can contain one or more pages.
+
+To add a new page follow these steps:
+
+##### Choose existing page set or create a new one
+
+Let's say we're creating page set from scratch. To do that we need a directory: `frontend/src/pages/MyPageSet`
+
+##### Create a component file under a page set directory
+
+React components are simple functions, so it's enough to write:
+
+```js
+export const MyNewPage = () => <div>Page content</div>
+```
+
+This would be a legit component and a page
+
+##### Setup title and route
+
+This is done by adding properties to a component:
+
+```js
+MyNewPage.title = "Some title"
+MyNewPage.path = "/my_page_path"
+```
+
+##### Create a page set
+
+If you're creating a new page set there's an additional step: you need to create an `index.js` file in the root of your page set. A path would be `frontend/src/pages/MyPageSet/index.js`
+
+This is necessary to group all the pages under the page set. Content of that file would be:
+
+```js
+export { MyNewPage } from './MyNewPage';
+```
+
+At this point you can also setup a layout wrapper around the page set. In this case content of the file will be a little bit different:
+
+```js
+import { MyNewPage } from './MyNewPage';
+
+export const MyLayout = (props) => {
+  return (
+    <div className="some-extra-class">
+      {props.children}
+    </div>
+  )
+}
+
+MyLayout.title = "My Page Set";
+MyLayout.path = "/some_root"
+```
+
+Notice the `props` argument and `props.children`. This is the default React way of passing content to the component. It will work for every component you create. In this case `children` would be a content of a single page you create depending on current route.
+
+Layout can also be extended with `title` and `path`.
+
+Keep in mind that if you're setting `path` property on the layout, every page under this layout will become a nested route and will extend layout's path. It meast that the page we defined earlier will have a full path of `/some_root/my_page_path`.
+
+##### Adding page set to a router
+
+Now the last step is to add our page set to the app. This is done inside `frontend/src/pages/index.js`:
+
+```js
+// First we need to import the page set we've created
+import * as MyPageSet from './MyPageSet'
+/* ...other imports might be here... */
+
+export const Pages = {
+  /* other pages here */,
+  // Next goes our page set
+  MyPageSet,
+}
+```
+
+Now we're done. We can now open the page `/some_root/my_page_path` in the browser and see everything in action.
+
+### Page and Route component properties
+
+* `title` – page title and a breadcrumb. can be string or function
+* `routes` – nested list of routes
+* `layout` – layout component to wrap around nested paths
+* `pages` – set of pages
+* `routes` – set of raw routes
+* `exact` – if true, lookup exact path rather than a subscring
+
+<img src="https://github.com/HumanSignal/label-studio/blob/develop/images/opossum_looking.png?raw=true" title="Hey everyone!" height="140" width="140" />

+ 14 - 0
web/apps/labelstudio/jest.config.ts

@@ -0,0 +1,14 @@
+/* eslint-disable */
+export default {
+  displayName: "labelstudio",
+  preset: "../../jest.preset.js",
+  transform: {
+    "^(?!.*\\.(js|jsx|ts|tsx|css|json)$)": "@nx/react/plugins/jest",
+    "^.+\\.[tj]sx?$": ["babel-jest", { presets: ["@nx/react/babel"] }],
+  },
+  moduleFileExtensions: ["ts", "tsx", "js", "jsx"],
+  moduleNameMapper: {
+    "^apps/labelstudio/(.*)$": "<rootDir>/$1",
+  },
+  coverageDirectory: "../../coverage/apps/labelstudio",
+};

+ 92 - 0
web/apps/labelstudio/project.json

@@ -0,0 +1,92 @@
+{
+  "name": "labelstudio",
+  "$schema": "../../node_modules/nx/schemas/project-schema.json",
+  "sourceRoot": "apps/labelstudio/src",
+  "projectType": "application",
+  "tags": [],
+  "targets": {
+    "build": {
+      "executor": "@nx/webpack:webpack",
+      "outputs": ["{options.outputPath}"],
+      "defaultConfiguration": "production",
+      "options": {
+        "compiler": "babel",
+        "outputPath": "dist/apps/labelstudio",
+        "index": "apps/labelstudio/src/index.html",
+        "baseHref": "/",
+        "main": "apps/labelstudio/src/main.tsx",
+        "tsConfig": "apps/labelstudio/tsconfig.app.json",
+        "assets": [],
+        "styles": [],
+        "scripts": [],
+        "isolatedConfig": true,
+        "webpackConfig": "webpack.config.js"
+      },
+      "configurations": {
+        "development": {
+          "extractLicenses": false,
+          "optimization": false,
+          "sourceMap": true,
+          "vendorChunk": true
+        },
+        "production": {
+          "fileReplacements": [
+            {
+              "replace": "apps/labelstudio/src/environments/environment.ts",
+              "with": "apps/labelstudio/src/environments/environment.prod.ts"
+            }
+          ],
+          "optimization": true,
+          "sourceMap": false,
+          "namedChunks": false,
+          "extractLicenses": true,
+          "vendorChunk": false
+        }
+      }
+    },
+    "serve": {
+      "executor": "@nx/webpack:dev-server",
+      "defaultConfiguration": "development",
+      "options": {
+        "buildTarget": "labelstudio:build",
+        "hmr": true
+      },
+      "configurations": {
+        "development": {
+          "buildTarget": "labelstudio:build:development"
+        },
+        "production": {
+          "buildTarget": "labelstudio:build:production",
+          "hmr": false
+        }
+      }
+    },
+    "serve-static": {
+      "executor": "@nx/web:file-server",
+      "options": {
+        "buildTarget": "labelstudio:build"
+      }
+    },
+    "unit": {
+      "executor": "@nx/jest:jest",
+      "outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
+      "options": {
+        "jestConfig": "apps/labelstudio/jest.config.ts",
+        "passWithNoTests": true
+      },
+      "configurations": {
+        "ci": {
+          "ci": true,
+          "codeCoverage": true
+        }
+      }
+    },
+    "version": {
+      "executor": "nx:run-commands",
+      "options": {
+        "cwd": "apps/labelstudio",
+        "command": "node ../../tools/version/version.mjs"
+      }
+    }
+  }
+}

+ 95 - 0
web/apps/labelstudio/src/app/App.jsx

@@ -0,0 +1,95 @@
+/* global Sentry */
+
+import { createBrowserHistory } from "history";
+import { render } from "react-dom";
+import { Router } from "react-router-dom";
+import { LEAVE_BLOCKER_KEY, leaveBlockerCallback } from "../components/LeaveBlocker/LeaveBlocker";
+import { initSentry } from "../config/Sentry";
+import { ApiProvider, useAPI } from "../providers/ApiProvider";
+import { AppStoreProvider } from "../providers/AppStoreProvider";
+import { ConfigProvider } from "../providers/ConfigProvider";
+import { MultiProvider } from "../providers/MultiProvider";
+import { ProjectProvider } from "../providers/ProjectProvider";
+import { RoutesProvider } from "../providers/RoutesProvider";
+import { DRAFT_GUARD_KEY, DraftGuard, draftGuardCallback } from "../components/DraftGuard/DraftGuard";
+import { AsyncPage } from "./AsyncPage/AsyncPage";
+import ErrorBoundary from "./ErrorBoundary";
+import { FF_UNSAVED_CHANGES, isFF } from "../utils/feature-flags";
+import { TourProvider } from "@humansignal/core";
+import { ToastProvider, ToastViewport } from "@humansignal/ui";
+import { JotaiProvider, JotaiStore } from "../utils/jotai-store";
+import { QueryClientProvider } from "@tanstack/react-query";
+import { queryClient } from "@humansignal/core/lib/utils/query-client";
+import { RootPage } from "./RootPage";
+import { ff } from "@humansignal/core";
+import "@humansignal/ui/src/tailwind.css";
+import "./App.scss";
+import { AuthProvider } from "@humansignal/core/providers/AuthProvider";
+
+const baseURL = new URL(APP_SETTINGS.hostname || location.origin);
+export const UNBLOCK_HISTORY_MESSAGE = "UNBLOCK_HISTORY";
+
+const browserHistory = createBrowserHistory({
+  basename: baseURL.pathname || "/",
+  // callback is an async way to confirm or decline going to another page in the context of routing. It accepts `true` or `false`
+  getUserConfirmation: (message, callback) => {
+    // `history.block` doesn't block events, so in the case of listeners,
+    // we need to have some flag that can be checked for preventing related actions
+    // `isBlocking` flag is used for this purpose
+    browserHistory.isBlocking = true;
+    const callbackWrapper = (result) => {
+      browserHistory.isBlocking = false;
+      callback(result);
+      isFF(FF_UNSAVED_CHANGES) && window.postMessage({ source: "label-studio", payload: UNBLOCK_HISTORY_MESSAGE });
+    };
+    if (message === DRAFT_GUARD_KEY) {
+      draftGuardCallback.current = callbackWrapper;
+    } else if (isFF(FF_UNSAVED_CHANGES) && message === LEAVE_BLOCKER_KEY) {
+      leaveBlockerCallback.current = callbackWrapper;
+    } else {
+      callbackWrapper(window.confirm(message));
+    }
+  },
+});
+
+window.LSH = browserHistory;
+
+initSentry(browserHistory);
+
+const App = ({ content }) => {
+  return (
+    <ErrorBoundary>
+      <Router history={browserHistory}>
+        <MultiProvider
+          providers={[
+            <QueryClientProvider client={queryClient} key="query" />,
+            <JotaiProvider key="jotai" store={JotaiStore} />,
+            <AuthProvider key="auth" />,
+            <AppStoreProvider key="app-store" />,
+            <ToastProvider key="toast" />,
+            <ApiProvider key="api" />,
+            <ConfigProvider key="config" />,
+            <RoutesProvider key="rotes" />,
+            <ProjectProvider key="project" />,
+            ff.isActive(ff.FF_PRODUCT_TOUR) && <TourProvider useAPI={useAPI} />,
+          ].filter(Boolean)}
+        >
+          <AsyncPage>
+            <DraftGuard />
+            <RootPage content={content} />
+            <ToastViewport />
+          </AsyncPage>
+        </MultiProvider>
+      </Router>
+    </ErrorBoundary>
+  );
+};
+
+const root = document.querySelector(".app-wrapper");
+const content = document.querySelector("#main-content");
+
+render(<App content={content.innerHTML} />, root);
+
+if (module?.hot) {
+  module.hot.accept(); // Enable HMR for React components
+}

+ 90 - 0
web/apps/labelstudio/src/app/App.scss

@@ -0,0 +1,90 @@
+@import '../themes/default/variables';
+
+body {
+  --header-height: 48px;
+  --menu-animation-duration: 0.15s;
+  --menu-animation-curve: cubic-bezier(0.21, 1.04, 0.68, 1);
+  --menu-animation-start: -10px;
+  --menu-sidebar-width: 240px;
+
+  margin: 0;
+  color: var(--color-neutral-content);
+  background: var(--color-neutral-background);
+  width: 100vw;
+  max-width: 100%;
+  min-height: 100vh;
+  scrollbar-color: var(--color-neutral-border-bold) var(--color-neutral-background);
+}
+
+.app-wrapper {
+  width: 100vw;
+  max-width: 100%;
+  min-height: 100vh;
+}
+
+.global-error {
+  padding: 32px;
+
+  &__heidi {
+    display: block;
+    margin: 32px auto;
+  }
+
+  h1 {
+    text-transform: uppercase;
+    text-align: center;
+    font-size: 20px;
+  }
+
+  h2 {
+    font-size: 20px;
+    color: var(--color-negative-content);
+  }
+
+  &__details {
+    background: var(--color-neutral-background);
+    max-height: 320px;
+    overflow-y: auto;
+    white-space: pre-wrap;
+    margin: 16px 0;
+    padding: 16px;
+  }
+
+  &__actions {
+    display: flex;
+    gap: 8px;
+
+    >* {
+      line-height: 1em;
+    }
+  }
+
+  &__slack {
+    margin-right: auto;
+    display: flex;
+    align-items: center;
+
+    img {
+      height: 16px;
+      width: 16px;
+      margin-right: 8px;
+    }
+  }
+}
+
+.color {
+  margin: 4px 8px;
+  position: relative;
+
+  &::before {
+    width: 24px;
+    height: 24px;
+    display: block;
+    margin: 0 auto;
+    content: '';
+    border-radius: 100%;
+    background-color: var(--background);
+    color: var(--color-neutral-content);
+    box-shadow: inset 0 0 0 1px rgb(0 0 0 / 15%);
+  }
+}

+ 272 - 0
web/apps/labelstudio/src/app/AsyncPage/AsyncPage.jsx

@@ -0,0 +1,272 @@
+import { createContext, useCallback, useContext, useEffect, useState } from "react";
+import { useHistory } from "react-router";
+import { ErrorWrapper } from "../../components/Error/Error";
+import { modal } from "../../components/Modal/Modal";
+import { ConfigContext } from "../../providers/ConfigProvider";
+import { FF_UNSAVED_CHANGES, isFF } from "../../utils/feature-flags";
+import { absoluteURL, removePrefix } from "../../utils/helpers";
+import { clearScriptsCache, isScriptValid, reInsertScripts, replaceScript } from "../../utils/scripts";
+import { UNBLOCK_HISTORY_MESSAGE } from "../App";
+
+const pageCache = new Map();
+
+const pageFromHTML = (html) => {
+  const parser = new DOMParser();
+  const document = parser.parseFromString(html, "text/html");
+  return document;
+};
+
+const loadAsyncPage = async (url) => {
+  try {
+    if (pageCache.has(url)) {
+      return pageCache.get(url);
+    }
+    const response = await fetch(url);
+    const html = await response.text();
+
+    if (response.status === 401) {
+      location.href = absoluteURL("/");
+      return;
+    }
+
+    if (!response.ok) {
+      modal({
+        body: () => (
+          <ErrorWrapper
+            title={`Error ${response.status}: ${response.statusText}`}
+            errorId={response.status}
+            stacktrace={`Cannot load url ${url}\n\n${html}`}
+          />
+        ),
+        allowClose: false,
+        style: { width: 680 },
+      });
+      return null;
+    }
+
+    pageCache.set(url, html);
+    return html;
+  } catch (err) {
+    modal({
+      body: () => (
+        <ErrorWrapper
+          possum={false}
+          title={"Connection refused"}
+          message={"Server not responding. Is it still running?"}
+        />
+      ),
+      simple: true,
+      allowClose: false,
+      style: { width: 680 },
+    });
+    return null;
+  }
+};
+
+/**
+ * @param {HTMLElement} oldNode
+ * @param {HTMLElement} newNode
+ */
+const swapNodes = async (oldNode, newNode) => {
+  if (oldNode && newNode) {
+    oldNode.replaceWith(newNode);
+    await reInsertScripts(newNode);
+  }
+};
+
+/**
+ * @param {Document} oldPage
+ * @param {Document} newPage
+ */
+const swapAppSettings = async (oldPage, newPage) => {
+  const oldSettings = oldPage.querySelector("script#app-settings");
+  const newSettings = newPage.querySelector("script#app-settings");
+
+  if (oldSettings && newSettings) {
+    await replaceScript(oldSettings, {
+      sourceScript: newSettings,
+      forceUpdate: true,
+    });
+  }
+};
+
+/**
+ * @param {Document} oldPage
+ * @param {Document} newPage
+ */
+const swapContent = async (oldPage, newPage) => {
+  const currentContent = oldPage.querySelector("#dynamic-content");
+  const newContent = newPage.querySelector("#dynamic-content");
+
+  if (currentContent && newContent) {
+    await swapNodes(currentContent, newContent);
+  } else {
+    await swapNodes(oldPage.body.children[0], newContent, { removeOld: false });
+  }
+};
+
+/** @param {HTMLElement} nodes */
+const nodesToSignatures = (nodes) => {
+  return new Set(Array.from(nodes).map((n) => n.outerHTML));
+};
+
+/**
+ * @param {HTMLHeadElement} oldHead
+ * @param {HTMLHeadElement} newHead
+ */
+const swapHeadScripts = async (oldHead, newHead) => {
+  swapNodes(oldHead.querySelector("title"), newHead.querySelector("title"));
+
+  const fragment = document.createDocumentFragment();
+
+  Array.from(newHead.querySelectorAll("script"))
+    .filter((script) => isScriptValid(script))
+    .forEach((script) => fragment.appendChild(script));
+
+  Array.from(oldHead.querySelectorAll("script"))
+    .filter((script) => isScriptValid(script))
+    .forEach((script) => script.remove());
+
+  oldHead.appendChild(fragment);
+  await reInsertScripts(oldHead);
+};
+
+/**
+ * @param {Document} oldPage
+ * @param {Document} newPage
+ */
+const swapStylesheets = async (oldPage, newPage) => {
+  const linkSelector = ["style:not([data-replaced])", "link[rel=stylesheet]:not([data-replaced])"].join(", ");
+  const oldStyles = Array.from(oldPage.querySelectorAll(linkSelector));
+  const newStyles = Array.from(newPage.querySelectorAll(linkSelector));
+
+  const existingSignatures = nodesToSignatures(oldStyles);
+  const stylesToReplace = newStyles.filter((style) => !existingSignatures.has(style.outerHTML));
+
+  await Promise.all(
+    stylesToReplace.map(
+      (style) =>
+        new Promise((resolve) => {
+          style.onload = () => resolve(style.outerHTML);
+          document.head.append(style);
+        }),
+    ),
+  );
+};
+
+/** @param {Document} newPage */
+const swapPageParts = async (newPage, onReady) => {
+  document.title = newPage.title;
+
+  await swapStylesheets(document, newPage);
+  await swapHeadScripts(document.head, newPage.head);
+  await swapAppSettings(document, newPage);
+  await swapContent(document, newPage);
+  onReady?.();
+};
+
+const isVisitable = (target) => {
+  if (!target) return false;
+  if (target.dataset.external) return false;
+  if (target.getAttribute("href").match(/#/)) return false;
+  if (target.origin !== location.origin) return false;
+
+  return true;
+};
+
+const locationWithoutHash = () => {
+  const { href } = location;
+  return href.replace(/#(.*)/g, "");
+};
+
+const fetchPage = async (locationUrl) => {
+  const html = await loadAsyncPage(locationUrl);
+  return html ? pageFromHTML(html) : null;
+};
+
+let currentLocation = locationWithoutHash();
+
+const useStaticContent = (initialContent, onContentLoad) => {
+  const [staticContent, setStaticContent] = useState(initialContent);
+
+  const fetchCallback = useCallback(async (locationUrl) => {
+    currentLocation = locationUrl;
+    clearScriptsCache();
+    const result = await fetchPage(locationUrl);
+
+    if (result) {
+      await swapPageParts(result, onContentLoad);
+      setStaticContent(result);
+      return true;
+    }
+    return false;
+  }, []);
+
+  return [staticContent, fetchCallback];
+};
+
+export const AsyncPageContext = createContext(null);
+
+export const AsyncPageConsumer = AsyncPageContext.Consumer;
+
+export const AsyncPage = ({ children }) => {
+  const initialContent = document;
+
+  const history = useHistory();
+  const config = useContext(ConfigContext);
+  const onLoadCallback = useCallback(() => {
+    config.update(window.APP_SETTINGS);
+  }, []);
+  const [staticContent, fetchStatic] = useStaticContent(initialContent, onLoadCallback);
+
+  const onLinkClick = useCallback(async (e) => {
+    /**@type {HTMLAnchorElement} */
+    const target = e.target.closest("a[href]:not([target]):not([download])");
+
+    if (!isVisitable(target)) return;
+    if (target.matches("[data-external]")) return;
+    if (e.metaKey || e.ctrlKey) return;
+
+    e.preventDefault();
+    const fetched = await fetchStatic(target.href);
+
+    if (fetched) {
+      history.push(`${removePrefix(target.pathname)}${target.search}`);
+    }
+  }, []);
+
+  const onPopState = useCallback(() => {
+    // Prevent false positive triggers in case of blocking page transitions
+    if (isFF(FF_UNSAVED_CHANGES) && history.isBlocking) return;
+    const newLocation = locationWithoutHash();
+    const isSameLocation = newLocation === currentLocation;
+
+    if (!isSameLocation) {
+      currentLocation = newLocation;
+      fetchStatic(newLocation);
+    }
+  }, []);
+
+  // Fallback in case of blocked transitions
+  const onMessage = useCallback((event) => {
+    if (event.origin !== window.origin) return;
+    if (event.data?.source !== "label-studio") return;
+    if (event.data?.payload !== UNBLOCK_HISTORY_MESSAGE) return;
+    onPopState();
+  }, []);
+
+  // useEffect(onPopState, [location]);
+
+  useEffect(() => {
+    document.addEventListener("click", onLinkClick, { capture: true });
+    window.addEventListener("popstate", onPopState);
+    isFF(FF_UNSAVED_CHANGES) && window.addEventListener("message", onMessage);
+    return () => {
+      document.removeEventListener("click", onLinkClick, { capture: true });
+      window.removeEventListener("popstate", onPopState);
+      isFF(FF_UNSAVED_CHANGES) && window.removeEventListener("message", onMessage);
+    };
+  }, []);
+
+  return <AsyncPageContext.Provider value={staticContent}>{children}</AsyncPageContext.Provider>;
+};

+ 92 - 0
web/apps/labelstudio/src/app/ErrorBoundary.jsx

@@ -0,0 +1,92 @@
+import React, { Component } from "react";
+import { ErrorWrapper } from "../components/Error/Error";
+import { Modal } from "../components/Modal/ModalPopup";
+import { captureException } from "../config/Sentry";
+import { isFF } from "../utils/feature-flags";
+import { IMPROVE_GLOBAL_ERROR_MESSAGES } from "../providers/ApiProvider";
+
+export const ErrorContext = React.createContext();
+
+export default class ErrorBoundary extends Component {
+  constructor(props) {
+    super(props);
+    this.state = { hasError: false };
+  }
+
+  static getDerivedStateFromError(error) {
+    // Update state so the next render will show the fallback UI.
+    return { hasError: true, error };
+  }
+
+  componentDidCatch(error, { componentStack }) {
+    console.error(error);
+
+    // Capture the error in Sentry, so we can fix it directly
+    // Don't make the users copy and paste the stacktrace, it's not actionable
+    // Check if error has sentry_skip property (e.g., from ConfigurationError)
+    captureException(error, {
+      extra: {
+        component_stacktrace: componentStack,
+        sentry_skip: error.sentry_skip || false,
+      },
+    });
+    this.setState({
+      error,
+      hasError: true,
+      errorInfo: componentStack,
+    });
+  }
+
+  render() {
+    if (this.state.hasError) {
+      const { error, errorInfo } = this.state;
+
+      const goBack = () => {
+        // usually this will trigger React Router in the broken app, which is not helpful
+        history.back();
+        // so we reload app totally on that previous page after some delay for Router's stuff
+        setTimeout(() => location.reload(), 32);
+      };
+
+      // We will capture the stacktrace in Sentry, so we don't need to show it in the modal
+      // It is not actionable to the user, let's not show it
+      const stacktrace = isFF(IMPROVE_GLOBAL_ERROR_MESSAGES)
+        ? undefined
+        : `${errorInfo ? `Component Stack: ${errorInfo}` : ""}\n\n${this.state.error?.stack ?? ""}`;
+
+      return (
+        <Modal onHide={() => location.reload()} style={{ width: "60vw" }} visible bare>
+          <div style={{ padding: 40 }}>
+            <ErrorWrapper
+              title="Runtime error"
+              message={error}
+              stacktrace={stacktrace}
+              onGoBack={goBack}
+              onReload={() => location.reload()}
+            />
+          </div>
+        </Modal>
+      );
+    }
+
+    return (
+      <ErrorContext.Provider
+        value={{
+          hasError: this.state.hasError,
+          error: this.state.error,
+          errorInfo: this.state.errorInfo,
+          silence: this.silence,
+          unsilence: this.unsilence,
+        }}
+      >
+        {this.props.children}
+      </ErrorContext.Provider>
+    );
+  }
+}
+
+export const ErrorUI = () => {
+  const context = React.useContext(ErrorContext);
+
+  return context.hasError && <div className="error">Error occurred</div>;
+};

+ 21 - 0
web/apps/labelstudio/src/app/RootPage.jsx

@@ -0,0 +1,21 @@
+import { Menubar } from "../components/Menubar/Menubar";
+import { ProjectRoutes } from "../routes/ProjectRoutes";
+import { useOrgValidation } from "../hooks/useOrgValidation";
+
+export const RootPage = ({ content }) => {
+  useOrgValidation();
+  const pinned = localStorage.getItem("sidebar-pinned") === "true";
+  const opened = pinned && localStorage.getItem("sidebar-opened") === "true";
+
+  return (
+    <Menubar
+      enabled={true}
+      defaultOpened={opened}
+      defaultPinned={pinned}
+      onSidebarToggle={(visible) => localStorage.setItem("sidebar-opened", visible)}
+      onSidebarPin={(pinned) => localStorage.setItem("sidebar-pinned", pinned)}
+    >
+      <ProjectRoutes content={content} />
+    </Menubar>
+  );
+};

+ 72 - 0
web/apps/labelstudio/src/app/StaticContent/StaticContent.jsx

@@ -0,0 +1,72 @@
+import parseHTML from "html-react-parser";
+import React from "react";
+import { reInsertScripts } from "../../utils/scripts";
+import { AsyncPageContext } from "../AsyncPage/AsyncPage";
+
+const parseContent = (id, source, children, parse) => {
+  let result;
+  let setInnerHTML = false;
+
+  if (!children || children.length === 0 || children instanceof Function) {
+    const template = source.querySelector(`template#${id}`);
+    const templateHTML = template.innerHTML ?? "";
+
+    if (parse) {
+      const parsed = parseHTML(templateHTML);
+      const childResult = children instanceof Function ? children(parsed) : false;
+
+      result = childResult || parsed;
+    } else {
+      const childResult = children instanceof Function ? children(template) : false;
+
+      if (childResult) {
+        result = childResult;
+      } else {
+        result = templateHTML;
+        setInnerHTML = true;
+      }
+    }
+  } else {
+    result = children;
+  }
+
+  return { children: result, setInnerHTML };
+};
+
+const StaticContentDrawer = React.forwardRef(
+  ({ id, tagName, children, source, onRenderFinished, parse = false, raw = false, ...props }, ref) => {
+    const rootRef = ref ?? React.useRef();
+
+    const [content, setContent] = React.useState(parseContent(id, source, children, parse));
+
+    React.useEffect(() => {
+      setContent(parseContent(id, source, children, parse));
+    }, [source, children]);
+
+    React.useEffect(() => {
+      if (rootRef.current) reInsertScripts(rootRef.current);
+      onRenderFinished?.();
+    }, [content]);
+
+    if (content.setInnerHTML) {
+      props.dangerouslySetInnerHTML = { __html: content.children };
+    } else {
+      props.children = content.children;
+    }
+
+    return raw === true && content.children ? (
+      <React.Fragment children={content.children} />
+    ) : (
+      React.createElement(tagName ?? "div", {
+        ...props,
+        ref: rootRef,
+      })
+    );
+  },
+);
+
+export const StaticContent = React.forwardRef((props, ref) => {
+  const pageSource = React.useContext(AsyncPageContext);
+
+  return pageSource ? <StaticContentDrawer {...props} source={pageSource} ref={ref} /> : null;
+});

A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 7 - 0
web/apps/labelstudio/src/assets/images/heidi-ai.svg


A különbségek nem kerülnek megjelenítésre, a fájl túl nagy
+ 4 - 0
web/apps/labelstudio/src/assets/images/heidi-speaking.svg


+ 3 - 0
web/apps/labelstudio/src/assets/images/index.js

@@ -0,0 +1,3 @@
+export { ReactComponent as HeidiSpeaking } from "./heidi-speaking.svg";
+export { ReactComponent as HeidiAi } from "./heidi-ai.svg";
+export { ReactComponent as LSLogo } from "./logo.svg";

+ 17 - 0
web/apps/labelstudio/src/assets/images/logo.svg

@@ -0,0 +1,17 @@
+<svg width="194" height="30" viewBox="0 0 194 30" fill="none" xmlns="http://www.w3.org/2000/svg">
+<g clip-path="url(#clip0_3335_44667)">
+<path d="M26.2112 4.84192H3.57422V26.2875H26.2112V4.84192Z" fill="#FFD6CD"/>
+<path fill-rule="evenodd" clip-rule="evenodd" d="M8.4444 8.42013H0V0H8.4444V8.42013ZM30.025 8.42017H21.5806V3.32022e-05H30.025V8.42017ZM0 29.9385H8.4444V21.5183H0V29.9385ZM30.025 29.9385H21.5806V21.5183H30.025V29.9385ZM2.81439 8.42028H5.62919V21.5183H2.81439V8.42028ZM27.2099 8.42028H24.3951V21.5183H27.2099V8.42028ZM8.44461 2.80693H21.5803V5.61364H8.44461V2.80693ZM21.5803 24.3246H8.44461V27.1313H21.5803V24.3246Z" fill="#FF7557"/>
+<path d="M41.1965 5.29473V21.8548H50.6312V25.0091H37.9014V5.29473H41.1965Z" fill="currentColor"/>
+<path d="M65.9644 25.0091H62.8101V23.094C62.4721 23.7324 61.8901 24.2581 61.064 24.6711C60.2378 25.0842 59.3554 25.2907 58.4166 25.2907C57.1962 25.2907 56.0791 24.9716 55.0652 24.3332C54.0513 23.6948 53.2533 22.8311 52.6713 21.7422C52.0892 20.6532 51.7982 19.4422 51.7982 18.1091C51.7982 16.7948 52.0892 15.5932 52.6713 14.5042C53.2533 13.4152 54.0513 12.5515 55.0652 11.9132C56.0791 11.2748 57.1962 10.9556 58.4166 10.9556C59.3554 10.9556 60.2378 11.1621 61.064 11.5752C61.8901 11.9883 62.4721 12.514 62.8101 13.1523V11.2372H65.9644V25.0091ZM54.868 18.1091C54.868 18.8977 55.0464 19.6111 55.4031 20.2495C55.7786 20.8879 56.2856 21.3948 56.924 21.7703C57.5811 22.1271 58.2946 22.3054 59.0644 22.3054C59.8342 22.3054 60.5664 22.1083 61.2611 21.714C61.9558 21.3197 62.4721 20.8128 62.8101 20.1932V16.0813C62.4909 15.4617 61.984 14.9548 61.2893 14.5605C60.5946 14.1474 59.8529 13.9409 59.0644 13.9409C58.2758 13.9409 57.5623 14.1287 56.924 14.5042C56.2856 14.8609 55.7786 15.3585 55.4031 15.9968C55.0464 16.6164 54.868 17.3205 54.868 18.1091Z" fill="currentColor"/>
+<path d="M72.3779 13.1523C72.7159 12.514 73.2979 11.9883 74.124 11.5752C74.9689 11.1621 75.8514 10.9556 76.7714 10.9556C77.9918 10.9556 79.109 11.2748 80.1228 11.9132C81.1367 12.5515 81.9347 13.4152 82.5167 14.5042C83.0988 15.5932 83.3898 16.7948 83.3898 18.1091C83.3898 19.4422 83.0988 20.6532 82.5167 21.7422C81.9347 22.8311 81.1367 23.6948 80.1228 24.3332C79.109 24.9716 77.9918 25.2907 76.7714 25.2907C75.8514 25.2907 74.9689 25.0842 74.124 24.6711C73.2979 24.2581 72.7159 23.7324 72.3779 23.094V25.0091H69.2518V5.29473H72.3779V13.1523ZM72.3779 20.1932C72.7159 20.8128 73.2322 21.3197 73.9269 21.714C74.6216 22.1083 75.3538 22.3054 76.1236 22.3054C76.8934 22.3054 77.5975 22.1271 78.2359 21.7703C78.893 21.3948 79.4 20.8879 79.7567 20.2495C80.1322 19.6111 80.32 18.8977 80.32 18.1091C80.32 17.3205 80.1322 16.6164 79.7567 15.9968C79.4 15.3585 78.9024 14.8609 78.2641 14.5042C77.6257 14.1287 76.9122 13.9409 76.1236 13.9409C75.3351 13.9409 74.5934 14.1474 73.8987 14.5605C73.204 14.9548 72.6971 15.4617 72.3779 16.0813V20.1932Z" fill="currentColor"/>
+<path d="M92.175 25.2907C90.8795 25.2907 89.6872 24.9809 88.5982 24.3613C87.5093 23.723 86.6456 22.8499 86.0072 21.7422C85.3688 20.6344 85.0497 19.4046 85.0497 18.0528C85.0497 16.7385 85.3595 15.5462 85.9791 14.476C86.6174 13.387 87.4811 12.5328 88.5701 11.9132C89.6591 11.2748 90.8513 10.9556 92.1468 10.9556C93.3672 10.9556 94.4844 11.2466 95.4983 11.8287C96.5121 12.4107 97.3101 13.1993 97.8921 14.1944C98.4742 15.1895 98.7652 16.2972 98.7652 17.5177C98.7652 17.8556 98.7276 18.3062 98.6525 18.8695H88.0913C88.2603 20.0148 88.7109 20.916 89.4431 21.5732C90.1942 22.2303 91.1423 22.5589 92.2876 22.5589C93.0574 22.5589 93.7709 22.3993 94.428 22.0801C95.104 21.7422 95.5921 21.3103 95.8925 20.7846L98.3709 22.3054C97.864 23.2442 97.0566 23.9765 95.9489 24.5022C94.8599 25.0279 93.6019 25.2907 92.175 25.2907ZM95.5827 16.6164C95.4701 15.734 95.0758 15.0111 94.3999 14.4479C93.724 13.8846 92.926 13.603 92.006 13.603C91.0109 13.603 90.1754 13.8752 89.4995 14.4197C88.8423 14.9454 88.4011 15.6777 88.1758 16.6164H95.5827Z" fill="currentColor"/>
+<path d="M104.502 25.0091H101.375V5.29473H104.502V25.0091Z" fill="currentColor"/>
+<path d="M120.561 5.0131C121.819 5.0131 123.077 5.28535 124.335 5.82984C125.612 6.35555 126.588 7.02209 127.264 7.82944L125.349 10.3923C124.692 9.71638 123.903 9.18127 122.983 8.78699C122.082 8.3927 121.19 8.19556 120.308 8.19556C119.407 8.19556 118.665 8.40209 118.083 8.81515C117.52 9.22821 117.238 9.76332 117.238 10.4205C117.238 10.9838 117.417 11.4532 117.773 11.8287C118.149 12.2042 118.609 12.5046 119.153 12.7299C119.717 12.9364 120.486 13.1805 121.463 13.4621C122.796 13.8377 123.875 14.2226 124.701 14.6168C125.546 14.9923 126.26 15.565 126.842 16.3348C127.443 17.0858 127.743 18.0809 127.743 19.3201C127.743 20.3903 127.452 21.3854 126.87 22.3054C126.307 23.2067 125.49 23.9295 124.42 24.474C123.35 25.0185 122.11 25.2907 120.702 25.2907C119.238 25.2907 117.792 24.9716 116.365 24.3332C114.938 23.6948 113.783 22.8875 112.901 21.9111L115.013 19.4891C115.877 20.3152 116.759 20.963 117.661 21.4324C118.562 21.883 119.576 22.1083 120.702 22.1083C121.791 22.1083 122.674 21.8548 123.35 21.3479C124.026 20.8409 124.363 20.1744 124.363 19.3483C124.363 18.8038 124.185 18.3626 123.828 18.0246C123.472 17.6679 123.021 17.3862 122.477 17.1797C121.932 16.9732 121.181 16.7385 120.223 16.4756C118.89 16.1189 117.801 15.7621 116.957 15.4054C116.13 15.0299 115.417 14.4666 114.816 13.7156C114.215 12.9458 113.915 11.9319 113.915 10.674C113.915 9.6225 114.197 8.66495 114.76 7.80127C115.342 6.9376 116.13 6.26168 117.126 5.77351C118.139 5.26657 119.285 5.0131 120.561 5.0131Z" fill="currentColor"/>
+<path d="M136.126 25.2062C134.568 25.2062 133.31 24.7369 132.352 23.7981C131.414 22.8405 130.944 21.5262 130.944 19.8552V14.2226H128.297V11.2372H130.944V7.5478H134.07V11.2372H137.675V14.2226H134.07V19.7144C134.07 20.503 134.305 21.132 134.774 21.6013C135.263 22.052 135.863 22.2773 136.577 22.2773C136.915 22.2773 137.272 22.2209 137.647 22.1083L137.816 24.8683C137.365 25.0936 136.802 25.2062 136.126 25.2062Z" fill="currentColor"/>
+<path d="M149.846 23.1785C149.433 23.8169 148.851 24.3332 148.1 24.7275C147.349 25.103 146.541 25.2907 145.678 25.2907C143.95 25.2907 142.58 24.7369 141.566 23.6291C140.552 22.5026 140.045 20.9911 140.045 19.0948V11.2372H143.171V18.5879C143.171 19.6956 143.462 20.5687 144.044 21.2071C144.645 21.8454 145.443 22.1646 146.438 22.1646C147.133 22.1646 147.78 21.9956 148.381 21.6577C149.001 21.3197 149.489 20.8597 149.846 20.2777L149.874 11.2372H153.028V25.0091H149.846V23.1785Z" fill="currentColor"/>
+<path d="M166.635 16.0813C166.315 15.4617 165.809 14.9548 165.114 14.5605C164.419 14.1474 163.678 13.9409 162.889 13.9409C162.1 13.9409 161.387 14.1287 160.749 14.5042C160.11 14.8609 159.603 15.3585 159.228 15.9968C158.871 16.6164 158.693 17.3205 158.693 18.1091C158.693 18.8977 158.871 19.6111 159.228 20.2495C159.603 20.8879 160.11 21.3948 160.749 21.7703C161.406 22.1271 162.119 22.3054 162.889 22.3054C163.659 22.3054 164.391 22.1083 165.086 21.714C165.78 21.3197 166.297 20.8128 166.635 20.1932V16.0813ZM155.623 18.1091C155.623 16.7948 155.914 15.5932 156.496 14.5042C157.078 13.4152 157.876 12.5515 158.89 11.9132C159.904 11.2748 161.021 10.9556 162.241 10.9556C163.18 10.9556 164.062 11.1621 164.889 11.5752C165.715 11.9883 166.297 12.514 166.635 13.1523V5.29473H169.761V25.0091H166.635V23.094C166.297 23.7324 165.715 24.2581 164.889 24.6711C164.062 25.0842 163.18 25.2907 162.241 25.2907C161.021 25.2907 159.904 24.9716 158.89 24.3332C157.876 23.6948 157.078 22.8311 156.496 21.7422C155.914 20.6532 155.623 19.4422 155.623 18.1091Z" fill="currentColor"/>
+<path d="M176.202 11.2372V25.0091H173.076V11.2372H176.202ZM174.654 4.84412C175.198 4.84412 175.667 5.04126 176.062 5.43555C176.475 5.82984 176.681 6.29923 176.681 6.84372C176.681 7.40698 176.475 7.88576 176.062 8.28005C175.667 8.67433 175.198 8.87148 174.654 8.87148C174.109 8.87148 173.64 8.67433 173.245 8.28005C172.851 7.88576 172.654 7.40698 172.654 6.84372C172.654 6.29923 172.851 5.82984 173.245 5.43555C173.64 5.04126 174.109 4.84412 174.654 4.84412Z" fill="currentColor"/>
+<path d="M185.962 10.9556C187.276 10.9556 188.477 11.2748 189.566 11.9132C190.655 12.5328 191.519 13.3964 192.157 14.5042C192.796 15.5932 193.115 16.8042 193.115 18.1373C193.115 19.4703 192.796 20.6813 192.157 21.7703C191.519 22.8593 190.655 23.723 189.566 24.3613C188.477 24.9809 187.276 25.2907 185.962 25.2907C184.628 25.2907 183.417 24.9809 182.328 24.3613C181.239 23.723 180.376 22.8593 179.737 21.7703C179.118 20.6813 178.808 19.4703 178.808 18.1373C178.808 16.8042 179.118 15.5932 179.737 14.5042C180.376 13.3964 181.239 12.5328 182.328 11.9132C183.436 11.2748 184.647 10.9556 185.962 10.9556ZM181.878 18.1373C181.878 18.9071 182.056 19.6111 182.413 20.2495C182.77 20.8691 183.258 21.3666 183.877 21.7422C184.516 22.0989 185.21 22.2773 185.962 22.2773C186.713 22.2773 187.398 22.0989 188.017 21.7422C188.637 21.3666 189.125 20.8691 189.482 20.2495C189.839 19.6111 190.017 18.9071 190.017 18.1373C190.017 17.3675 189.839 16.6634 189.482 16.025C189.125 15.3866 188.637 14.8891 188.017 14.5323C187.398 14.1568 186.713 13.9691 185.962 13.9691C185.21 13.9691 184.516 14.1568 183.877 14.5323C183.258 14.8891 182.77 15.3866 182.413 16.025C182.056 16.6634 181.878 17.3675 181.878 18.1373Z" fill="currentColor"/>
+</g>
+</svg>

+ 98 - 0
web/apps/labelstudio/src/components/Breadcrumbs/Breadcrumbs.jsx

@@ -0,0 +1,98 @@
+import { useEffect, useState } from "react";
+import { NavLink } from "react-router-dom";
+import { useConfig } from "../../providers/ConfigProvider";
+import { useBreadcrumbs, useFindRouteComponent } from "../../providers/RoutesProvider";
+import { cn } from "../../utils/bem";
+import { absoluteURL } from "../../utils/helpers";
+import { Dropdown } from "@humansignal/ui";
+import { Menu } from "../Menu/Menu";
+import "./Breadcrumbs.scss";
+
+export const Breadcrumbs = () => {
+  const config = useConfig();
+  const reactBreadcrumbs = useBreadcrumbs();
+  const findComponent = useFindRouteComponent();
+  const [breadcrumbs, setBreadcrumbs] = useState(reactBreadcrumbs);
+
+  useEffect(() => {
+    if (reactBreadcrumbs.length) {
+      setBreadcrumbs(reactBreadcrumbs);
+    } else if (config.breadcrumbs) {
+      setBreadcrumbs(config.breadcrumbs);
+    }
+  }, [reactBreadcrumbs, config]);
+
+  return (
+    <div className={cn("breadcrumbs").toClassName()}>
+      <ul className={cn("breadcrumbs").elem("list").toClassName()}>
+        {breadcrumbs.map((item, index, list) => {
+          const isLastItem = index === list.length - 1;
+
+          const key = `item-${index}-${item.title}`;
+
+          const href = item.href ?? item.path;
+
+          const isInternal = findComponent(href) !== null;
+
+          const title = (
+            <span
+              className={cn("breadcrumbs")
+                .elem("label")
+                .mod({ faded: index === item.length - 1 })
+                .toClassName()}
+            >
+              {item.title}
+            </span>
+          );
+
+          const dropdownSubmenu = item.submenu ? (
+            <Dropdown>
+              <Menu>
+                {item.submenu.map((sub, index) => {
+                  return (
+                    <Menu.Item
+                      key={`${index}-${item.title}`}
+                      label={sub.title}
+                      icon={sub.icon}
+                      href={sub.href ?? sub.path}
+                      active={sub.active}
+                    />
+                  );
+                })}
+              </Menu>
+            </Dropdown>
+          ) : null;
+
+          return item.onClick ? (
+            <li key={key} className={cn("breadcrumbs").elem("item").mod({ last: isLastItem }).toClassName()}>
+              <span onClick={item.onClick}>{title}</span>
+            </li>
+          ) : dropdownSubmenu ? (
+            <Dropdown.Trigger
+              key={key}
+              component="li"
+              className={cn("breadcrumbs").elem("item").mod({ last: isLastItem }).toClassName()}
+              content={dropdownSubmenu}
+            >
+              <span>{title}</span>
+            </Dropdown.Trigger>
+          ) : href && !isLastItem ? (
+            <li key={key} className={cn("breadcrumbs").elem("item").mod({ last: isLastItem }).toClassName()}>
+              {isInternal ? (
+                <NavLink to={href} data-external={true}>
+                  {title}
+                </NavLink>
+              ) : (
+                <a href={absoluteURL(href)}>{title}</a>
+              )}
+            </li>
+          ) : (
+            <li key={key} className={cn("breadcrumbs").elem("item").mod({ last: isLastItem }).toClassName()}>
+              {title}
+            </li>
+          );
+        })}
+      </ul>
+    </div>
+  );
+};

+ 112 - 0
web/apps/labelstudio/src/components/Breadcrumbs/Breadcrumbs.scss

@@ -0,0 +1,112 @@
+.breadcrumbs {
+  height: 100%;
+  display: flex;
+  align-items: center;
+  margin-right: 20px;
+
+  &__label {
+    display: flex;
+    gap: 4px;
+    align-items: center;
+  }
+
+  &__beta {
+    font-size: 12px;
+    font-style: normal;
+    font-weight: 500;
+    line-height: 16px;
+    color: var(--plum_0);
+    padding: 2px 8px;
+    background-color: var(--plum_500);
+    border-radius: 12px;
+  }
+
+  &__list {
+    height: 100%;
+    display: flex;
+    align-items: center;
+    list-style-type: none;
+    margin: 0;
+    padding: 0;
+  }
+
+  &__item {
+    font-size: 16px;
+    line-height: 22px;
+    position: relative;
+    margin: 0;
+    padding: 0;
+    height: 100%;
+    display: flex;
+    align-items: center;
+    cursor: default;
+
+    &:not(.breadcrumbs__item_last) {
+      cursor: pointer;
+
+      & > span,
+      & > a {
+        color: var(--color-neutral-content-subtler);
+
+        &:hover {
+          color: var(--color-primary-content-hover);
+        }
+      }
+    }
+
+    &:not(:nth-child(2)) {
+      flex-shrink: 0;
+    }
+
+    &:nth-child(2) span {
+      overflow: hidden;
+      text-overflow: ellipsis;
+      white-space: nowrap;
+    }
+
+    &:only-child {
+      pointer-events: none;
+    }
+  }
+
+  &__item > span,
+  &__item > a {
+    text-decoration: none;
+    color: var(--color-neutral-content);
+
+    a {
+      color: var(--color-primary-content);
+    }
+  }
+
+  &__item + &__item {
+    margin-left: 30px;
+  }
+
+  &__item + &__item::before {
+    top: 50%;
+    right: 100%;
+    width: 30px;
+    content: "/";
+    height: 16px;
+    display: block;
+    color: var(--color-neutral-content-subtlest);
+    position: absolute;
+    transform: translate3d(0, -50%, 0);
+    text-align: center;
+    line-height: 16px;
+    font-size: 18px;
+  }
+
+  &__settings {
+    width: 20px;
+    height: 20px;
+    display: block;
+    margin-left: 10px;
+  }
+
+  &__settings img {
+    display: block;
+    opacity: 0.23;
+  }
+}

+ 22 - 0
web/apps/labelstudio/src/components/Card/Card.jsx

@@ -0,0 +1,22 @@
+import { cn } from "../../utils/bem";
+import { Tooltip } from "@humansignal/ui";
+import "./Card.scss";
+
+export const Card = ({ header, extra, children, style }) => {
+  const rootClass = cn("card");
+
+  return (
+    <div className={rootClass} style={style}>
+      {(header || extra) && (
+        <div className={rootClass.elem("header")}>
+          <Tooltip title={header}>
+            <div className="line-clamp-1">{header}</div>
+          </Tooltip>
+
+          {extra && <div className={rootClass.elem("header-extra")}>{extra}</div>}
+        </div>
+      )}
+      <div className={rootClass.elem("content")}>{children}</div>
+    </div>
+  );
+};

+ 25 - 0
web/apps/labelstudio/src/components/Card/Card.scss

@@ -0,0 +1,25 @@
+.card {
+  border-radius: 5px;
+  background-color: var(--color-neutral-background);
+  border: 1px solid var(--color-neutral-border);
+
+  &__header {
+    display: flex;
+    height: 48px;
+    padding: 0 15px;
+    align-items: center;
+    font-weight: 500;
+    font-size: 16px;
+    line-height: 18px;
+    justify-content: space-between;
+    border-bottom: 1px solid var(--color-neutral-border);
+  }
+
+  &__content {
+    padding: 15px;
+  }
+
+  &:not(:first-child) {
+    margin-top: 24px;
+  }
+}

+ 26 - 0
web/apps/labelstudio/src/components/Columns/Columns.jsx

@@ -0,0 +1,26 @@
+import React from "react";
+import { cn } from "../../utils/bem";
+import "./Columns.scss";
+
+export const Columns = ({ children, count, size, gap }) => {
+  /**@type {import('react').RefObject<HTMLElement>} */
+  const ref = React.useRef();
+
+  /**@type {import('react').CSSProperties} */
+  const style = {
+    "--columns": Math.max(1, count ?? 1),
+    "--column-width": size,
+    "--column-gap": gap,
+  };
+
+  return <div ref={ref} className={cn("columns")} style={style} children={children} />;
+};
+
+Columns.Column = ({ title, children }) => {
+  return (
+    <div className={cn("columns").elem("item")}>
+      <div className={cn("columns").elem("title")}>{title}</div>
+      {children}
+    </div>
+  );
+};

+ 17 - 0
web/apps/labelstudio/src/components/Columns/Columns.scss

@@ -0,0 +1,17 @@
+.columns {
+  --column-default-width: calc(100% / var(--columns));
+  --column-size: var(--column-width, var(--column-default-width));
+
+  display: grid;
+  grid-template-columns: repeat(var(--columns), var(--column-size));
+  grid-column-gap: var(--column-gap, 10px);
+
+  &__title {
+    margin-bottom: 0.5rem;
+    font-weight: 500;
+    font-size: 1.125rem;
+    line-height: 22px;
+    padding: 0 1rem 0 0;
+    color: var(--color-neutral-content);
+  }
+}

+ 22 - 0
web/apps/labelstudio/src/components/CopyableTooltip/CopyableTooltip.jsx

@@ -0,0 +1,22 @@
+import { Children, cloneElement, forwardRef, useCallback } from "react";
+import { useCopyText } from "../../hooks/useCopyText";
+import { Tooltip } from "@humansignal/ui";
+
+export const CopyableTooltip = forwardRef(({ children, title, textForCopy, ...restProps }, ref) => {
+  const [copied, copyText] = useCopyText({ defaultText: textForCopy });
+
+  const clickHandler = useCallback((e) => {
+    e.preventDefault();
+    e.stopPropagation();
+    copyText();
+  }, []);
+
+  const child = Children.only(children);
+  const clone = cloneElement(child, {
+    ...child.props,
+    ref,
+    onClick: clickHandler,
+  });
+
+  return <Tooltip title={copied ? "Copied!" : title} onClick={clickHandler} {...restProps} children={clone} />;
+});

+ 32 - 0
web/apps/labelstudio/src/components/DescriptionList/DescriptionList.jsx

@@ -0,0 +1,32 @@
+import { cn } from "../../utils/bem";
+import "./DescriptionList.scss";
+import { IconInfoOutline } from "@humansignal/icons";
+import { Tooltip } from "@humansignal/ui";
+
+export const DescriptionList = ({ style, className, children }) => {
+  return (
+    <dl className={cn("dl").mix(className)} style={style}>
+      {children}
+    </dl>
+  );
+};
+
+DescriptionList.Item = ({ retmClassName, descriptionClassName, term, descriptionStyle, termStyle, children, help }) => {
+  return (
+    <>
+      <dt className={cn("dl").elem("dt").mix(retmClassName)} style={descriptionStyle}>
+        {term}{" "}
+        {help ? (
+          <Tooltip style={{ whiteSpace: "pre-wrap" }} title={help}>
+            <IconInfoOutline className={cn("help-icon")} width="14" height="14" />
+          </Tooltip>
+        ) : (
+          ""
+        )}
+      </dt>
+      <dd className={cn("dl").elem("dd").mix(descriptionClassName)} style={termStyle}>
+        {children}
+      </dd>
+    </>
+  );
+};

+ 31 - 0
web/apps/labelstudio/src/components/DescriptionList/DescriptionList.scss

@@ -0,0 +1,31 @@
+.dl {
+  margin: 0;
+  display: grid;
+  font-size: 16px;
+  line-height: 22px;
+  color: var(--color-neutral-content-subtler);
+  grid-template-columns: 40% 60%;
+  grid-row-gap: 12px;
+
+  &__dt {
+    font-weight: 500;
+    min-width: 300px;
+    color: var(--color-neutral-content);
+    display: flex;
+    align-items: center;
+    gap: var(--spacing-tighter);
+  }
+
+  &__dd {
+    margin: 0;
+  }
+}
+
+.help-icon {
+  position: relative;
+  display: inline;
+  vertical-align: baseline;
+  color: var(--color-primary-icon);
+  width: 20px;
+  height: 20px;
+}

+ 3 - 0
web/apps/labelstudio/src/components/Divider/Divider.jsx

@@ -0,0 +1,3 @@
+export const Divider = ({ height }) => {
+  return <div style={{ height: height }} />;
+};

+ 60 - 0
web/apps/labelstudio/src/components/DraftGuard/DraftGuard.jsx

@@ -0,0 +1,60 @@
+import { useContext, useEffect } from "react";
+import { useHistory } from "react-router-dom";
+import { ToastContext } from "@humansignal/ui";
+
+export const DRAFT_GUARD_KEY = "DRAFT_GUARD";
+
+export const draftGuardCallback = {
+  current: null,
+};
+
+export const DraftGuard = () => {
+  const toast = useContext(ToastContext);
+  const history = useHistory();
+
+  useEffect(() => {
+    const unblock = () => {
+      draftGuardCallback.current?.(true);
+      draftGuardCallback.current = null;
+    };
+
+    /**
+     * The version of Router History that is in use does not currently support
+     * the `block` method fully. This is a workaround to allow us to block navigation
+     * when there are unsaved changes. The draftGuardCallback allows the unblock callback to be captured from the
+     * history callback `getUserConfirmation` that is triggered by returning a string message from history.block, allowing the user to
+     * confirm they want to leave the page. Here we send through a constant message
+     * to signify that we aren't looking for user confirmation but to utilize this to enable navigation blocking based on
+     * unsuccessful draft saves.
+     */
+    const unsubscribe = history.block(() => {
+      const selected = window.Htx?.annotationStore?.selected;
+      const submissionInProgress = !!selected?.submissionStarted;
+      const hasChanges = !!selected?.history.undoIdx && !submissionInProgress;
+
+      if (hasChanges) {
+        selected.saveDraftImmediatelyWithResults()?.then((res) => {
+          const status = res?.$meta?.status;
+
+          if (status === 200 || status === 201) {
+            toast.show({ message: "Draft saved successfully", type: "info" });
+            unblock();
+          } else if (status !== undefined) {
+            toast.show({ message: "There was an error saving your draft", type: "error" });
+          } else {
+            unblock();
+          }
+        });
+
+        return DRAFT_GUARD_KEY;
+      }
+    });
+
+    return () => {
+      unblock();
+      unsubscribe();
+    };
+  }, []);
+
+  return <></>;
+};

+ 14 - 0
web/apps/labelstudio/src/components/EmptyState/EmptyState.jsx

@@ -0,0 +1,14 @@
+import { cn } from "../../utils/bem";
+import "./EmptyState.scss";
+
+export const EmptyState = ({ icon, title, description, action, footer }) => {
+  return (
+    <div className={cn("empty-state-default").toClassName()}>
+      {icon && <div className={cn("empty-state-default").elem("icon").toClassName()}>{icon}</div>}
+      {title && <div className={cn("empty-state-default").elem("title").toClassName()}>{title}</div>}
+      {description && <div className={cn("empty-state-default").elem("description").toClassName()}>{description}</div>}
+      {action && <div className={cn("empty-state-default").elem("action").toClassName()}>{action}</div>}
+      {footer && <div className={cn("empty-state-default").elem("footer").toClassName()}>{footer}</div>}
+    </div>
+  );
+};

+ 59 - 0
web/apps/labelstudio/src/components/EmptyState/EmptyState.scss

@@ -0,0 +1,59 @@
+.empty-state-default {
+  max-width: 40rem;
+  background: var(--color-primary-background);
+  border: 1px solid var(--color-primary-border-subtlest);
+  padding: 2rem;
+  border-radius: 0.5rem;
+  display: flex;
+  flex-direction: column;
+  justify-content: center;
+  align-items: center;
+
+  &__icon {
+    margin-bottom: 0.75rem;
+    background: var(--color-primary-emphasis);
+    padding: 0.5rem;
+    display: flex;
+    border-radius: 2rem;
+
+    & svg path {
+      fill: var(--color-primary-icon);
+    }
+  }
+
+  &__action {
+    margin-bottom: 1.25rem;
+  }
+
+  &__title {
+    font-size: 1.75rem;
+    font-weight: 500;
+    color: var(--color-primary-content);
+    margin-bottom: 1rem;
+    text-align: center;
+  }
+
+  &__description {
+    font-size: 1rem;
+    color: var(--color-neutral-content-subtle);
+    margin-bottom: 1rem;
+    text-align: center;
+    line-height: 1.5em;
+  }
+
+  &__footer {
+    font-size: 0.75rem;
+    color: var(--color-neutral-content);
+    text-align: center;
+    line-height: 1.5em;
+  }
+
+  &__footer a {
+    color: var(--grape_700);
+    text-decoration: underline;
+
+    &:hover {
+      text-decoration: none;
+    }
+  }
+}

+ 131 - 0
web/apps/labelstudio/src/components/Error/Error.jsx

@@ -0,0 +1,131 @@
+import { Fragment, useCallback, useMemo, useState } from "react";
+import sanitizeHtml from "sanitize-html";
+import { IconSlack } from "@humansignal/icons";
+import { cn } from "../../utils/bem";
+import { absoluteURL, copyText } from "../../utils/helpers";
+import { Button } from "@humansignal/ui";
+import { Space } from "../Space/Space";
+import "./Error.scss";
+
+const SLACK_INVITE_URL = "https://slack.labelstud.io/?source=product-error-msg";
+
+export const ErrorWrapper = ({
+  title,
+  message,
+  errorId,
+  stacktrace,
+  validation,
+  version,
+  onGoBack,
+  onReload,
+  possum = false,
+  minimal = false,
+}) => {
+  const preparedStackTrace = useMemo(() => {
+    return (stacktrace ?? "").trim();
+  }, [stacktrace]);
+
+  const [copied, setCopied] = useState(false);
+
+  const copyStacktrace = useCallback(() => {
+    setCopied(true);
+    copyText(preparedStackTrace);
+    setTimeout(() => setCopied(false), 1200);
+  }, [preparedStackTrace]);
+
+  return (
+    <div className={cn("error-message").toClassName()}>
+      {!minimal && possum !== false && (
+        <img
+          className={cn("error-message").elem("heidi").toClassName()}
+          src={absoluteURL("/static/images/opossum_broken.svg")}
+          height="111"
+          alt="Heidi's down"
+        />
+      )}
+
+      {!minimal && title && <div className={cn("error-message").elem("title").toClassName()}>{title}</div>}
+
+      {!minimal && message && (
+        <div
+          className={cn("error-message").elem("detail").toClassName()}
+          dangerouslySetInnerHTML={{
+            __html: sanitizeHtml(String(message)),
+          }}
+        />
+      )}
+
+      {!minimal && preparedStackTrace && (
+        <div
+          className={cn("error-message").elem("stracktrace").toClassName()}
+          dangerouslySetInnerHTML={{
+            __html: sanitizeHtml(preparedStackTrace.replace(/(\n)/g, "<br>")),
+          }}
+        />
+      )}
+
+      {validation?.length > 0 && (
+        <ul className={cn("error-message").elem("validation").toClassName()}>
+          {validation.map(([field, errors]) => (
+            <Fragment key={field}>
+              {[].concat(errors).map((err, i) => (
+                <li
+                  key={i}
+                  className={cn("error-message").elem("message").toClassName()}
+                  dangerouslySetInnerHTML={{ __html: sanitizeHtml(err) }}
+                />
+              ))}
+            </Fragment>
+          ))}
+        </ul>
+      )}
+
+      {!minimal && (version || errorId) && (
+        <div className={cn("error-message").elem("version").toClassName()}>
+          <Space>
+            {version && `Version: ${version}`}
+            {errorId && `Error ID: ${errorId}`}
+          </Space>
+        </div>
+      )}
+
+      {!minimal && (
+        <div className={cn("error-message").elem("actions").toClassName()}>
+          <Space spread>
+            <Button
+              className={cn("error-message").elem("action-slack").toClassName()}
+              target="_blank"
+              icon={<IconSlack />}
+              href={SLACK_INVITE_URL}
+            >
+              Ask on Slack
+            </Button>
+
+            <Space size="small">
+              {preparedStackTrace && (
+                <Button
+                  disabled={copied}
+                  onClick={copyStacktrace}
+                  className="w-[100px]"
+                  aria-label="Copy error stacktrace"
+                >
+                  {copied ? "Copied" : "Copy Stacktrace"}
+                </Button>
+              )}
+              {onGoBack && (
+                <Button onClick={onGoBack} aria-label="Go back">
+                  Go Back
+                </Button>
+              )}
+              {onReload && (
+                <Button onClick={onReload} aria-label="Reload page">
+                  Reload
+                </Button>
+              )}
+            </Space>
+          </Space>
+        </div>
+      )}
+    </div>
+  );
+};

+ 104 - 0
web/apps/labelstudio/src/components/Error/Error.scss

@@ -0,0 +1,104 @@
+.inline-error {
+  width: 100%;
+  border-radius: 0.5rem;
+  box-sizing: border-box;
+  background-color: var(--color-negative-background);
+}
+
+.error-message {
+  max-width: 100%;
+  padding: 1rem;
+
+  &__heidi {
+    display: block;
+    margin: 32px auto 0;
+  }
+
+  &__title {
+    color: var(--color-negative-content);
+    font-size: 1.25rem;
+    font-weight: 600;
+  }
+
+  &__detail {
+    font-size: 1rem;
+    font-weight: 400;
+    color: var(--color-neutral-content);
+    margin-top: 0.5rem;
+    white-space: pre-line;
+    word-break: break-word;
+  }
+
+  &__exception {
+    margin: 0.5rem 1rem;
+  }
+
+  &__stracktrace {
+    margin: 0.5rem 1rem;
+    padding: 16px;
+    overflow: auto;
+    line-height: 26px;
+    max-height: 200px;
+    white-space: pre;
+    border-radius: 5px;
+    color: var(--color-neutral-content);
+    font-family: var(--font-mono);
+  }
+
+  &__version {
+    font-size: 14px;
+    font-weight: 600;
+    margin: 0.5rem 0;
+    color: var(--color-neutral-content-subtlest);
+  }
+
+  &__validation {
+    padding: 0;
+    margin: 0.5rem 1rem;
+    list-style-type: none;
+    max-height: 300px;
+    overflow-y: auto;
+  }
+
+  &__message {
+    color: var(--color-neutral-content-subtler);
+    padding: 0;
+    white-space: pre-line;
+    line-height: 1.4;
+    word-break: break-word;
+  }
+
+  &__actions {
+    display: flex;
+    margin: 1rem;
+  }
+
+  &__slack {
+    margin-right: auto;
+    display: flex;
+    align-items: center;
+
+    img {
+      height: 16px;
+      width: 16px;
+      margin-right: 8px;
+    }
+  }
+
+  &_kind_paused {
+    padding: 32px;
+  }
+
+  &_kind_paused &__detail {
+    margin-block: 16px;
+  }
+
+  &_kind_paused &__actions {
+    margin-inline: 0;
+  }
+}
+
+.paused-error .modal-ls__content {
+  border-radius: 16px;
+  overflow: hidden;
+}

+ 13 - 0
web/apps/labelstudio/src/components/Error/InlineError.d.ts

@@ -0,0 +1,13 @@
+import type { CSSProperties, ReactNode, FC } from "react";
+
+export interface InlineErrorProps {
+  minimal?: boolean;
+  includeValidation?: boolean;
+  className?: string;
+  style?: CSSProperties;
+  children?: ReactNode;
+}
+
+declare module "apps/labelstudio/src/components/Error/InlineError" {
+  export const InlineError: FC<InlineErrorProps>;
+}

+ 23 - 0
web/apps/labelstudio/src/components/Error/InlineError.jsx

@@ -0,0 +1,23 @@
+import React from "react";
+import { ApiContext } from "../../providers/ApiProvider";
+import { cn } from "../../utils/bem";
+import { ErrorWrapper } from "./Error";
+
+export const InlineError = ({ minimal, children, includeValidation, className, style }) => {
+  const context = React.useContext(ApiContext);
+
+  React.useEffect(() => {
+    context.showGlobalError = false;
+  }, [context]);
+
+  return context.error ? (
+    <div className={cn("inline-error").mix(className).toClassName()} style={style}>
+      <ErrorWrapper
+        possum={false}
+        minimal={minimal}
+        {...context.errorFormatter(context.error, { includeValidation })}
+      />
+      {children}
+    </div>
+  ) : null;
+};

+ 0 - 0
web/apps/labelstudio/src/components/Error/PauseError


+ 176 - 0
web/apps/labelstudio/src/components/Form/Elements/Counter/Counter.jsx

@@ -0,0 +1,176 @@
+import React from "react";
+import { cn } from "../../../../utils/bem";
+import { Oneof } from "../../../Oneof/Oneof";
+import { FormField } from "../../FormField";
+import { default as Label } from "../Label/Label";
+import "./Counter.scss";
+import { IconMinus, IconPlus } from "@humansignal/icons";
+
+const allowedKeys = ["ArrowUp", "ArrowDown", "Backspace", "Delete", /[0-9]/];
+
+const CounterContext = React.createContext(null);
+
+const Counter = ({ label, className, validate, required, skip, labelProps, ...props }) => {
+  const [min, max] = [props.min ?? Number.NEGATIVE_INFINITY, props.max ?? Number.POSITIVE_INFINITY];
+
+  const normalizeValue = (value) => Math.max(min, Math.min(max, value));
+
+  const [currentValue, setCurrentValue] = React.useState(normalizeValue(props.value ?? 0));
+  const [focused, setFocused] = React.useState(props.autofocus ?? false);
+  const [disabled, setDisabled] = React.useState(props.disabled ?? null);
+
+  const setNewValue = (value) => {
+    setCurrentValue(normalizeValue(Number(value)));
+  };
+
+  const increase = React.useCallback(() => {
+    setNewValue((currentValue ?? 0) + (props.step ?? 1));
+  }, [currentValue, props.step]);
+
+  const decrease = React.useCallback(() => {
+    setNewValue((currentValue ?? 0) - (props.step ?? 1));
+  }, [currentValue, props.step]);
+
+  /**@type {(e: import('react').SyntheticEvent<HTMLInputElement, KeyboardEvent>)} */
+  const onInputHandler = (e) => {
+    const allowedKey = allowedKeys.find((k) => (k instanceof RegExp ? k.test(e.key) : k === e.key));
+
+    if (!allowedKey && !e.metaKey) e.preventDefault();
+
+    if (allowedKey === "ArrowUp") {
+      increase();
+      e.preventDefault();
+    } else if (allowedKey === "ArrowDown") {
+      decrease();
+      e.preventDefault();
+    }
+  };
+
+  /**@type {(e: import('react').SyntheticEvent<HTMLInputElement, ClipboardEvent>)} */
+  const onPasteHandler = (e) => {
+    const content = e.nativeEvent.clipboardData.getData("text");
+    const isNumerical = /([0-9]+)/.test(content);
+
+    if (!isNumerical) e.preventDefault();
+  };
+
+  /**@type {(e: import('react').SyntheticEvent<HTMLInputElement>)} */
+  const onChangeHandler = (e) => {
+    if (e.target.value) {
+      setCurrentValue(normalizeValue(Number(e.target.value)));
+    } else {
+      setCurrentValue("");
+    }
+    props.onChange?.(e);
+  };
+
+  const onFocusHandler = (e) => {
+    setFocused(true);
+    props.onFocus?.(e);
+  };
+
+  const onBlurHandler = (e) => {
+    setFocused(false);
+    props.onBlur?.(e);
+  };
+
+  const onClickHandler = (type, input) => (e) => {
+    e.preventDefault();
+    e.stopPropagation();
+    document.activeElement?.blur();
+    setFocused();
+    input.current.focus();
+    getSelection().removeAllRanges();
+    if (type === "increase") return increase();
+    if (type === "decrease") return decrease();
+  };
+
+  // Update currentValue when props.value changes
+  React.useEffect(() => {
+    if (props.value !== undefined && props.value !== null) {
+      setCurrentValue(normalizeValue(Number(props.value)));
+    }
+  }, [props.value]);
+
+  const field = (
+    <FormField
+      label={label}
+      name={props.name}
+      validate={validate}
+      required={required}
+      setValue={setNewValue}
+      skip={skip}
+      onDependencyChanged={(f) => {
+        if (f.type === "checkbox") setDisabled(!f.checked);
+      }}
+      {...props}
+    >
+      {(ref, dep) => {
+        const depDisabled = (dep?.type === "checkbox" && dep?.checked === false) || false;
+        const fieldDisabled = disabled ?? depDisabled;
+        const contextValue = {
+          currentValue,
+          min,
+          max,
+          disabled: fieldDisabled,
+          ref,
+          onClickHandler,
+        };
+
+        return (
+          <CounterContext.Provider value={contextValue}>
+            <div className={cn("counter").mod({ focused, disabled: fieldDisabled }).mix(className).toClassName()}>
+              <CounterButton type="decrease" />
+
+              <input
+                ref={ref}
+                className={cn("counter").elem("input").toClassName()}
+                type="text"
+                disabled={fieldDisabled}
+                value={currentValue}
+                onKeyDown={onInputHandler}
+                onPaste={onPasteHandler}
+                onChange={onChangeHandler}
+                onFocus={onFocusHandler}
+                onBlur={onBlurHandler}
+              />
+
+              <CounterButton type="increase" />
+            </div>
+          </CounterContext.Provider>
+        );
+      }}
+    </FormField>
+  );
+
+  return label ? <Label {...(labelProps ?? {})} text={label} required={required} children={field} /> : field;
+};
+
+const CounterButton = ({ type }) => {
+  const { currentValue, min, max, disabled, ref, onClickHandler } = React.useContext(CounterContext);
+
+  const compareLimit = type === "increase" ? max : min;
+
+  return (
+    // biome-ignore lint/a11y/useValidAnchor: Legacy counter design uses anchor for styling
+    <a
+      className={cn("counter")
+        .elem("btn")
+        .mod({
+          type,
+          disabled: currentValue === compareLimit || disabled,
+        })
+        .toClassName()}
+      href="#"
+      onClick={onClickHandler(type, ref)}
+      onMouseDownCapture={(e) => e.preventDefault()}
+    >
+      <Oneof value={type}>
+        <IconMinus case="decrease" />
+        <IconPlus case="increase" />
+      </Oneof>
+    </a>
+  );
+};
+
+export default Counter;

+ 68 - 0
web/apps/labelstudio/src/components/Form/Elements/Counter/Counter.scss

@@ -0,0 +1,68 @@
+.counter {
+  width: 114px;
+  height: 40px;
+  display: flex;
+  min-width: 114px;
+  border-radius: 8px;
+  background: var(--color-neutral-surface);
+  box-sizing: border-box;
+
+  // box-shadow: 0 0 0 1px rgba(var(--color-neutral-shadow-raw) / 16%) inset;
+  border: 1px solid var(--color-neutral-border);
+  transition: all 150ms ease;
+  align-items: center;
+
+  &:active {
+    border: 1px solid var(--color-neutral-border-bold);
+  }
+
+  &_disabled {
+    opacity: 0.6;
+    background-color: var(--color-neutral-background);
+  }
+
+  &__btn {
+    min-width: 32px;
+    min-height: 32px;
+    margin: 4px;
+    border-radius: 4px;
+    background: var(--color-neutral-background);
+    display: flex;
+    color: var(--color-primary-icon);
+    border: none;
+    outline: none;
+    align-items: center;
+    justify-content: center;
+    transition: all 150ms ease;
+    box-shadow: 0 4px 8px rgba(var(--color-neutral-shadow-raw) / 16%), 0 1px 2px rgba(var(--color-neutral-shadow-raw) / 30%);
+
+    &_disabled {
+      box-shadow: none;
+      pointer-events: none;
+      color: var(--color-neutral-content-subtlest);
+      background: var(--color-neutral-surface);
+    }
+
+    &:active,
+    &:hover {
+      background: var(--color-neutral-surface-hover);
+      color: var(--color-primary-content);
+      box-shadow: 0 6px 12px 0 rgb(0 0 0 / 15%), 0 2px 4px 0 rgb(38 38 38 / 30%);
+      box-shadow: 0 6px 12px rgba(var(--color-neutral-shadow-raw) / 16%), 0 2px 4px rgba(var(--color-neutral-shadow-raw) / 30%);
+
+    }
+  }
+
+  &__input {
+    flex: 1;
+    width: 100%;
+    border: none;
+    padding: 0;
+    background: none;
+    text-align: center;
+    outline: none;
+    font-size: 16px;
+    line-height: 22px;
+    color: var(--color-neutral-content);
+  }
+}

+ 45 - 0
web/apps/labelstudio/src/components/Form/Elements/Input/Input.jsx

@@ -0,0 +1,45 @@
+import { cn } from "../../../../utils/bem";
+import { FormField } from "../../FormField";
+import { default as Label } from "../Label/Label";
+import "./Input.scss";
+
+const Input = ({
+  label,
+  description,
+  footer,
+  className,
+  validate,
+  required,
+  skip,
+  labelProps,
+  ghost,
+  tooltip,
+  tooltipIcon,
+  ...props
+}) => {
+  const classList = [cn("input-ls").mod({ ghost }), className].join(" ").trim();
+
+  const input = (
+    <FormField label={label} name={props.name} validate={validate} required={required} skip={skip} {...props}>
+      {(ref) => <input {...props} ref={ref} className={classList} />}
+    </FormField>
+  );
+
+  return label ? (
+    <Label
+      {...(labelProps ?? {})}
+      description={description}
+      footer={footer}
+      text={label}
+      tooltip={tooltip}
+      tooltipIcon={tooltipIcon}
+      required={required}
+    >
+      {input}
+    </Label>
+  ) : (
+    input
+  );
+};
+
+export default Input;

+ 68 - 0
web/apps/labelstudio/src/components/Form/Elements/Input/Input.scss

@@ -0,0 +1,68 @@
+.input-ls,
+.select-ls,
+.textarea-ls {
+  --input-size: 40px;
+
+  height: var(--input-size);
+  min-height: var(--input-size);
+  background: var(--color-neutral-background);
+  font-size: 16px;
+  line-height: 22px;
+  border: 1px solid var(--color-neutral-border);
+  box-sizing: border-box;
+  border-radius: 5px;
+  padding: 0 16px;
+  transition: all 150ms ease-out;
+  font-weight: 400;
+  color: var(--color-neutral-content);
+  box-shadow: inset 0 1px 2px rgba(var(--color-neutral-shadow-raw) / 8%);
+
+  &::placeholder {
+    color: var(--color-neutral-content-subtler);
+  }
+
+  &_ghost {
+    border: none;
+    padding: 0;
+    background-color: transparent;
+    outline: none;
+  }
+
+  &:read-only {
+    background-color: var(--color-neutral-surface);
+    color: var(--color-neutral-content-subtler);
+  }
+}
+
+.input-ls,
+.textarea-ls {
+  &:not([disabled]):hover {
+    border-color: var(--color-neutral-border-bold);
+  }
+  
+
+  &:not([disabled]):active {
+    border-color: var(--color-neutral-border-bolder);
+  }
+}
+
+.select-ls {
+  &:not(.disabled):hover {
+    border-color: var(--color-neutral-border-bold);
+  }
+
+  &:not(.disabled):active {
+    border-color: var(--color-neutral-border-bolder);
+  }
+}
+
+input.input-ls[type="radio"] {
+  width: 16px;
+  height: 16px;
+  min-height: 0;
+}
+
+.textarea-ls {
+  padding: 12px 16px;
+  min-height: 50px;
+}

+ 70 - 0
web/apps/labelstudio/src/components/Form/Elements/Label/Label.jsx

@@ -0,0 +1,70 @@
+import { createElement } from "react";
+import { IconInfoOutline } from "@humansignal/icons";
+import { Tooltip } from "@humansignal/ui";
+import { cn } from "../../../../utils/bem";
+import "./Label.scss";
+import { clsx } from "clsx";
+/** @deprecated - needs to be replaced with @humansignal/ui Label - visualizes differently currently */
+const Label = ({
+  text,
+  children,
+  required,
+  placement,
+  description,
+  footer,
+  size,
+  large,
+  style,
+  simple,
+  flat,
+  className,
+  tooltip,
+  tooltipIcon,
+}) => {
+  const rootClass = cn("label-ls");
+  const classList = [rootClass.toClassName()];
+  const tagName = simple ? "div" : "label";
+  const mods = {
+    size,
+    large,
+    flat,
+    placement,
+    withDescription: !!description,
+    withFooter: !!footer,
+    empty: !children,
+  };
+
+  classList.push(rootClass.mod(mods).toClassName());
+  const rootProps = {
+    className: clsx(classList, className),
+    style: style,
+  };
+
+  if (required) {
+    rootProps["data-required"] = true;
+  }
+
+  return createElement(
+    tagName,
+    rootProps,
+    <>
+      <div className={rootClass.elem("text")}>
+        <div className={rootClass.elem("content")}>
+          <div className={rootClass.elem("label")}>
+            <span>{text}</span>
+            {tooltip && (
+              <div className={rootClass.elem("tooltip")}>
+                <Tooltip title={tooltip}>{tooltipIcon ? tooltipIcon : <IconInfoOutline />}</Tooltip>
+              </div>
+            )}
+          </div>
+          {description && <div className={rootClass.elem("description")}>{description}</div>}
+        </div>
+      </div>
+      <div className={rootClass.elem("field")}>{children}</div>
+      {footer && <div className={rootClass.elem("footer")}>{footer}</div>}
+    </>,
+  );
+};
+
+export default Label;

+ 185 - 0
web/apps/labelstudio/src/components/Form/Elements/Label/Label.scss

@@ -0,0 +1,185 @@
+.label-ls {
+  margin-bottom: 0;
+  color: var(--color-neutral-content);
+
+  &__text {
+    display: flex;
+    margin-bottom: 0;
+    font-size: 14px;
+    line-height: 1.25rem;
+  }
+
+  &__description {
+    font-size: 0.875rem;
+    color: var(--color-neutral-content-subtler);
+    font-weight: 400;
+    line-height: 140%;
+    white-space: pre-line;  // enable \n support in description texts
+
+    a {
+      color: var(--color-primary-content);
+      text-decoration: underline;
+
+      &:hover {
+        text-decoration: none;
+        color: var(--color-primary-content-hover);
+      }
+    }
+  }
+
+  &__footer {
+    font-size: var(--font-size-14);
+    color: var(--color-neutral-content-subtler);
+    font-weight: 400;
+    line-height: 140%;
+    white-space: pre-line;  // enable \n support in footer texts
+    margin-top: var(--spacing-tightest);
+
+    a {
+      color: var(--color-primary-content);
+      text-decoration: underline;
+
+      &:hover {
+        text-decoration: none;
+        color: var(--color-primary-content-hover);
+      }
+    }
+  }
+
+  &__field {
+    line-height: 0;
+  }
+
+  &__label {
+    display: flex;
+    align-items: center;
+    gap: 4px;
+    white-space: pre-line;  // enable \n support in label texts
+    margin-bottom: var(--spacing-tightest);
+  }
+
+  &__tooltip {
+    display: flex;
+    align-items: center;
+    height: 20px;
+    width: 20px;
+    font-size: 0;
+    color: var(--color-primary-content);
+    margin-left: 4px;
+
+    svg {
+      height: 100%;
+      width: 100%;
+      fill: var(--color-neutral-icon);
+    }
+  }
+
+  &_size_large &__text {
+    font-weight: 500;
+    font-size: 0.875rem;
+  }
+
+  &_flat &__text {
+    padding: 0;
+  }
+
+  .input-ls,
+  .select-ls,
+  .textarea-ls {
+    width: 100%;
+  }
+
+  &[data-required] &__text span::after {
+    content: "Required";
+    font-size: 0.825rem;
+    color: var(--color-neutral-content-subtler);
+    margin-left: 0.325rem;
+  }
+
+  &_large &__text {
+    font-size: 1.25rem;
+    font-weight: 500;
+  }
+
+  &_placement_right,
+  &_placement_left {
+    display: grid;
+    gap: 8px;
+    row-gap: 0;
+  }
+
+  &_placement_right {
+    grid-template-areas: "field label" "field description";
+    grid-template-columns: auto 1fr;
+  }
+
+  &_placement_left {
+    grid-template-areas: "label field" "description field";
+    grid-template-columns: 1fr auto;
+  }
+
+  &_placement_left:not(.label-ls_withDescription, .label-ls_withFooter) {
+    grid-template-areas: "label field";
+  }
+
+  &_placement_right:not(.label-ls_withDescription, .label-ls_withFooter) {
+    grid-template-areas: "field label";
+  }
+
+  &_placement_left.label-ls_withDescription.label-ls_withFooter {
+    grid-template-areas: "label field" "description field" "footer footer";
+  }
+
+  &_placement_right.label-ls_withDescription.label-ls_withFooter {
+    grid-template-areas: "field label" "field description" "footer footer";
+  }
+
+  &_placement_left.label-ls_withFooter:not(.label-ls_withDescription) {
+    grid-template-areas: "label field" "footer footer";
+  }
+
+  &_placement_right.label-ls_withFooter:not(.label-ls_withDescription) {
+    grid-template-areas: "field label" "footer footer";
+  }
+
+  &_empty &__text,
+  &_placement_right &__text,
+  &_placement_left &__text {
+    grid-area: label;
+    margin-bottom: 0;
+    line-height: 22px;
+    height: auto;
+    align-items: center;
+    font-weight: 500;
+  }
+
+  &_placement_right:not(.label-ls_withDescription) &__field,
+  &_placement_left:not(.label-ls_withDescription) &__field {
+    grid-area: field;
+    display: flex;
+    align-items: center;
+  }
+
+  &_placement_right &__description,
+  &_placement_left &__description {
+    grid-area: description;
+  }
+
+  &_placement_right &__footer,
+  &_placement_left &__footer {
+    grid-area: footer;
+  }
+
+  &_placement_right.label-ls_withDescription &__field {
+    input[type="radio"] {
+      margin: 4px 0 0;
+    }
+  }
+}
+
+label {
+  font-weight: 500;
+  font-size: 0.875rem;
+  margin-bottom: 4px;
+  display: block;
+}

+ 114 - 0
web/apps/labelstudio/src/components/Form/Elements/RadioGroup/RadioGroup.jsx

@@ -0,0 +1,114 @@
+import { createContext, useCallback, useContext, useEffect, useState } from "react";
+import { Label } from "..";
+import { cn } from "../../../../utils/bem";
+import { FormField } from "../../FormField";
+import "./RadioGroup.scss";
+
+const RadioContext = createContext();
+
+export const RadioGroup = ({
+  label,
+  className,
+  validate,
+  required,
+  skip,
+  simple,
+  labelProps,
+  size,
+  value,
+  onChange,
+  children,
+  ...props
+}) => {
+  const [currentValue, setCurrentValue] = useState(value);
+
+  const onRadioChange = (value) => {
+    setCurrentValue(value);
+    onChange?.(value);
+  };
+
+  const field = (
+    <FormField
+      name={props.name}
+      label={label}
+      validate={validate}
+      required={required}
+      skip={skip}
+      setValue={(value) => setCurrentValue(value)}
+      {...props}
+    >
+      {(ref, dep, form) => (
+        <RadioContext.Provider
+          value={{
+            value: currentValue,
+            onChange: (value) => {
+              onRadioChange(value);
+              form.autosubmit();
+            },
+            setValue: setCurrentValue,
+            isSimple: simple === true,
+          }}
+        >
+          <div className={cn("radio-group-ls").mod({ size, simple }).mix(className).toClassName()}>
+            <input ref={ref} name={props.name} type="hidden" defaultValue={currentValue} />
+            <div className={cn("radio-group-ls").elem("buttons").toClassName()}>{children}</div>
+          </div>
+        </RadioContext.Provider>
+      )}
+    </FormField>
+  );
+
+  return label ? (
+    <Label {...(labelProps ?? {})} text={label} simple={simple} required={required}>
+      {field}
+    </Label>
+  ) : (
+    field
+  );
+};
+
+const RadioButton = ({ value, disabled, children, label, description, ...props }) => {
+  const { onChange, setValue, value: currentValue, isSimple } = useContext(RadioContext);
+  const checked = value === currentValue;
+
+  const clickHandler = useCallback(
+    (e) => {
+      // TODO: Find a better way to prevent the click event from being triggered by the child element
+      // that works beyond just the anchor tag. Otherwise there will be problems with other components/elements.
+      if (e.target.tagName === "A") return;
+      e.preventDefault();
+      e.stopPropagation();
+      if (disabled) return;
+      onChange(value);
+    },
+    [value, disabled],
+  );
+
+  useEffect(() => {
+    if (props.checked) setValue(value);
+  }, [props.checked]);
+
+  return (
+    <div
+      className={cn("radio-group-ls").elem("button").mod({ checked, disabled }).toClassName()}
+      onClickCapture={clickHandler}
+    >
+      {isSimple ? (
+        <Label placement="right" text={label} description={description}>
+          <input
+            type="radio"
+            value={value}
+            checked={checked}
+            disabled={disabled}
+            readOnly
+            style={{ pointerEvents: "none" }}
+          />
+        </Label>
+      ) : (
+        children
+      )}
+    </div>
+  );
+};
+
+RadioGroup.Button = RadioButton;

+ 159 - 0
web/apps/labelstudio/src/components/Form/Elements/RadioGroup/RadioGroup.scss

@@ -0,0 +1,159 @@
+.radio-group-ls {
+  --radius: 8px;
+  --padding: 4px;
+  --font-size: 16px;
+  --button-padding: 0 10px;
+  --button-checked-shadow: 0 1px 0 rgb(0 0 0 / 10%), 0 0 0 1px rgb(0 0 0 / 2%), 0 5px 10px rgb(0 0 0 / 15%);
+
+  border-radius: var(--radius);
+  padding: var(--padding);
+  background: var(--color-neutral-surface);
+  border: 1px solid var(--color-neutral-border);
+  box-sizing: border-box;
+
+  &__buttons {
+    height: calc(var(--height) - calc(var(--padding) * 2));
+    display: grid;
+    grid-auto-columns: 1fr; 
+    grid-auto-flow: column;
+    gap: 2px;
+  }
+
+  .radio-group-ls_horizontal &__buttons {
+    display: grid;
+    grid-auto-columns: min-content;
+    column-gap: 16px;
+    align-items: center;
+    grid-auto-flow: column;
+    margin: 0;
+  }
+
+  .radio-group-ls_simple &__buttons {
+    all: unset;
+    display: inline-block;
+    margin-bottom: 16px;
+  }
+
+  & .label-ls__text {
+    font-weight: 500;
+
+    &::before {
+      content: '';
+      background: var(--color-neutral-background);
+      border-radius: 100%;
+      border: 1px solid var(--color-neutral-border);
+      display: inline-block;
+      width: 1.4em;
+      height: 1.4em;
+      position: relative;
+      top: -10px;
+      margin-right: 0.25rem;
+      vertical-align: top;
+      cursor: pointer;
+      text-align: center;
+      transition: all 300ms ease-out;
+      box-shadow: inset 0 0 0 8px var(--color-neutral-background);
+    }
+
+    &:hover {
+      &::before {
+        border-color: var(--color-neutral-border-bold);
+      }
+    }
+  }
+
+  & input[type="radio"] {
+    display: none;
+  }
+
+  &__button {
+    display: flex;
+    opacity: 0.6;
+    padding: var(--button-padding);
+    cursor: pointer;
+    font-weight: 500;
+    position: relative;
+    text-align: center;
+    align-items: center;
+    justify-content: center;
+    font-size: var(--font-size);
+    border-radius: 4px;
+    height: calc(var(--height) - calc(var(--padding) * 2));
+    transition: all 150ms ease-out;
+
+    &:hover {
+      opacity: 1;
+
+      & .label-ls__text {
+        &::before {
+          box-shadow: inset 0 0 0 4px var(--color-neutral-background);
+        }
+      }
+    }
+
+    &_checked {
+      opacity: 1;
+      background-color: var(--color-neutral-surface-hover);
+      box-shadow: var(--button-checked-shadow);
+
+      & .label-ls__text {
+        &::before {
+          background-color: var(--color-primary-surface);
+          box-shadow: inset 0 0 0 4px var(--color-neutral-background);
+        }
+      }
+    }
+
+    &_disabled {
+      opacity: 0.3;
+      cursor: not-allowed;
+    }
+  }
+
+  .radio-group-ls_horizontal &__button {
+    margin: 0;
+  }
+
+  .radio-group-ls_simple &__button {
+    all: unset;
+    display: block;
+    margin-bottom: 0.5rem !important;
+  }
+
+  &__input {
+    top: 0;
+    left: 0;
+    opacity: 0;
+    width: 100%;
+    height: 100%;
+    position: absolute;
+  }
+
+  &_size {
+    &_large {
+      --height: 40px;
+      --radius: 8px;
+    }
+
+    &_compact {
+      --height: 32px;
+      --radius: 8px;
+    }
+
+    &_small {
+      --height: 24px;
+      --radius: 4px;
+      --padding: 2px;
+      --font-size: 12px;
+      --button-padding: 0 5px;
+      --button-checked-shadow: 0 1px 0 rgb(0 0 0 / 10%), 0 0 0 1px rgb(0 0 0 / 2%), 0 2px 4px rgb(0 0 0 / 15%);
+    }
+  }
+
+  &_simple {
+    --height: auto;
+
+    all: unset;
+    display: block;
+  }
+}

+ 80 - 0
web/apps/labelstudio/src/components/Form/Elements/Select/Select.jsx

@@ -0,0 +1,80 @@
+import { useEffect, useMemo, useState } from "react";
+import { cn } from "../../../../utils/bem";
+import { FormField } from "../../FormField";
+import { default as Label } from "../Label/Label";
+import { Select as SelectUI } from "@humansignal/ui";
+
+const SelectOption = ({ value, label, disabled = false, hidden = false, ...props }) => {
+  return (
+    <option value={value} disabled={disabled} hidden={hidden} {...props}>
+      {label ?? value}
+    </option>
+  );
+};
+
+const Select = ({ label, className, options, validate, required, skip, labelProps, groupProps, ghost, ...props }) => {
+  const rootClass = cn("select-ls");
+  const initialValue = useMemo(() => props.value ?? "", [props.value]);
+  const [value, setValue] = useState(initialValue);
+
+  const grouped = options.reduce((groupedOptions, option) => {
+    const key = option.group || "NoGroup"; // fallback group for items without a group property
+
+    (groupedOptions[key] = groupedOptions[key] || []).push(option);
+    return groupedOptions;
+  }, {});
+
+  const classList = rootClass.mod({ ghost }).mix(className);
+
+  useEffect(() => {
+    setValue(initialValue);
+  }, [initialValue]);
+
+  const selectOptions = useMemo(() => {
+    return Object.keys(grouped).flatMap((group) => {
+      return group === "NoGroup"
+        ? grouped[group]
+        : (grouped[group] = {
+            label: group,
+            children: grouped[group],
+          });
+    });
+  }, [grouped]);
+
+  const selectWrapper = (
+    <FormField
+      name={props.name}
+      label={label}
+      validate={validate}
+      required={required}
+      skip={skip}
+      setValue={(val) => setValue(val)}
+      {...props}
+    >
+      {(ref) => {
+        return (
+          <SelectUI
+            {...props}
+            value={value}
+            onChange={(val) => {
+              setValue(val);
+              props.onChange?.(val);
+            }}
+            ref={ref}
+            options={selectOptions}
+          />
+        );
+      }}
+    </FormField>
+  );
+
+  return label ? (
+    <Label {...(labelProps ?? {})} text={label} required={required}>
+      {selectWrapper}
+    </Label>
+  ) : (
+    selectWrapper
+  );
+};
+
+export default Select;

+ 23 - 0
web/apps/labelstudio/src/components/Form/Elements/TextArea/TextArea.jsx

@@ -0,0 +1,23 @@
+import { cn } from "../../../../utils/bem";
+import { FormField } from "../../FormField";
+import { default as Label } from "../Label/Label";
+
+const TextArea = ({ label, className, validate, required, skip, labelProps, ...props }) => {
+  const classList = [cn("textarea-ls"), className].join(" ").trim();
+
+  const input = (
+    <FormField label={label} name={props.name} validate={validate} required={required} skip={skip} {...props}>
+      {(ref) => <textarea {...props} ref={ref} className={classList} />}
+    </FormField>
+  );
+
+  return label ? (
+    <Label {...(labelProps ?? {})} text={label} required={required}>
+      {input}
+    </Label>
+  ) : (
+    input
+  );
+};
+
+export default TextArea;

+ 71 - 0
web/apps/labelstudio/src/components/Form/Elements/Toggle/Toggle.jsx

@@ -0,0 +1,71 @@
+import { forwardRef, useEffect, useMemo } from "react";
+import { Toggle as UiToggle } from "@humansignal/ui";
+import { FormField } from "../../FormField";
+import { useValueTracker } from "../../Utils";
+import { default as Label } from "../Label/Label";
+
+const Toggle = forwardRef(
+  (
+    {
+      className,
+      label,
+      labelProps,
+      description,
+      checked,
+      defaultChecked,
+      onChange,
+      validate,
+      required,
+      skip,
+      ...props
+    },
+    ref,
+  ) => {
+    const initialChecked = useMemo(() => defaultChecked ?? checked ?? false, [defaultChecked, checked]);
+    const [isChecked, setIsChecked] = useValueTracker(checked, defaultChecked ?? false);
+
+    useEffect(() => {
+      setIsChecked(initialChecked);
+    }, [initialChecked]);
+
+    const formField = (
+      <FormField
+        ref={label ? null : ref}
+        label={label}
+        name={props.name}
+        validate={validate}
+        required={required}
+        skip={skip}
+        setValue={(value) => setIsChecked(value)}
+        {...props}
+      >
+        {(ref) => (
+          <UiToggle
+            ref={ref}
+            {...props}
+            checked={isChecked}
+            onChange={(e) => {
+              setIsChecked(e.target.checked);
+              onChange?.(e);
+            }}
+          />
+        )}
+      </FormField>
+    );
+    return label ? (
+      <Label
+        ref={ref}
+        placement="right"
+        required={required}
+        text={label}
+        children={formField}
+        description={description}
+        {...(labelProps ?? {})}
+      />
+    ) : (
+      formField
+    );
+  },
+);
+
+export default Toggle;

+ 6 - 0
web/apps/labelstudio/src/components/Form/Elements/index.ts

@@ -0,0 +1,6 @@
+export { default as Counter } from "./Counter/Counter";
+export { default as Input } from "./Input/Input";
+export { default as Label } from "./Label/Label";
+export { default as Select } from "./Select/Select";
+export { default as TextArea } from "./TextArea/TextArea";
+export { default as Toggle } from "./Toggle/Toggle";

+ 573 - 0
web/apps/labelstudio/src/components/Form/Form.jsx

@@ -0,0 +1,573 @@
+import React, { useCallback, useContext, useEffect, useRef, useState } from "react";
+import { shallowEqualObjects } from "shallow-equal";
+import { ApiProvider } from "../../providers/ApiProvider";
+import { MultiProvider } from "../../providers/MultiProvider";
+import { cn } from "../../utils/bem";
+import { debounce } from "../../utils/debounce";
+import { isDefined, objectClean } from "../../utils/helpers";
+import { Button } from "@humansignal/ui";
+import { Oneof } from "../Oneof/Oneof";
+import { Space } from "../Space/Space";
+import { Counter, Input, Select, Toggle } from "./Elements";
+import "./Form.scss";
+import {
+  FormContext,
+  FormResponseContext,
+  FormStateContext,
+  FormSubmissionContext,
+  FormValidationContext,
+} from "./FormContext";
+import * as Validators from "./Validation/Validators";
+import { ToastProvider, ToastViewport } from "@humansignal/ui";
+
+const PASSWORD_PROTECTED_VALUE = "got ya, suspicious hacker!";
+
+export default class Form extends React.Component {
+  state = {
+    validation: null,
+    showValidation: true,
+    submitting: false,
+  };
+
+  /**@type {import('react').RefObject<HTMLFormElement>} */
+  formElement = React.createRef();
+
+  apiRef = React.createRef();
+
+  /**@type {Set<HTMLInputElement|HTMLSelectElement>} */
+  fields = new Set();
+
+  validation = new Map();
+
+  get api() {
+    return this.apiRef.current;
+  }
+
+  componentDidMount() {
+    if (this.props.formData) {
+      setTimeout(() => {
+        this.fillFormData();
+      }, 50);
+    }
+  }
+
+  componentDidUpdate(prevProps) {
+    const equal = shallowEqualObjects(prevProps.formData ?? {}, this.props.formData ?? {});
+
+    if (!equal) {
+      this.fillFormData();
+    }
+  }
+
+  render() {
+    const providers = [
+      <FormContext.Provider key="form-ctx" value={this} />,
+      <FormValidationContext.Provider key="form-validation-ctx" value={this.state.validation} />,
+      <FormSubmissionContext.Provider key="form-submission-ctx" value={this.state.submitting} />,
+      <FormStateContext.Provider key="form-state-ctx" value={this.state.state} />,
+      <FormResponseContext.Provider key="form-response" value={this.state.lastResponse} />,
+      <ToastProvider key="toast" />,
+      <ApiProvider key="form-api" ref={this.apiRef} />,
+    ];
+
+    return (
+      <MultiProvider providers={providers}>
+        <form
+          ref={this.formElement}
+          className={cn("form")}
+          action={this.props.action}
+          onSubmit={this.onFormSubmitted}
+          onChange={this.onFormChanged}
+          autoComplete={this.props.autoComplete}
+          autoSave={this.props.autoSave}
+          style={this.props.style}
+        >
+          {this.props.children}
+
+          {this.state.validation && this.state.showValidation && (
+            <ValidationRenderer validation={this.state.validation} />
+          )}
+        </form>
+        <ToastViewport />
+      </MultiProvider>
+    );
+  }
+
+  registerField(field) {
+    const existingField = this.getFieldContext(field.name);
+
+    if (!existingField) {
+      this.fields.add(field);
+
+      setTimeout(() => {
+        this.fillWithFormData(field);
+      }, 0);
+    } else {
+      Object.assign(existingField, field);
+    }
+  }
+
+  unregisterField(name) {
+    const field = this.getFieldContext(name);
+
+    if (field) this.fields.delete(field);
+  }
+
+  getField(name) {
+    return this.getFieldContext(name)?.field;
+  }
+
+  getFieldContext(name) {
+    return Array.from(this.fields).find((f) => f.name === name);
+  }
+
+  disableValidationMessage() {
+    this.setState({ showValidation: false });
+  }
+
+  enableValidationMessage() {
+    this.setState({ showValidation: true });
+  }
+
+  onFormSubmitted = async (e) => {
+    e.preventDefault();
+
+    this.validateFields();
+
+    if (!this.validation.size) {
+      this.setState({ step: "submitting" });
+      this.submit();
+    } else {
+      this.setState({ step: "invalid" });
+    }
+  };
+
+  _onAutoSubmit = () => {
+    this.validateFields();
+
+    if (!this.validation.size) {
+      this.submit();
+    }
+  };
+
+  onAutoSubmit = this.props.debounce ? debounce(this._onAutoSubmit, this.props.debounce) : this._onAutoSubmit;
+
+  onFormChanged = async (e) => {
+    e.stopPropagation();
+
+    this.props.onChange?.(e);
+
+    this.autosubmit();
+  };
+
+  autosubmit() {
+    if (this.props.autosubmit) {
+      setTimeout(() => {
+        this.onAutoSubmit();
+      }, 100);
+    }
+  }
+
+  assembleFormData({ asJSON = false, full = false, booleansAsNumbers = false, fieldsFilter } = {}) {
+    let fields = Array.from(this.fields);
+
+    if (fieldsFilter instanceof Function) {
+      fields = fields.filter(fieldsFilter);
+    }
+
+    const requestBody = fields.reduce((res, { name, field, skip, allowEmpty, isProtected }) => {
+      const skipProtected = isProtected && field.value === PASSWORD_PROTECTED_VALUE;
+      const skipField = skip || skipProtected || ((this.props.skipEmpty || allowEmpty === false) && !field.value);
+
+      if (full === true || !skipField) {
+        const value = (() => {
+          const inputValue = field.value;
+          if (["checkbox", "radio"].includes(field.type)) {
+            if (isDefined(inputValue) && !["", "on", "off", "true", "false"].includes(inputValue)) {
+              return field.checked ? inputValue : null;
+            }
+
+            return booleansAsNumbers ? Number(field.checked) : field.checked;
+          }
+
+          return inputValue;
+        })();
+
+        if (value !== null) res.push([name, value]);
+      }
+
+      return res;
+    }, []);
+
+    if (asJSON) {
+      return requestBody.reduce((res, [key, value]) => ({ ...res, [key]: value }), {});
+    }
+    const formData = new FormData();
+
+    requestBody.forEach(([key, value]) => formData.append(key, value));
+    return formData;
+  }
+
+  async submit({ fieldsFilter } = {}) {
+    this.setState({ submitting: true, lastResponse: null });
+
+    const rawAction = this.formElement.current.getAttribute("action");
+    const useApi = this.api.isValidMethod(rawAction);
+    const data = this.assembleFormData({ asJSON: useApi, fieldsFilter });
+    const body = this.props.prepareData?.(data) ?? data;
+    let success = false;
+
+    if (useApi) {
+      success = await this.submitWithAPI(rawAction, body);
+    } else {
+      success = await this.submitWithFetch(body);
+    }
+
+    this.setState(
+      {
+        submitting: false,
+        state: success ? "success" : "fail",
+      },
+      () => {
+        setTimeout(() => {
+          this.setState({ state: null });
+        }, 1500);
+      },
+    );
+  }
+
+  async submitWithAPI(action, body) {
+    const urlParams = objectClean(this.props.params ?? {});
+    const response = await this.api.callApi(action, {
+      params: urlParams,
+      body,
+    });
+
+    this.setState({ lastResponse: response });
+
+    if (!response?.$meta?.ok) {
+      this.props.onError?.();
+      return false;
+    }
+    this.props.onSubmit?.(response);
+    return true;
+  }
+
+  async submitWithFetch(body) {
+    const action = this.formElement.current.action;
+    const method = (this.props.method ?? "POST").toUpperCase();
+    const response = await fetch(action, { method, body });
+
+    try {
+      const result = await response.json();
+
+      this.setState({ lastResponse: result });
+
+      if (result.validation_errors) {
+        Object.entries(result.validation_errors).forEach(([key, messages]) => {
+          const field = this.getField(key);
+
+          this.validation.set(field.name, {
+            label: field.label,
+            field: field.field,
+            messages,
+          });
+        });
+
+        this.setState({ validation: this.validation });
+      }
+
+      if (response.ok) {
+        this.props.onSubmit?.(result);
+        return true;
+      }
+      this.props.onError?.(result);
+    } catch (err) {
+      console.log(err);
+      this.props.onError?.(err);
+    }
+    return false;
+  }
+
+  validateFields() {
+    this.validation.clear();
+
+    for (const field of this.fields) {
+      const result = this.validateField(field);
+
+      if (result.length) {
+        this.validation.set(field.name, {
+          label: field.label,
+          messages: result,
+          field: field.field,
+        });
+      }
+    }
+
+    if (this.validation.size) {
+      this.setState({ validation: this.validation });
+    } else {
+      this.setState({ validation: null });
+    }
+
+    return this.validation.size === 0;
+  }
+
+  validateField(field) {
+    const messages = [];
+    const { validation, field: element } = field;
+    const value = element.value?.trim() || null;
+
+    if (field.isProtected && value === PASSWORD_PROTECTED_VALUE) {
+      return messages;
+    }
+
+    validation.forEach((validator) => {
+      const result = validator(field.label, value);
+
+      if (result) messages.push(result);
+    });
+
+    return messages;
+  }
+
+  fillFormData() {
+    if (!this.props.formData) return;
+    if (this.fields.size === 0) return;
+
+    Array.from(this.fields).forEach((field) => {
+      this.fillWithFormData(field);
+    });
+  }
+
+  fillWithFormData(field) {
+    const value = (this.props.formData ?? {})[field.name];
+
+    if (field.isProtected && this.props.formData) {
+      field.setValue(PASSWORD_PROTECTED_VALUE);
+    } else if (isDefined(value) && field.value !== value && !field.skipAutofill) {
+      field.setValue(value);
+    }
+  }
+}
+
+const ValidationRenderer = ({ validation }) => {
+  const rootClass = cn("form-validation");
+
+  return (
+    <div className={rootClass}>
+      {Array.from(validation).map(([name, result]) => (
+        <div key={name} className={rootClass.elem("group")} onClick={() => result.field.focus()}>
+          <div className={rootClass.elem("field")}>{result.label}</div>
+
+          <div className={rootClass.elem("messages")}>
+            {result.messages.map((message, i) => (
+              <div key={`${name}-${i}`} className={rootClass.elem("message")}>
+                {message}
+              </div>
+            ))}
+          </div>
+        </div>
+      ))}
+    </div>
+  );
+};
+
+Form.Validator = Validators;
+
+Form.Row = ({ columnCount, rowGap, children, style, spread = false }) => {
+  const styles = {};
+
+  if (columnCount) styles["--column-count"] = columnCount;
+  if (rowGap) styles["--row-gap"] = rowGap;
+
+  return (
+    <div className={cn("form").elem("row").mod({ spread })} style={{ ...(style ?? {}), ...styles }}>
+      {children}
+    </div>
+  );
+};
+
+Form.Builder = React.forwardRef(
+  (
+    {
+      fields: defaultFields,
+      formData: defaultFormData,
+      fetchFields,
+      fetchFormData,
+      children,
+      formRowStyle,
+      onSubmit,
+      withActions,
+      ...props
+    },
+    ref,
+  ) => {
+    const formRef = ref ?? useRef();
+    const [fields, setFields] = useState(defaultFields ?? []);
+    const [formData, setFormData] = useState(defaultFormData ?? {});
+
+    const renderFields = (fields) => {
+      return fields.map((field, index) => {
+        if (!field) return <div key={`spacer-${index}`} />;
+
+        const currentValue = formData?.[field.name] ?? undefined;
+        const triggerUpdate = props.autosubmit !== true && field.trigger_form_update === true;
+        const getValue = () => {
+          const isProtected =
+            field.skipAutofill && (!field.allowEmpty || field.protectedValue) && field.type === "password";
+
+          if (isProtected) {
+            return PASSWORD_PROTECTED_VALUE;
+          }
+
+          if (field.skipAutofill) {
+            return null;
+          }
+
+          return currentValue ?? field.value;
+        };
+
+        const commonProps = {};
+
+        if (triggerUpdate) {
+          commonProps.onChange = async () => {
+            await formRef.current.submit({
+              fieldsFilter: (f) => f.name === field.name,
+            });
+            await updateFields();
+            await updateFormData();
+          };
+        }
+
+        const InputComponent = (() => {
+          switch (field.type) {
+            case "select":
+              return Select;
+            case "counter":
+              return Counter;
+            case "toggle":
+              return Toggle;
+            default:
+              return Input;
+          }
+        })();
+
+        if (["checkbox", "radio", "toggle"].includes(field.type)) {
+          commonProps.checked = getValue();
+        } else if (field.type === "counter") {
+          commonProps.value = getValue();
+        } else {
+          commonProps.defaultValue = getValue();
+        }
+
+        return <InputComponent key={field.name ?? index} {...field} {...commonProps} />;
+      });
+    };
+
+    const renderColumns = (columns) => {
+      return columns.map((col, index) => (
+        <div className={cn("form").elem("column")} key={index} style={{ width: col.width }}>
+          {renderFields(col.fields)}
+        </div>
+      ));
+    };
+
+    const updateFields = useCallback(async () => {
+      if (fetchFields) {
+        const newFields = await fetchFields();
+
+        if (JSON.stringify(fields) !== JSON.stringify(newFields)) {
+          setFields(newFields);
+        }
+      }
+    }, [fetchFields]);
+
+    const updateFormData = useCallback(async () => {
+      if (fetchFormData) {
+        const newFormData = await fetchFormData();
+
+        if (shallowEqualObjects(formData, newFormData) === false) {
+          setFormData(newFormData);
+        }
+      }
+    }, [fetchFormData]);
+
+    const handleOnSubmit = useCallback(
+      async (...args) => {
+        onSubmit?.(...args);
+        await updateFields();
+        await updateFormData();
+      },
+      [onSubmit, fetchFormData],
+    );
+
+    useEffect(() => {
+      updateFields();
+    }, [updateFields]);
+
+    useEffect(() => {
+      updateFormData();
+    }, [updateFormData]);
+
+    useEffect(() => {
+      setFields(defaultFields);
+    }, [defaultFields]);
+
+    return (
+      <Form {...props} onSubmit={handleOnSubmit} ref={formRef}>
+        {(fields ?? []).map(({ columnCount, fields, columns }, index) => (
+          <Form.Row key={index} columnCount={columnCount} style={formRowStyle} spread>
+            {columns ? renderColumns(columns) : renderFields(fields)}
+          </Form.Row>
+        ))}
+        {children}
+        {props.autosubmit !== true && withActions === true && (
+          <Form.Actions>
+            <Button type="submit" className="w-[120px]" aria-label="Submit form">
+              Save
+            </Button>
+          </Form.Actions>
+        )}
+      </Form>
+    );
+  },
+);
+
+Form.Actions = ({ children, valid, extra, size }) => {
+  const rootClass = cn("form");
+
+  return (
+    <div className={rootClass.elem("submit").mod({ size })}>
+      <div className={rootClass.elem("info").mod({ valid })}>{extra}</div>
+
+      <Space>{children}</Space>
+    </div>
+  );
+};
+
+Form.Indicator = () => {
+  const state = React.useContext(FormStateContext);
+
+  return (
+    <div className={cn("form-indicator").toClassName()}>
+      <Oneof value={state}>
+        <span className={cn("form-indicator").elem("item").mod({ type: state }).toClassName()} case="success">
+          Saved!
+        </span>
+      </Oneof>
+    </div>
+  );
+};
+
+Form.ResponseParser = ({ children }) => {
+  const callback = children;
+
+  if (callback instanceof Function === false) {
+    throw new Error("Response Parser only accepts function as a child");
+  }
+
+  const response = useContext(FormResponseContext);
+
+  return <>{response ? callback(response) : null}</>;
+};

+ 98 - 0
web/apps/labelstudio/src/components/Form/Form.scss

@@ -0,0 +1,98 @@
+.form {
+  width: 100%;
+  display: block;
+
+  &__row {
+    display: grid;
+    justify-items: stretch;
+    justify-content: space-between;
+    grid-template-columns: repeat(var(--column-count, 5), 1fr);
+    grid-gap: var(--row-gap, 16px) 12px;
+
+    &:not(:first-child) {
+      margin-top: 20px;
+    }
+
+    &__description {
+      font-size: 0.875rem;
+    }
+  }
+
+  &__submit {
+    display: flex;
+    margin-top: 32px;
+    align-items: center;
+    justify-content: space-between;
+
+    & + .inline-error {
+      margin-top: 32px;
+    }
+
+    &_size {
+      &_small {
+        margin-top: 16px;
+      }
+    }
+  }
+
+  &__info {
+    display: flex;
+    align-items: center;
+    color: var(--color-negative-content);
+    font-size: 14px;
+    line-height: 22px;
+
+    &_valid {
+      color: var(--color-neutral-content-subtler);
+    }
+  }
+
+  &__column {
+    display: grid;
+    grid-auto-flow: column;
+    align-items: flex-start;
+  }
+}
+
+.input-ls,
+.textarea-ls,
+.counter,
+.select-ls__list {
+  transition: all 100ms ease-out;
+  outline: 0;
+  color: var(--color-neutral-content);
+
+  &:not(&_ghost):focus,
+  &:not(:read-only):focus,
+  &_focused {
+    outline: 4px solid var(--color-primary-focus-outline);
+
+    // box-shadow: 0 0 0 6px var(--color-primary-surface-content-subtle), inset 0 -1px 0 rgb(0 0 0 / 10%), inset 0 0 0 1px rgb(0 0 0 / 15%), inset 0 0 0 1px var(--color-primary-surface-content-subtle);
+    border-color: var(--color-neutral-border-bolder);
+  }
+
+  &:focus-visible {
+    outline: none;
+  }
+
+  &:read-only:focus {
+    box-shadow: none;
+    border-color: var(--border-color);
+  }
+}
+
+.form-indicator {
+  font-weight: 500;
+
+  &__item {
+    &_type {
+      &_success {
+        color: var(--color-positive-content);
+      }
+
+      &_fail {
+        color: var(--color-negative-content);
+      }
+    }
+  }
+}

+ 16 - 0
web/apps/labelstudio/src/components/Form/FormContext.js

@@ -0,0 +1,16 @@
+import { createContext } from "react";
+
+export const FormContext = createContext();
+FormContext.displayName = "FormContext";
+
+export const FormValidationContext = createContext();
+FormValidationContext.displayName = "FormValidationContext";
+
+export const FormSubmissionContext = createContext();
+FormSubmissionContext.displayName = "FormSubmissionContext";
+
+export const FormStateContext = createContext();
+FormStateContext.displayName = "FormStateContext";
+
+export const FormResponseContext = createContext();
+FormResponseContext.displayName = "FormResponseContext";

+ 114 - 0
web/apps/labelstudio/src/components/Form/FormField.js

@@ -0,0 +1,114 @@
+import { forwardRef, useCallback, useContext, useEffect, useRef, useState } from "react";
+import { isDefined } from "../../utils/helpers";
+import { FormContext } from "./FormContext";
+import * as Validators from "./Validation/Validators";
+
+export const FormField = forwardRef(
+  (
+    {
+      label,
+      name,
+      children,
+      required,
+      validate,
+      skip,
+      allowEmpty,
+      protectedValue,
+      skipAutofill,
+      setValue,
+      dependency,
+      validators,
+      ...props
+    },
+    ref,
+  ) => {
+    /**@type {Form} */
+    const context = useContext(FormContext);
+    const [dependencyField, setDependencyField] = useState(null);
+
+    const field = ref ?? useRef();
+
+    const validation = [...(validate ?? [])];
+
+    validators?.forEach?.((validator) => {
+      const [name, value] = validator.split(/:(.+)/).slice(0, 2);
+      const validatorFunc = Validators[name];
+
+      if (isDefined(validatorFunc)) {
+        if (isDefined(value)) {
+          validation.push(validatorFunc(value));
+        } else {
+          validation.push(validatorFunc);
+        }
+      }
+    });
+
+    if (required) validation.push(Validators.required);
+
+    useEffect(() => {
+      if (!context || !dependency) return;
+
+      let field = null;
+      const dep = context.getFieldContext(dependency);
+
+      const handler = () => {
+        props.onDependencyChanged?.(dep.field);
+      };
+
+      if (dep) {
+        dep.field.addEventListener("change", handler);
+        field = dep.field;
+      } else {
+        console.warn(`Dependency field not found ${dependency}`);
+      }
+
+      setDependencyField(field);
+      return () => dep.field.removeEventListener("change", handler);
+    }, [context, field, dependency]);
+
+    const setValueCallback = useCallback(
+      (value) => {
+        if (!field || !field.current) return;
+
+        /**@type {HTMLInputElement|HTMLTextAreaElement} */
+        const input = field.current;
+
+        if (setValue instanceof Function) {
+          setValue(value);
+        } else if (input.type === "checkbox" || input.type === "radio") {
+          input.checked = value ?? input.checked;
+        } else if (value === null) {
+          input.value = "";
+        } else {
+          input.value = value;
+        }
+
+        const evt = document.createEvent("HTMLEvents");
+
+        evt.initEvent("change", false, true);
+        input.dispatchEvent(evt);
+      },
+      [field],
+    );
+
+    useEffect(() => {
+      const isProtected = skipAutofill && (!allowEmpty || protectedValue) && field.current.type === "password";
+
+      context?.registerField({
+        label,
+        name,
+        validation,
+        skip,
+        allowEmpty,
+        skipAutofill,
+        isProtected,
+        protectedValue,
+        field: field.current,
+        setValue: setValueCallback,
+      });
+      return () => context?.unregisterField(name);
+    }, [field, setValueCallback]);
+
+    return children(field, dependencyField, context);
+  },
+);

+ 15 - 0
web/apps/labelstudio/src/components/Form/Utils.ts

@@ -0,0 +1,15 @@
+import { type Dispatch, type SetStateAction, useEffect, useMemo, useState } from "react";
+
+export const useValueTracker = <T>(value: T, defaultValue?: T): [T, Dispatch<SetStateAction<T>>] => {
+  const initialValue = useMemo(() => {
+    return (value ?? defaultValue ?? "") as T;
+  }, [value, defaultValue]);
+
+  const [finalValue, setValue] = useState<T>(initialValue);
+
+  useEffect(() => {
+    setValue(initialValue);
+  }, [initialValue]);
+
+  return [finalValue as T, setValue];
+};

+ 29 - 0
web/apps/labelstudio/src/components/Form/Validation/Validation.scss

@@ -0,0 +1,29 @@
+.form-validation {
+  margin-top: 32px;
+
+  &__group {
+    padding: 7px 14px;
+    border-radius: 5px;
+    background-color: var(--color-negative-background);
+    color: var(--color-negative-content);
+    border: 1px solid var(--color-negative-border-subtlest);
+
+    & + & {
+      margin-top: 5px;
+    }
+  }
+ 
+  &__field {
+    font-size: 14px;
+    font-weight: bold;
+  }
+
+  &__messages {
+    margin-top: 3px;
+  }
+
+  &__message {
+    font-size: 12px;
+    color: var(--color-neutral-content);
+  }
+}

+ 40 - 0
web/apps/labelstudio/src/components/Form/Validation/Validators.js

@@ -0,0 +1,40 @@
+import { isDefined, isEmptyString } from "../../../utils/helpers";
+import "./Validation.scss";
+
+export const required = (fieldName, value) => {
+  if (!isDefined(value) || isEmptyString(value)) {
+    return `${fieldName} is required`;
+  }
+};
+
+export const matchPattern = (pattern) => (fieldName, value) => {
+  pattern = typeof pattern === "string" ? new RegExp(pattern) : pattern;
+
+  if (!isEmptyString(value) && value.match(pattern) === null) {
+    return `${fieldName} must match the pattern ${pattern}`;
+  }
+};
+
+export const json = (fieldName, value) => {
+  const err = `${fieldName} must be valid JSON string`;
+
+  if (!isDefined(value) || value.trim().length === 0) return;
+
+  if (/^(\{|\[)/.test(value) === false || /(\}|\])$/.test(value) === false) {
+    return err;
+  }
+
+  try {
+    JSON.parse(value);
+  } catch (e) {
+    return err;
+  }
+};
+
+export const regexp = (fieldName, value) => {
+  try {
+    new RegExp(value);
+  } catch (err) {
+    return `${fieldName} must be a valid regular expression`;
+  }
+};

+ 2 - 0
web/apps/labelstudio/src/components/Form/index.js

@@ -0,0 +1,2 @@
+export * from "./Elements";
+export { default as Form } from "./Form";

+ 14 - 0
web/apps/labelstudio/src/components/Hamburger/Hamburger.jsx

@@ -0,0 +1,14 @@
+import { cn } from "../../utils/bem";
+import "./Hamburger.scss";
+
+export const Hamburger = ({ opened, animated = true }) => {
+  const root = cn("hamburger");
+
+  return (
+    <span className={root.mod({ animated, opened })}>
+      <span />
+      <span />
+      <span />
+    </span>
+  );
+};

+ 54 - 0
web/apps/labelstudio/src/components/Hamburger/Hamburger.scss

@@ -0,0 +1,54 @@
+.hamburger {
+  width: 18px;
+  height: 14px;
+  cursor: pointer;
+  position: relative;
+  display: inline-block;
+
+  span {
+    height: 2px;
+    width: 100%;
+    display: block;
+    position: absolute;
+    background-color: var(--color-neutral-content);
+
+    &:nth-child(1) {
+      top: 0;
+    }
+
+    &:nth-child(2) {
+      top: 50%;
+      transform: translate3d(0, -50%, 0);
+    }
+
+    &:nth-child(3) {
+      bottom: 0;
+    }
+  }
+
+  &_animated span {
+    transition: all var(--menu-animation-duration) ease;
+  }
+
+  &:hover span,
+  &_opened span {
+    background-color: var(--color-neutral-content);
+  }
+
+  &_opened span {
+    &:nth-child(1) {
+      top: 6px;
+      transform: rotate(135deg);
+    }
+
+    &:nth-child(2) {
+      opacity: 0;
+      transform: translate3d(-100%, -50%, 0);
+    }
+
+    &:nth-child(3) {
+      bottom: 6px;
+      transform: rotate(-135deg);
+    }
+  }
+}

+ 63 - 0
web/apps/labelstudio/src/components/HeidiTips/HeidiTip.scss

@@ -0,0 +1,63 @@
+.heidy-tip {
+  &__content {
+    padding: 1rem;
+    border: 1px solid var(--color-neutral-border);
+    font-size: 14px;
+    border-radius: 8px;
+    box-shadow: 0 2px 6px 0 rgb(0 0 0 / 10%);
+    box-sizing: border-box;
+    background: var(--color-neutral-surface);
+  }
+
+  &__header {
+    display: flex;
+    width: 100%;
+    min-height: 24px;
+    margin-bottom: 8px;
+    flex-direction: row;
+    justify-content: space-between;
+  }
+
+  &__title {
+    font-size: 16px;
+    font-weight: 500;
+    line-height: 24px;
+    letter-spacing: 0.15px;
+    color:  var(--color-neutral-content);
+  }
+
+  &__text {
+    line-height: 20px;
+    letter-spacing: 0.25px;
+    color:  var(--color-neutral-content-subtler);
+  }
+
+  &__link {
+    color: var(--color-primary-content);
+    font-weight: 500;
+    display: block;
+
+    &:hover {
+      color: var(--color-primary-content-hover);
+      text-decoration: underline;
+    }
+
+    &::before {
+      content: " ";
+    }
+  }
+
+  &__heidi {
+    margin-top: -12px;
+    padding-left: 16px;
+    pointer-events: none;
+    color: var(--color-neutral-border);
+
+    svg {
+      path.spike-stroke {
+        color: red;
+      }
+
+    }
+  }
+}

+ 59 - 0
web/apps/labelstudio/src/components/HeidiTips/HeidiTip.tsx

@@ -0,0 +1,59 @@
+import { type FC, type MouseEvent, useCallback, useMemo } from "react";
+import { cn } from "../../utils/bem";
+import { IconCross } from "@humansignal/icons";
+import "./HeidiTip.scss";
+import { Button } from "@humansignal/ui";
+import { HeidiSpeaking } from "../../assets/images";
+import type { HeidiTipProps, Tip } from "./types";
+import { createURL } from "./utils";
+
+const HeidiLink: FC<{ link: Tip["link"]; onClick: () => void }> = ({ link, onClick }) => {
+  const url = useMemo(() => {
+    const params = link.params ?? {};
+    /* if needed, add server ID here */
+
+    return createURL(link.url, params);
+  }, [link]);
+
+  return (
+    <a
+      className={cn("heidy-tip").elem("link").toClassName()}
+      href={url}
+      target="_blank"
+      onClick={onClick}
+      rel="noreferrer"
+    >
+      {link.label}
+    </a>
+  );
+};
+
+export const HeidiTip: FC<HeidiTipProps> = ({ tip, onDismiss, onLinkClick }) => {
+  const handleClick = useCallback((event: MouseEvent) => {
+    event.preventDefault();
+    event.stopPropagation();
+    onDismiss();
+  }, []);
+
+  return (
+    <div className={cn("heidy-tip").toClassName()}>
+      <div className={cn("heidy-tip").elem("content").toClassName()}>
+        <div className={cn("heidy-tip").elem("header").toClassName()}>
+          <div className={cn("heidy-tip").elem("title").toClassName()}>{tip.title}</div>
+          {tip.closable && (
+            <Button tooltip="Don't show" look="string" size="small" onClick={handleClick} className="!p-0">
+              <IconCross />
+            </Button>
+          )}
+        </div>
+        <div className={cn("heidy-tip").elem("text").toClassName()}>
+          {tip.content}
+          <HeidiLink link={tip.link} onClick={onLinkClick} />
+        </div>
+      </div>
+      <div className={cn("heidy-tip").elem("heidi").toClassName()}>
+        <HeidiSpeaking />
+      </div>
+    </div>
+  );
+};

+ 10 - 0
web/apps/labelstudio/src/components/HeidiTips/HeidiTips.tsx

@@ -0,0 +1,10 @@
+import { type FC, memo } from "react";
+import type { HeidiTipsProps } from "./types";
+import { HeidiTip } from "./HeidiTip";
+import { useRandomTip } from "./hooks";
+
+export const HeidiTips: FC<HeidiTipsProps> = memo(({ collection }) => {
+  const [tip, dismiss, onLinkClick] = useRandomTip(collection);
+
+  return tip && <HeidiTip tip={tip} onDismiss={dismiss} onLinkClick={onLinkClick} />;
+});

+ 270 - 0
web/apps/labelstudio/src/components/HeidiTips/content.ts

@@ -0,0 +1,270 @@
+import type { TipsCollection } from "./types";
+
+export const defaultTipsCollection: TipsCollection = {
+  projectCreation: [
+    {
+      title: "Did you know?",
+      content: "It’s easier to find the projects when you organize them into workspaces using Label Studio Enterprise.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/manage_projects#Create-workspaces-to-organize-projects",
+        params: {
+          experiment: "project_creation_tip",
+          treatment: "find_and_manage_projects",
+        },
+      },
+    },
+    {
+      title: "Unlock faster access provisioning",
+      content:
+        "Streamline assigning staff to multiple projects by assigning them to workspaces in Label Studio Enterprise.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/manage_projects#Add-or-remove-members-to-a-workspace",
+        params: {
+          experiment: "project_creation_tip",
+          treatment: "faster_provisioning",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content:
+        "In the Enterprise platform, admins can view annotator performance dashboards to optimize resource allocation, improve team management, and inform compensation.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/dashboard_annotator",
+        params: {
+          experiment: "project_creation_tip",
+          treatment: "annotator_dashboard",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content:
+        "You can control access to specific projects and workspaces for internal team members and external annotators using Label Studio Enterprise.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/manage_users#Roles-in-Label-Studio-Enterprise",
+        params: {
+          experiment: "project_creation_tip",
+          treatment: "access_to_projects",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content:
+        "You can use or modify dozens or templates to configure your labeling UI, or create a custom configuration from scratch using simple XML-like tag.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://labelstud.io/guide/setup",
+        params: {
+          experiment: "project_creation_tip",
+          treatment: "templates",
+        },
+      },
+    },
+    {
+      title: "Labeling for GenAI",
+      content:
+        "Label Studio has templates available for supervised LLM fine-tuning, RAG retrieval ranking, RLHF, chatbot evaluation, and more.",
+      closable: true,
+      link: {
+        label: "Explore templates",
+        url: "https://labelstud.io/templates/gallery_generative_ai",
+        params: {
+          experiment: "project_creation_tip",
+          treatment: "genai_templates",
+        },
+      },
+    },
+  ],
+  organizationPage: [
+    {
+      title: "It looks like your team is growing!",
+      content:
+        "Assign roles to your team using Label Studio Enterprise and control access to sensitive data at the project and workspace levels.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/manage_users#Roles-in-Label-Studio-Enterprise",
+        params: {
+          experiment: "organization_page_tip",
+          treatment: "team_growing",
+        },
+      },
+    },
+    {
+      title: "Want to simplify and secure logging in?",
+      content: "Enable Single Sign-On for your team using SAML, SCIM2 or LDAP with Label Studio Enterprise.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/auth_setup",
+        params: {
+          experiment: "organization_page_tip",
+          treatment: "enable_sso",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content: "Try Label Studio Starter Cloud, optimized for small teams and projects.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://humansignal.com/pricing/",
+        params: {
+          experiment: "organization_page_tip",
+          treatment: "starter_cloud_live",
+        },
+      },
+    },
+    {
+      title: "Want to automate task distribution?",
+      content:
+        "Create rules, automate how tasks are distributed to annotators, and only show tasks assigned to each annotator in their view.and control task visibility for each annotator.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/setup_project#Set-up-annotation-settings-for-your-project",
+        params: {
+          experiment: "organization_page_tip",
+          treatment: "automate_distribution",
+        },
+      },
+    },
+    {
+      title: "Share knowledge with the community",
+      content:
+        "Have questions or a tip to share with other Label Studio users? Join the community slack channel for the latest updates. ",
+      closable: true,
+      link: {
+        label: "Join the community",
+        url: "https://label-studio.slack.com",
+        params: {
+          experiment: "organization_page_tip",
+          treatment: "share_knowledge",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content:
+        "Label Studio supports multiple points of integration with cloud storage, machine learning models, and popular tools to automate your machine learning pipeline.",
+      closable: true,
+      link: {
+        label: "Check out the integrations directory",
+        url: "https://labelstud.io/integrations/",
+        params: {
+          experiment: "organization_page_tip",
+          treatment: "integration_points",
+        },
+      },
+    },
+  ],
+  projectSettings: [
+    {
+      title: "Apply your AWS spend to Label Studio Enterprise",
+      content:
+        "Label Studio Enterprise is now available on the AWS Marketplace so you can use your committed spend to streamline data labeling workflows.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://aws.amazon.com/marketplace/pp/prodview-wjac3msf77tny",
+        params: {
+          experiment: "project_settings_tip",
+          treatment: "aws_marketplace",
+        },
+      },
+    },
+    {
+      title: "Save time with Auto-Labeling",
+      content:
+        "Use automation to instantly label large-scale datasets without sacrificing quality in the Enterprise platform.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/prompts_overview#Auto-labeling-with-Prompts",
+        params: {
+          experiment: "project_settings_tip",
+          treatment: "auto_labeling",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content:
+        "You can increase the quality of your labeled data with reviewer workflows and task agreement scores using Label Studio Enterprise.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://docs.humansignal.com/guide/quality",
+        params: {
+          experiment: "project_settings_tip",
+          treatment: "quality_and_agreement",
+        },
+      },
+    },
+    {
+      title: "Evaluate GenAI models",
+      content:
+        "Combine automation plus human supervision to evaluate and ensure LLM quality in the Enterprise platform.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://humansignal.com/evals/",
+        params: {
+          experiment: "project_settings_tip",
+          treatment: "evals",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content:
+        "You can save time managing infrastructure and upgrades, plus access more features for automation, quality, and team management, by using the Enterprise cloud service.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://humansignal.com/platform/",
+        params: {
+          experiment: "project_settings_tip",
+          treatment: "infrastructure_and_upgrades",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content: "Try Label Studio Starter Cloud, optimized for small teams and projects.",
+      link: {
+        label: "Learn more",
+        url: "https://humansignal.com/pricing/",
+        params: {
+          experiment: "project_settings_tip",
+          treatment: "starter_cloud_live",
+        },
+      },
+    },
+    {
+      title: "Did you know?",
+      content: "You can connect ML models using the backend SDK to save time with pre-labeling or active learning.",
+      closable: true,
+      link: {
+        label: "Learn more",
+        url: "https://labelstud.io/guide/ml",
+        params: {
+          experiment: "project_settings_tip",
+          treatment: "connect_ml_models",
+        },
+      },
+    },
+  ],
+};

+ 19 - 0
web/apps/labelstudio/src/components/HeidiTips/hooks.ts

@@ -0,0 +1,19 @@
+import { useCallback, useState } from "react";
+import { dismissTip, getRandomTip, getTipEvent, getTipMetadata } from "./utils";
+import type { Tip, TipsCollection } from "./types";
+
+export const useRandomTip = (collection: keyof TipsCollection) => {
+  const [tip, setTip] = useState<Tip | null>(() => getRandomTip(collection));
+  const dismiss = useCallback(() => {
+    dismissTip(collection);
+    setTip(null);
+  }, []);
+
+  const onLinkClick = useCallback(() => {
+    if (tip) {
+      __lsa(getTipEvent(collection, tip, "click"), getTipMetadata(tip));
+    }
+  }, [tip]);
+
+  return [tip, dismiss, onLinkClick] as const;
+};

+ 288 - 0
web/apps/labelstudio/src/components/HeidiTips/liveContent.json

@@ -0,0 +1,288 @@
+{
+  "projectCreation": [
+    {
+      "title": "New Storage Connector",
+      "content": "You can connect Label Studio Enterprise to Databricks Unity Catalog (UC) Volumes to import files as tasks and export annotations.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://docs.humansignal.com/guide/storage.html#Databricks-Files-UC-Volumes",
+        "params": {
+          "experiment": "project_creation_tip",
+          "treatment": "databricks_uc_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "content": "Try Label Studio Starter Cloud, optimized for small teams and projects.",
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/pricing/",
+        "params": {
+          "experiment": "project_creation_tip",
+          "treatment": "starter_cloud_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "content": "You can control access to specific projects and workspaces for internal team members and external annotators using Label Studio Enterprise.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://docs.humansignal.com/guide/manage_users#Roles-in-Label-Studio-Enterprise",
+        "params": {
+          "experiment": "project_creation_tip",
+          "treatment": "access_to_projects_live"
+        }
+      }
+    },
+    {
+      "title": "Labeling for GenAI",
+      "content": "Label Studio has templates available for supervised LLM fine-tuning, RAG retrieval ranking, RLHF, chatbot evaluation, and more.",
+      "closable": true,
+      "link": {
+        "label": "Explore templates",
+        "url": "https://labelstud.io/templates/gallery_generative_ai",
+        "params": {
+          "experiment": "project_creation_tip",
+          "treatment": "genai_templates_live"
+        }
+      }
+    }
+  ],
+  "organizationPage": [
+    {
+      "title": "It looks like your team is growing!",
+      "content": "Assign roles to your team using Label Studio Enterprise and control access to sensitive data at the project and workspace levels.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://docs.humansignal.com/guide/manage_users#Roles-in-Label-Studio-Enterprise",
+        "params": {
+          "experiment": "organization_page_tip",
+          "treatment": "team_growing_live"
+        }
+      }
+    },
+    {
+      "title": "Want to simplify and secure logging in?",
+      "content": "Enable Single Sign-On for your team using SAML, SCIM2 or LDAP with Label Studio Enterprise.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://docs.humansignal.com/guide/auth_setup",
+        "params": {
+          "experiment": "organization_page_tip",
+          "treatment": "enable_sso_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "content": "Label Studio now has a Starter Cloud offering optimized for small teams and projects.",
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/pricing/",
+        "params": {
+          "experiment": "organization_page_tip",
+          "treatment": "starter_cloud_live"
+        }
+      }
+    },
+    {
+      "title": "Share knowledge with the community",
+      "content": "Have questions or a tip to share with other Label Studio users? Join the community slack channel for the latest updates. ",
+      "closable": true,
+      "link": {
+        "label": "Join the community",
+        "url": "https://label-studio.slack.com",
+        "params": {
+          "experiment": "organization_page_tip",
+          "treatment": "share_knowledge_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "content": "Label Studio supports multiple points of integration with cloud storage, machine learning models, and popular tools to automate your machine learning pipeline.",
+      "closable": true,
+      "link": {
+        "label": "Check out the integrations directory",
+        "url": "https://labelstud.io/integrations/",
+        "params": {
+          "experiment": "organization_page_tip",
+          "treatment": "integration_points_live"
+        }
+      }
+    },
+    {
+      "title": "Need Compliance?",
+      "content": "Label Studio Enterprise is fully SOC 2 and HIPAA compliant. Want more control? Deploy it On-Premises for maximum flexibility.",
+      "closable": true,
+      "link": {
+        "label": "Contact us",
+        "url": "https://humansignal.com/contact-sales/",
+        "params": {
+          "experiment": "organization_page_tip",
+          "treatment": "compliance_live"
+        }
+      }
+    }
+  ],
+  "projectSettings": [
+    {
+      "title": "Apply your AWS spend to Label Studio Enterprise",
+      "content": "Label Studio Enterprise is now available on the AWS Marketplace so you can use your committed spend to streamline data labeling workflows.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://aws.amazon.com/marketplace/pp/prodview-wjac3msf77tny",
+        "params": {
+          "experiment": "project_settings_tip",
+          "treatment": "aws_marketplace"
+        }
+      }
+    },
+    {
+      "title": "Save time with Auto-Labeling",
+      "content": "Use automation to instantly label large-scale datasets without sacrificing quality in the Enterprise platform.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://docs.humansignal.com/guide/prompts_overview#Auto-labeling-with-Prompts",
+        "params": {
+          "experiment": "project_settings_tip",
+          "treatment": "auto_labeling_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "content": "Try Label Studio Starter Cloud, optimized for small teams and projects.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/pricing/",
+        "params": {
+          "experiment": "project_settings_tip",
+          "treatment": "starter_cloud_live"
+        }
+      }
+    },
+    {
+      "title": "Evaluate GenAI models",
+      "content": "Combine automation plus human supervision to evaluate and ensure LLM quality in the Enterprise platform.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/evals/",
+        "params": {
+          "experiment": "project_settings_tip",
+          "treatment": "evals_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "content": "You can connect ML models using the backend SDK to save time with pre-labeling or active learning.",
+      "closable": true,
+      "link": {
+        "label": "Learn more",
+        "url": "https://labelstud.io/guide/ml",
+        "params": {
+          "experiment": "project_settings_tip",
+          "treatment": "connect_ml_models_live"
+        }
+      }
+    },
+    {
+      "title": "Native PDF support is coming!",
+      "content": "Label Studio Enterprise provides native PDF support.",
+      "closable": true,
+      "link": {
+        "label": "Request early access",
+        "url": "https://humansignal.com/pdf-interest-signup",
+        "params": {
+          "experiment": "project_settings_tip",
+          "treatment": "lse_pdf_live"
+        }
+      }
+    }
+  ],
+  "authPage": [
+    {
+      "title": "Live Event Dec 10th",
+      "description": "Join the Label Studio product team for a fast-paced tour of this year's biggest releases",
+      "link": {
+        "label": "December 10, 2025 9am PST",
+        "url": "https://humansignal.com/webinars/label-studio-wrapped-2025/",
+        "params": {
+          "experiment": "login_revamp",
+          "treatment": "wrapped_webinar_2025_live"
+        }
+      }
+    },
+    {
+      "title": "Auto-labeling with Prompts",
+      "description": "Use LLMs to instantly pre-label thousands of tasks with accurate predictions.",
+      "link": {
+        "label": "Learn more",
+        "url": "https://docs.humansignal.com/guide/prompts_overview",
+        "params": {
+          "experiment": "login_revamp",
+          "treatment": "prompts_auto_labeling_live"
+        }
+      }
+    },
+    {
+      "title": "Behind the benchmark",
+      "description": "Learn how Legalbenchmarks.ai built and scaled a benchmark for practical contract drafting tasks using LLM-as-a-judge and human review in Label Studio Enterprise.",
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/blog/how-legalbenchmarks-ai-built-a-domain-specific-ai-benchmark/",
+        "params": {
+          "experiment": "login_revamp",
+          "treatment": "legalbench_live"
+        }
+      }
+    },
+    {
+      "title": "New Enterprise Feature!",
+      "description": "Chat conversations are now a native data type for creating and evaluating chat-based AI experiences in Label Studio.",
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/blog/introducing-chat-4-use-cases-to-ship-a-high-quality-chatbot/",
+        "params": {
+          "experiment": "login_revamp",
+          "treatment": "chat_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "description": "Try Label Studio Starter Cloud, optimized for small teams and projects.",
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/pricing/",
+        "params": {
+          "experiment": "login_revamp",
+          "treatment": "starter_cloud_live"
+        }
+      }
+    },
+    {
+      "title": "Did you know?",
+      "description": "There's an Enterprise version of Label Studio packed with more features and automation to label data faster while ensuring the highest quality.",
+      "link": {
+        "label": "Learn more",
+        "url": "https://humansignal.com/goenterprise/",
+        "params": {
+          "experiment": "login_revamp",
+          "treatment": "enterprise_platform_live"
+        }
+      }
+    }
+  ]
+}

+ 30 - 0
web/apps/labelstudio/src/components/HeidiTips/types.ts

@@ -0,0 +1,30 @@
+export type TipLinkParams = Record<string, string> & {
+  experiment?: string;
+  treatment?: string;
+};
+
+export type Tip = {
+  title: string;
+  content: string;
+  description?: string;
+  closable?: boolean;
+  link: {
+    url: string;
+    label: string;
+    params?: TipLinkParams;
+  };
+};
+
+export type TipCollectionKey = "projectCreation" | "organizationPage" | "projectSettings";
+
+export type TipsCollection = Record<TipCollectionKey, Tip[]>;
+
+export type HeidiTipsProps = {
+  collection: keyof TipsCollection;
+};
+
+export type HeidiTipProps = {
+  tip: Tip;
+  onDismiss: () => void;
+  onLinkClick: () => void;
+};

+ 151 - 0
web/apps/labelstudio/src/components/HeidiTips/utils.ts

@@ -0,0 +1,151 @@
+import { defaultTipsCollection } from "./content";
+import type { Tip, TipsCollection } from "./types";
+
+const STORE_KEY = "heidi_ignored_tips";
+const EVENT_NAMESPACE_KEY = "heidi_tips";
+const CACHE_KEY = "heidi_live_tips_collection";
+const CACHE_FETCHED_AT_KEY = "heidi_live_tips_collection_fetched_at";
+const CACHE_STALE_TIME = 1000 * 60 * 60; // 1 hour
+const MAX_TIMEOUT = 5000; // 5 seconds
+
+function getKey(collection: string) {
+  return `${STORE_KEY}:${collection}`;
+}
+
+export function getTipCollectionEvent(collection: string, event: string) {
+  return `${EVENT_NAMESPACE_KEY}.${collection}.${event}`;
+}
+
+export function getTipEvent(collection: string, tip: Tip, event: string) {
+  if (tip.link.params?.experiment && tip.link.params?.treatment) {
+    return `${EVENT_NAMESPACE_KEY}.${collection}.${tip.link.params?.experiment}.${tip.link.params?.treatment}.${event}`;
+  }
+  if (tip.link.params?.experiment) {
+    return `${EVENT_NAMESPACE_KEY}.${collection}.${tip.link.params?.experiment}.${event}`;
+  }
+  if (tip.link.params?.treatment) {
+    return `${EVENT_NAMESPACE_KEY}.${collection}.${tip.link.params?.treatment}.${event}`;
+  }
+
+  return getTipCollectionEvent(collection, event);
+}
+
+export function getTipMetadata(tip: Tip) {
+  // Everything except the experiment and treatment params as those are part of the event name
+  const { experiment, treatment, ...rest } = tip.link.params ?? {};
+  return {
+    ...rest,
+    content: tip.description ?? tip.content ?? "",
+    title: tip.title,
+    href: tip.link.url,
+    label: tip.link.label,
+  };
+}
+
+export const loadLiveTipsCollection = () => {
+  // stale while revalidate - we will return the data present in the cache or the default data and fetch updated data to be put into the cache for the next time this function is called without waiting for the promise.
+  const cachedData = localStorage.getItem(CACHE_KEY);
+  const fetchedAt = localStorage.getItem(CACHE_FETCHED_AT_KEY);
+
+  // Read from local storage if the cachedData is less than CACHE_STALE_TIME milliseconds old
+  if (cachedData && fetchedAt && Date.now() - Number.parseInt(fetchedAt) < CACHE_STALE_TIME) {
+    return JSON.parse(cachedData);
+  }
+
+  const abortController = new AbortController();
+
+  // Abort the request after MAX_TIMEOUT milliseconds to ensure we won't wait for too long, something might be wrong with the network or it could be an air-gapped instance
+  const abortTimeout = setTimeout(abortController.abort, MAX_TIMEOUT);
+
+  // Fetch from github raw liveContent.json proxied through the server
+  fetch("/heidi-tips", {
+    headers: {
+      "Cache-Control": "no-cache",
+      "Content-Type": "application/json",
+    },
+    signal: abortController.signal,
+  })
+    .then(async (response) => {
+      if (response.ok) {
+        const data = await response.json();
+
+        // Cache the fetched content
+        localStorage.setItem(CACHE_FETCHED_AT_KEY, String(Date.now()));
+        localStorage.setItem(CACHE_KEY, JSON.stringify(data));
+      }
+    })
+    .catch((e) => {
+      console.warn("Failed to load live Heidi tips collection", e);
+    })
+    .finally(() => {
+      // Wait until the content is fetched to clear the abort timeout
+      // The abort should consider the entire request not just the headers
+      clearTimeout(abortTimeout);
+    });
+
+  // Serve possibly stale cached content
+  if (cachedData) {
+    return JSON.parse(cachedData);
+  }
+
+  // Default local content
+  return defaultTipsCollection;
+};
+
+export function getRandomTip(collection: keyof TipsCollection): Tip | null {
+  const tipsCollection = loadLiveTipsCollection();
+
+  if (!tipsCollection[collection] || isTipDismissed(collection)) return null;
+
+  const tips = tipsCollection[collection];
+
+  const index = Math.floor(Math.random() * tips.length);
+
+  return tips[index];
+}
+
+/**
+ * Set a cookie that indicates that a collection of tips is dismissed
+ * for 30 days
+ */
+export function dismissTip(collection: string) {
+  // will expire in 30 days
+  const cookieExpiryTime = 1000 * 60 * 60 * 24 * 30;
+  const cookieExpiryDate = new Date();
+
+  cookieExpiryDate.setTime(cookieExpiryDate.getTime() + cookieExpiryTime);
+
+  const finalKey = getKey(collection);
+  const cookieValue = `${finalKey}=true`;
+  const cookieExpiry = `expires=${cookieExpiryDate.toUTCString()}`;
+  const cookiePath = "path=/";
+  const cookieString = [cookieValue, cookieExpiry, cookiePath].join("; ");
+  document.cookie = cookieString;
+
+  __lsa(getTipCollectionEvent(collection, "dismiss"), {
+    expires: cookieExpiryDate.getTime(),
+  });
+}
+
+export function isTipDismissed(collection: string) {
+  const cookies = Object.fromEntries(document.cookie.split(";").map((item) => item.trim().split("=")));
+  const finalKey = getKey(collection);
+
+  return cookies[finalKey] === "true";
+}
+
+export function createURL(url: string, params?: Record<string, string>): string {
+  const base = new URL(url);
+
+  Object.entries(params ?? {}).forEach(([key, value]) => {
+    base.searchParams.set(key, value);
+  });
+
+  const userID = APP_SETTINGS.user?.id;
+  const serverID = APP_SETTINGS.server_id;
+
+  if (serverID) base.searchParams.set("server_id", serverID);
+  if (userID) base.searchParams.set("user_id", userID);
+
+  return base.toString();
+}

+ 128 - 0
web/apps/labelstudio/src/components/LeaveBlocker/LeaveBlocker.tsx

@@ -0,0 +1,128 @@
+import { useCallback, useEffect, useRef } from "react";
+import { useHistory } from "react-router";
+
+/**
+ * @param continueCallback - callback to call when the user wants to leave the page
+ * @param cancelCallback - callback to call when the user wants to stay on the page
+ */
+export type LeaveBlockerCallbacks = {
+  continueCallback?: () => void;
+  cancelCallback?: () => void;
+};
+
+/**
+ * @param active - should the blocker be active or not. Set false to disable the blocker
+ * @param onBeforeBlock - callback to check if we should block the page. If there is a need for a predicate to block the page
+ * @param onBlock - callback to call when we should block the page. It Allows using custom modals to ask the user if they want to leave the page
+ */
+export type LeaveBlockerProps = {
+  active: boolean;
+  onBeforeBlock?: () => boolean;
+  onBlock?: (callbacks: LeaveBlockerCallbacks) => void;
+};
+
+// Use `data-leave` attribute to mark the button that should be used to leave the current view (without changing url) to be able to block this action
+const LEAVE_BUTTON_SELECTOR = "[data-leave]";
+export const LEAVE_BLOCKER_KEY: string = "LEAVE_BLOCKER";
+
+type LeaveBlockerCallback = {
+  current?: (shouldLeave: boolean) => void;
+};
+// This is used to avoid problems with blocking the page API in react-router v5
+// Callback is stored in a ref and called when the user decides to leave the page (this will unblock history.block for the current transition)
+export const leaveBlockerCallback: LeaveBlockerCallback = {
+  current: undefined,
+};
+/**
+ * Block leaving the page if there is a reason to do so.
+ * It includes
+ * - blocking the action of a tab/window closing,
+ * - blocking going through the browser history,
+ * - blocking clicking on the button with `data-leave` attribute, which is supposed to lead to leave the current view
+ */
+export const LeaveBlocker = ({ active = true, onBeforeBlock, onBlock }: LeaveBlockerProps) => {
+  // This will make active value available in the callbacks without the need to update the callback every time the active value changes
+  const isActive = useRef(active);
+  isActive.current = active;
+  const history = useHistory();
+  // This is a way to block the page on a tab/window closing
+  // It will be done with browser standard API and confirm dialog
+  const beforeUnloadHandler = useCallback(
+    (e: BeforeUnloadEvent) => {
+      if (!isActive.current) return;
+      const shouldBlock = onBeforeBlock ? onBeforeBlock() : true;
+      if (!shouldBlock) return true;
+      e.preventDefault();
+      e.returnValue = false;
+      return false;
+    },
+    [onBeforeBlock],
+  );
+  const shouldSkipClickChecks = useRef(false);
+  // This is a way to block the view (but not a page) change by clicking on the button
+  // It obligates us to use `data-leave` attribute on the button that should be used to leave the current view
+  const beforeLeaveClickHandler = useCallback(
+    (e: MouseEvent) => {
+      if (!isActive.current) return;
+      // It allows to skip the check if the user chooses to leave the page
+      if (shouldSkipClickChecks.current) return;
+      const eventTarget = e.target as HTMLElement;
+      const target = eventTarget?.matches?.(LEAVE_BUTTON_SELECTOR)
+        ? e.target
+        : eventTarget?.closest(LEAVE_BUTTON_SELECTOR);
+
+      if (target) {
+        const shouldBlock = onBeforeBlock ? onBeforeBlock() : true;
+        if (!shouldBlock) return;
+        e.preventDefault();
+        e.stopPropagation();
+        if (onBlock) {
+          onBlock({
+            continueCallback() {
+              shouldSkipClickChecks.current = true;
+              eventTarget.click();
+              shouldSkipClickChecks.current = false;
+            },
+          });
+        }
+        return false;
+      }
+    },
+    [onBeforeBlock, onBlock],
+  );
+
+  useEffect(() => {
+    let unsubcribe: Function | null = null;
+
+    window.addEventListener("beforeunload", beforeUnloadHandler);
+    window.addEventListener("click", beforeLeaveClickHandler, { capture: true });
+    unsubcribe = history.block(() => {
+      if (!isActive.current) return;
+      const shouldBlock = onBeforeBlock ? onBeforeBlock() : true;
+      if (!shouldBlock) {
+        return;
+      }
+
+      onBlock?.({
+        continueCallback: () => {
+          leaveBlockerCallback.current?.(true);
+          leaveBlockerCallback.current = undefined;
+          unsubcribe?.();
+        },
+        cancelCallback: () => {
+          leaveBlockerCallback.current?.(false);
+          leaveBlockerCallback.current = undefined;
+        },
+      });
+      // workaround for react-router v5
+      // see `getUserConfirmation` on the history object
+      return LEAVE_BLOCKER_KEY;
+    });
+    return () => {
+      window.removeEventListener("beforeunload", beforeUnloadHandler);
+      window.removeEventListener("click", beforeLeaveClickHandler, { capture: true });
+      if (unsubcribe) unsubcribe();
+    };
+  }, [onBeforeBlock, onBlock, beforeUnloadHandler, beforeLeaveClickHandler]);
+  return null;
+};

+ 90 - 0
web/apps/labelstudio/src/components/Menu/Menu.jsx

@@ -0,0 +1,90 @@
+import { forwardRef, useCallback, useMemo } from "react";
+import { cn } from "../../utils/bem";
+import { useDropdown } from "@humansignal/ui";
+import "./Menu.scss";
+import { MenuContext } from "./MenuContext";
+import { MenuItem } from "./MenuItem";
+
+export const Menu = forwardRef(
+  ({ children, className, style, size, selectedKeys, closeDropdownOnItemClick, contextual }, ref) => {
+    const dropdown = useDropdown();
+
+    const selected = useMemo(() => {
+      return new Set(selectedKeys ?? []);
+    }, [selectedKeys]);
+
+    const clickHandler = useCallback(
+      (e) => {
+        const elem = cn("main-menu").elem("item").closest(e.target);
+
+        if (dropdown && elem && closeDropdownOnItemClick !== false) {
+          dropdown.close();
+        }
+      },
+      [dropdown],
+    );
+
+    const collapsed = useMemo(() => {
+      return !!dropdown;
+    }, [dropdown]);
+
+    return (
+      <MenuContext.Provider value={{ selected }}>
+        <ul
+          ref={ref}
+          className={cn("main-menu").mod({ size, collapsed, contextual }).mix(className).toClassName()}
+          style={style}
+          onClick={clickHandler}
+        >
+          {children}
+        </ul>
+      </MenuContext.Provider>
+    );
+  },
+);
+
+Menu.Item = MenuItem;
+Menu.Spacer = () => <li className={cn("main-menu").elem("spacer").toClassName()} />;
+Menu.Divider = () => <li className={cn("main-menu").elem("divider").toClassName()} />;
+Menu.Builder = (url, menuItems) => {
+  return (menuItems ?? []).map((item, index) => {
+    if (item === "SPACER") return <Menu.Spacer key={index} />;
+    if (item === "DIVIDER") return <Menu.Divider key={index} />;
+
+    let pageLabel;
+    let pagePath;
+
+    if (Array.isArray(item)) {
+      [pagePath, pageLabel] = item;
+    } else {
+      const { menuItem, title, path } = item;
+      pageLabel = title ?? menuItem;
+      pagePath = path;
+    }
+
+    if (typeof pagePath === "function") {
+      return (
+        <Menu.Item key={index} onClick={pagePath}>
+          {pageLabel}
+        </Menu.Item>
+      );
+    }
+
+    const location = `${url}${pagePath}`.replace(/([/]+)/g, "/");
+
+    return (
+      <Menu.Item key={index} to={location} exact>
+        {pageLabel}
+      </Menu.Item>
+    );
+  });
+};
+
+Menu.Group = ({ children, title, className, style }) => {
+  return (
+    <div className={cn("menu-group").mix(className).toClassName()} style={style}>
+      <div className={cn("menu-group").elem("title").toClassName()}>{title}</div>
+      <ul className={cn("menu-group").elem("list").toClassName()}>{children}</ul>
+    </div>
+  );
+};

+ 177 - 0
web/apps/labelstudio/src/components/Menu/Menu.scss

@@ -0,0 +1,177 @@
+.main-menu {
+  flex: 1;
+  margin: 0;
+  padding: 8px;
+  display: flex;
+  flex-direction: column;
+  list-style-type: none;
+  max-width: 100%;
+  box-sizing: border-box;
+  background: var(--color-neutral-background);
+  border-radius: 0;
+  transition: background-color 400ms ease-out;
+  border-right: 1px solid var(--color-neutral-border);
+  gap: 2px;
+
+  &__item {
+    height: 40px;
+    display: flex;
+    padding: 0 13px;
+    border-radius: var(--corner-radius-smaller);
+    align-items: center;
+    box-sizing: border-box;
+    color: var(--color-neutral-content-subtler);
+    font-size: 1rem;
+    white-space: nowrap;
+    text-decoration: none;
+    cursor: pointer;
+    transition: all 150ms ease-out;
+
+    &-icon {
+      margin-right: 10px;
+      object-fit: contain;
+      opacity: 0.5;
+      display: flex;
+      align-items: center;
+      justify-content: center;
+      width: 24px;
+    }
+
+    &-beta {
+      background-color: var(--color-accent-plum-base);
+      color: var(--color-accent-plum-subtlest);
+      font-size: 12px;
+      font-style: normal;
+      font-weight: 500;
+      line-height: 16px;
+      margin-left: 8px;
+      padding: 2px 8px;
+      border-radius: 12px;
+    }
+
+    &_look {
+      &_danger {
+        color: var(--color-negative-content);
+      }
+    }
+
+    &:not(.main-menu__item_look_danger):hover {
+      color: var(--color-neutral-content);
+      background: var(--color-primary-emphasis-subtle);
+    }
+
+    &:not(.main-menu__item_active):hover {
+      color: var(--color-neutral-content);
+      background: var(--color-primary-emphasis-subtle);
+    }
+
+    &_active {
+      color: var(--color-neutral-content);
+      font-weight: 500;
+    }
+
+    &_active:not(.sidebar__pin) {
+      pointer-events: none;
+      background: var(--color-neutral-emphasis);
+    }
+
+    &:hover &-icon,
+    &_active &-icon {
+      opacity: 1;
+    }
+
+    &_dangerous {
+      color: var(--color-negative-content);
+
+      &:hover {
+        color: var(--color-neutral-content) !important;
+        background-color: var(--color-negative-emphasis-subtle) !important;
+      }
+    }
+  }
+
+  &__spacer {
+    flex: 1;
+  }
+
+  &__divider {
+    height: 1px;
+    margin: 8px 0;
+    background-color: var(--color-neutral-border);
+    transition: background-color 150ms ease-out;
+  }
+
+  &_size_compact {
+    background: var(--color-neutral-background);
+  }
+
+  &_size_compact &__item,
+  &_size_medium &__item {
+    height: 32px;
+    font-size: 16px;
+  }
+
+  &_size_small &__item {
+    height: 24px;
+    font-size: 14px;
+    padding: 0 10px;
+  }
+
+  &_collapsed {
+    padding: 0.5rem;
+
+    &__item {
+      border-radius: 0.25rem;
+    }
+  }
+
+  &_contextual {
+    box-shadow: 0 1px 2px rgb(38 38 38 / 30%), 0 1px 3px 1px rgb(38 38 38 / 15%);
+    border-radius: 4px;
+  }
+
+  &_contextual &__item {
+    height: 32px;
+    padding: 16px 8px;
+    display: flex;
+    align-items: center;
+    justify-content: space-between;
+    border-radius: 4px;
+    cursor: pointer;
+
+    &:hover {
+      background-color: var(--color-primary-emphasis-subtle);
+    }
+
+    &_dangerous {
+      color: var(--color-negative-content);
+
+      &:hover {
+        color: var(--color-neutral-content);
+        background-color: var(--color-negative-emphasis-subtle);
+      }
+    }
+  }
+
+  &:first-child {
+    padding-top: 8px;
+  }
+
+  &:last-child {
+    padding-bottom: 8px;
+  }
+}
+
+.menu-group {
+  &__title {
+    padding: 4px 10px;
+    font-size: 14px;
+    color: var(--color-neutral-content-subtler);
+  }
+
+  &__list {
+    padding: 0;
+    margin-left: 10px;
+    list-style-type: none;
+  }
+}

Nem az összes módosított fájl került megjelenítésre, mert túl sok fájl változott