diff --git a/AGENTS.md b/AGENTS.md index 75db9178d..a39785e09 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -143,14 +143,15 @@ Keep this section light; implementation details move often. ### File and folder naming -OpenPencil follows a Reka UI-inspired component namespace structure: +OpenPencil uses domain namespaces rather than full Feature-Sliced Design ceremony: -- Vue component namespace folders use PascalCase: `ColorPicker/`, `Toolbar/`, `ProviderSettings/`. -- Vue component files use PascalCase: `ColorPickerRoot.vue`, `ToolbarItem.vue`. -- Component-scoped composables use camelCase: `useToolbarState.ts`, `usePageList.ts`. -- Non-component domain folders use lowercase or kebab-case: `scene-graph/`, `figma-api/`, `node-edit/`. -- Non-component TypeScript files use lowercase or kebab-case unless they are conventional entrypoints such as `index.ts`, `types.ts`, `context.ts`, or `use.ts`. -- Multi-file root components live inside their component namespace folder, not beside it. +- App services/state/integration live under `src/app/**`; route/layout views live under `src/views/**`; app UI lives under `src/components/**`. +- `src/components/ui/**` is the shared app design-system layer. `packages/vue/src/primitives/**` is the headless SDK primitive layer. App wrappers around SDK primitives should stay in app component domains and only move to `ui/**` when genuinely generic. +- Root-level `src/components/*.vue` is reserved for broad editor panels/surfaces assembled by views or shell layout. Do not add new root-level base controls; create a domain namespace or use `src/components/ui/**` for reusable primitives. +- App component domain folders should be lowercase or kebab-case (`chat/`, `properties/`, `fill-picker/`, `color-picker-panel/`, `canvas/`, `inputs/`). Avoid adding new `PascalCase/Component.vue` app folders; migrate existing ones gradually when touched. +- Vue component files stay PascalCase: `ColorPickerRoot.vue`, `ToolbarItem.vue`. Component-scoped composables use camelCase: `useToolbarState.ts`, `usePageList.ts`. +- Non-component domain folders use lowercase or kebab-case: `scene-graph/`, `figma-api/`, `node-edit/`. Non-component TypeScript files use lowercase or kebab-case unless they are conventional entrypoints such as `index.ts`, `types.ts`, `context.ts`, or `use.ts`. +- Multi-file root components live inside their component namespace folder, not beside it. When a reusable picker/input/control grows beyond one file, create a namespace instead of leaving related files at `src/components/` root. - Use subfolders for multi-file domains instead of sibling files with repeated prefixes. Prefer `selection/container.ts`, `selection/hit-test.ts` over `selection-container.ts`, `selection-hit-test.ts`. When adding a second file for a domain (e.g. `eval-wrap.ts` next to `eval.ts`), create the folder immediately (`eval/index.ts` + `eval/wrap.ts`) instead of prefixing. Oxlint catches sibling prefix files when a sibling folder exists; Steiger catches 3+ sibling files with the same prefix. The convention applies even before either rule triggers. ### Repo tools and scripts @@ -258,7 +259,7 @@ Self-review checklist: - `src/components/ui/**` is the app design-system layer: reusable visual primitives, wrappers around Reka UI primitives, low-level styled controls, and UI class helpers. These files must not import app services/stores or feature panels. - `src/components/Shell/**` is for app shell chrome and global app services rendered as components (menu bar, toast viewport, update/status chrome). Shell components may use app shell/editor stores. - `src/components/properties/**`, `src/components/chat/**`, `src/components/LayerTree/**`, `src/components/Toolbar/**`, and similar folders are feature/domain component namespaces. Keep feature-specific controls there unless they are genuinely reusable UI primitives. -- Root-level `src/components/*.vue` is for broad editor panels/surfaces that are assembled by views or shell layout. Do not add new root-level base controls; create a domain folder or move reusable primitives to `src/components/ui/**`. +- Treat existing root-level picker/input/control components as migration candidates when touched; do not expand that pattern. - Test hooks should be `data-test-id` attributes owned by the rendered markup or generated internally from semantic component state. Do not add `testId`, `visibilityTestId`, `triggerTestId`, or other test-id props to component APIs. - Use reka-ui for UI components (Splitter, ContextMenu, DropdownMenu, etc.) @@ -267,7 +268,7 @@ Self-review checklist: - App wrappers around SDK primitives should compose a single `ui` object from shared UI helpers (`useSelectUI`, `usePopoverUI`, etc.) rather than bypassing the design system with raw Tailwind strings spread across multiple props. - Editor commands share `packages/vue/src/editor/commands/registry.ts` as the canonical source for shortcut display tokens, keyboard bindings, and context-menu test IDs. Store portable shortcuts such as `MOD+D`, `MOD+SHIFT+H`, and `MOD+ALT+K`; format them with `formatShortcut()` at render time so macOS shows `⌘`/`⌥` and Windows/Linux show `Ctrl`/`Alt`. - Labels and translations must not contain shortcut text. Keep labels semantic (`Add auto layout`, `Show/Hide`) and render shortcuts from command metadata. Steiger enforces this for `packages/vue/src/i18n/messages.ts` and locale JSON files. -- Canvas context-menu structure lives in `packages/vue/src/editor/menu-model/canvas.ts`. Do not hand-build command grouping in `src/components/CanvasMenu.vue`; the component should render menu entries and provide app-specific actions only when unavoidable. +- Canvas context-menu structure lives in `packages/vue/src/editor/menu-model/canvas.ts`. Do not hand-build command grouping in `src/components/canvas/CanvasMenu.vue`; the component should render menu entries and provide app-specific actions only when unavoidable. - Browser and Tauri menus share `src/app/shell/menu/schema.ts` as the canonical menu model. Do not add menu items directly in `src/components/Shell/AppMenu.vue` or `desktop/src/menu.rs`. - Regenerate the native menu with `bun run generate:tauri-menu` after editing the shared menu schema; `desktop/generated/menu.json` is consumed by the Tauri menu builder. Tauri also runs this generator from `desktop/tauri.conf.json` via `beforeDevCommand` and `beforeBuildCommand`. - Every shared menu item with an `id` must be handled by `src/app/shell/menu/use.ts`, an editor command, or explicitly marked browser/native-only in the schema. diff --git a/lint/plugin.js b/lint/plugin.js index 44f119c83..692900360 100644 --- a/lint/plugin.js +++ b/lint/plugin.js @@ -1,4 +1,5 @@ import { existsSync } from 'node:fs' + import { parse as parseVueSfc } from 'vue/compiler-sfc' function normalizedFilename(context) { @@ -311,7 +312,10 @@ const noNativeTitleAttributesInVue = { let hasTitleAttribute = false walkVueTemplateAst(template, (templateNode) => { if (hasTitleAttribute) return - if (isStaticVueAttribute(templateNode, 'title') || isVueBindDirective(templateNode, 'title')) { + if ( + isStaticVueAttribute(templateNode, 'title') || + isVueBindDirective(templateNode, 'title') + ) { hasTitleAttribute = true } }) @@ -451,7 +455,8 @@ const TEST_ID_FORMAT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/ const noRawTestIdStringProps = { meta: { docs: { - description: 'Disallow test-id component props — use data-test-id attrs or internal semantic ids' + description: + 'Disallow test-id component props — use data-test-id attrs or internal semantic ids' } }, create(context) { @@ -624,9 +629,7 @@ const noRawTestIdSelectorsInTests = { function isGeneratedTestIdLiteral(value) { if (typeof value !== 'string') return false - const toolbarValue = value.startsWith('mobile-toolbar-') - ? value.slice('mobile-'.length) - : value + const toolbarValue = value.startsWith('mobile-toolbar-') ? value.slice('mobile-'.length) : value return ( toolbarValue.startsWith('toolbar-tool-') || toolbarValue.startsWith('toolbar-flyout-') || @@ -714,7 +717,8 @@ const noBrowserSideEffectsInVue = { ) { context.report({ node, - message: 'Use VueUse useEventListener() instead of direct browser event listeners in Vue components.' + message: + 'Use VueUse useEventListener() instead of direct browser event listeners in Vue components.' }) return } @@ -743,7 +747,8 @@ const noBrowserSideEffectsInVue = { const noDocumentQuerySelectorInVue = { meta: { docs: { - description: 'Disallow document.querySelector in Vue components — use template refs or composables' + description: + 'Disallow document.querySelector in Vue components — use template refs or composables' } }, create(context) { @@ -756,7 +761,10 @@ const noDocumentQuerySelectorInVue = { if (callee?.type !== 'MemberExpression') return if (callee.object?.type !== 'Identifier' || callee.object.name !== 'document') return if (callee.property?.type !== 'Identifier') return - if (callee.property.name !== 'querySelector' && callee.property.name !== 'querySelectorAll') { + if ( + callee.property.name !== 'querySelector' && + callee.property.name !== 'querySelectorAll' + ) { return } context.report({ @@ -772,7 +780,8 @@ const noDocumentQuerySelectorInVue = { const noDirectSelectionToolStateMutation = { meta: { docs: { - description: 'Disallow direct editor selection/tool state assignment outside core editor internals' + description: + 'Disallow direct editor selection/tool state assignment outside core editor internals' } }, create(context) { @@ -843,7 +852,8 @@ function colorObjectLiteral(node, color) { const noHardcodedColorConstants = { meta: { docs: { - description: 'Use named color constants instead of inline Color object literals for shared colors' + description: + 'Use named color constants instead of inline Color object literals for shared colors' } }, create(context) { @@ -853,10 +863,17 @@ const noHardcodedColorConstants = { return { ObjectExpression(node) { if (colorObjectLiteral(node, { r: 0, g: 0, b: 0, a: 1 })) { - context.report({ node, message: 'Use BLACK from constants instead of an inline black Color literal.' }) + context.report({ + node, + message: 'Use BLACK from constants instead of an inline black Color literal.' + }) } if (colorObjectLiteral(node, { r: 0, g: 0, b: 0, a: 0 })) { - context.report({ node, message: 'Use TRANSPARENT from constants instead of an inline transparent Color literal.' }) + context.report({ + node, + message: + 'Use TRANSPARENT from constants instead of an inline transparent Color literal.' + }) } } } @@ -1551,10 +1568,10 @@ const vueComponentFilePascalCase = { } } -const componentNamespacePascalCase = { +const componentNamespaceCasing = { meta: { docs: { - description: 'Require component namespace folders to use PascalCase names' + description: 'Require component namespace folders to use the project casing convention' } }, create(context) { @@ -1577,12 +1594,11 @@ const componentNamespacePascalCase = { const parts = componentMatch[1].split('/') const first = parts[0] const second = parts[1] - const allowedGroups = new Set(['chat', 'properties', 'ui']) - if (parts.length > 1 && !allowedGroups.has(first) && !isPascalCaseName(first)) { + if (parts.length > 1 && !isPascalCaseName(first) && !isKebabOrLowercaseName(first)) { context.report({ node, - message: `Component namespace folder '${first}' must use PascalCase.` + message: `Component namespace folder '${first}' must use PascalCase or kebab-case.` }) return } @@ -1591,11 +1607,12 @@ const componentNamespacePascalCase = { parts.length > 2 && (first === 'chat' || first === 'properties') && second !== undefined && - !isPascalCaseName(second) + !isPascalCaseName(second) && + !isKebabOrLowercaseName(second) ) { context.report({ node, - message: `Nested component namespace folder '${first}/${second}' must use PascalCase.` + message: `Nested component namespace folder '${first}/${second}' must use PascalCase or kebab-case.` }) } } @@ -1789,7 +1806,8 @@ const noDirectOpenPencilBrowserStore = { if (!isOpenPencilMember(node.object)) return context.report({ node, - message: 'Use window.openPencil.getStore() instead of accessing window.openPencil.store directly.' + message: + 'Use window.openPencil.getStore() instead of accessing window.openPencil.store directly.' }) } } @@ -1887,7 +1905,8 @@ function hasAstChild(node, predicate, seen = new WeakSet()) { function containsRecordStringUnknownType(node) { if (isRecordStringUnknownType(node)) return true if (node?.type === 'TSArrayType') return isRecordStringUnknownType(node.elementType) - if (node?.type === 'TSUnionType') return node.types?.some(containsRecordStringUnknownType) ?? false + if (node?.type === 'TSUnionType') + return node.types?.some(containsRecordStringUnknownType) ?? false return false } @@ -1942,7 +1961,8 @@ const noBroadUnknownTypeAssertions = { function typeNameText(node) { if (!node) return 'unknown' if (node.type === 'Identifier') return node.name - if (node.type === 'TSQualifiedName') return `${typeNameText(node.left)}.${typeNameText(node.right)}` + if (node.type === 'TSQualifiedName') + return `${typeNameText(node.left)}.${typeNameText(node.right)}` return node.type } @@ -2034,7 +2054,8 @@ const noDuplicateTypeShapes = { } context.report({ node, - message: 'Duplicate object type shape. Reuse the existing named type instead of redeclaring the same members.' + message: + 'Duplicate object type shape. Reuse the existing named type instead of redeclaring the same members.' }) } } @@ -2054,7 +2075,8 @@ const noLocalJsonObjectAliases = { if (!isRecordStringUnknownType(node.typeAnnotation)) return context.report({ node, - message: 'Import JsonObject from @open-pencil/scene-graph/primitives instead of declaring a local alias.' + message: + 'Import JsonObject from @open-pencil/scene-graph/primitives instead of declaring a local alias.' }) } } @@ -2062,7 +2084,6 @@ const noLocalJsonObjectAliases = { } const noFlatKiwiModules = createProgramFilenameRule({ - description: 'Disallow flat top-level Kiwi modules — group code under Kiwi subdomains', check(file) { const marker = '/packages/core/src/kiwi/' @@ -2136,7 +2157,7 @@ const plugin = { 'prefer-vueuse-timeouts': preferVueUseTimeouts, 'max-composition-root-lines': maxCompositionRootLines, 'vue-component-file-pascal-case': vueComponentFilePascalCase, - 'component-namespace-pascal-case': componentNamespacePascalCase, + 'component-namespace-casing': componentNamespaceCasing, 'non-component-source-directories-kebab-case': nonComponentSourceDirectoriesKebabCase, 'no-component-root-sibling-folder': noComponentRootSiblingFolder, 'no-useless-pass-through-wrappers': noUselessPassThroughWrappers, diff --git a/oxlint.json b/oxlint.json index 8ad08a2a3..ad86e2da6 100644 --- a/oxlint.json +++ b/oxlint.json @@ -208,7 +208,7 @@ "open-pencil/prefer-vueuse-intervals": "error", "open-pencil/prefer-vueuse-timeouts": "error", "open-pencil/vue-component-file-pascal-case": "error", - "open-pencil/component-namespace-pascal-case": "error", + "open-pencil/component-namespace-casing": "error", "open-pencil/non-component-source-directories-kebab-case": "error", "open-pencil/no-component-root-sibling-folder": "error", "open-pencil/no-flat-kiwi-modules": "error", diff --git a/src/components/ColorPicker/ColorPicker.vue b/src/components/ColorPicker/ColorPicker.vue index 545f213e3..0fe6f9376 100644 --- a/src/components/ColorPicker/ColorPicker.vue +++ b/src/components/ColorPicker/ColorPicker.vue @@ -1,7 +1,7 @@ diff --git a/src/components/ColorPickerPanel/ColorPickerPanel.vue b/src/components/color-picker-panel/ColorPickerPanel.vue similarity index 63% rename from src/components/ColorPickerPanel/ColorPickerPanel.vue rename to src/components/color-picker-panel/ColorPickerPanel.vue index 2e9f5baeb..5f34f7d51 100644 --- a/src/components/ColorPickerPanel/ColorPickerPanel.vue +++ b/src/components/color-picker-panel/ColorPickerPanel.vue @@ -2,10 +2,10 @@ import type { Color } from '@open-pencil/scene-graph/primitives' import type { OkHCLControls } from '@open-pencil/vue' -import ColorAreaControl from '@/components/ColorPickerPanel/ColorAreaControl.vue' -import FormatControls from '@/components/ColorPickerPanel/FormatControls.vue' -import HueAlphaSliders from '@/components/ColorPickerPanel/HueAlphaSliders.vue' -import { provideColorPickerPanel } from '@/components/ColorPickerPanel/context' +import ColorAreaControl from '@/components/color-picker-panel/ColorAreaControl.vue' +import FormatControls from '@/components/color-picker-panel/FormatControls.vue' +import HueAlphaSliders from '@/components/color-picker-panel/HueAlphaSliders.vue' +import { provideColorPickerPanel } from '@/components/color-picker-panel/context' const { color, okhcl = null } = defineProps<{ color: Color diff --git a/src/components/ColorPickerPanel/FormatControls.vue b/src/components/color-picker-panel/FormatControls.vue similarity index 64% rename from src/components/ColorPickerPanel/FormatControls.vue rename to src/components/color-picker-panel/FormatControls.vue index c70e144bb..3aab43627 100644 --- a/src/components/ColorPickerPanel/FormatControls.vue +++ b/src/components/color-picker-panel/FormatControls.vue @@ -1,10 +1,10 @@ diff --git a/src/components/ColorPickerPanel/HsbFields.vue b/src/components/color-picker-panel/HsbFields.vue similarity index 92% rename from src/components/ColorPickerPanel/HsbFields.vue rename to src/components/color-picker-panel/HsbFields.vue index 0d8bb8db8..136c85094 100644 --- a/src/components/ColorPickerPanel/HsbFields.vue +++ b/src/components/color-picker-panel/HsbFields.vue @@ -2,8 +2,8 @@ import { inputNumberValue } from '@open-pencil/vue' import { colorToCSS } from '@open-pencil/core/color' -import PickerSlider from '@/components/PickerSlider.vue' -import { useColorPickerPanelContext } from '@/components/ColorPickerPanel/context' +import PickerSlider from '@/components/color-picker-panel/PickerSlider.vue' +import { useColorPickerPanelContext } from '@/components/color-picker-panel/context' const ctx = useColorPickerPanelContext() diff --git a/src/components/ColorPickerPanel/HslFields.vue b/src/components/color-picker-panel/HslFields.vue similarity index 92% rename from src/components/ColorPickerPanel/HslFields.vue rename to src/components/color-picker-panel/HslFields.vue index e61e645c0..4aaa6e790 100644 --- a/src/components/ColorPickerPanel/HslFields.vue +++ b/src/components/color-picker-panel/HslFields.vue @@ -2,8 +2,8 @@ import { inputNumberValue } from '@open-pencil/vue' import { colorToCSS } from '@open-pencil/core/color' -import PickerSlider from '@/components/PickerSlider.vue' -import { useColorPickerPanelContext } from '@/components/ColorPickerPanel/context' +import PickerSlider from '@/components/color-picker-panel/PickerSlider.vue' +import { useColorPickerPanelContext } from '@/components/color-picker-panel/context' const ctx = useColorPickerPanelContext() diff --git a/src/components/ColorPickerPanel/HueAlphaSliders.vue b/src/components/color-picker-panel/HueAlphaSliders.vue similarity index 87% rename from src/components/ColorPickerPanel/HueAlphaSliders.vue rename to src/components/color-picker-panel/HueAlphaSliders.vue index 11cf7cd31..f1a952193 100644 --- a/src/components/ColorPickerPanel/HueAlphaSliders.vue +++ b/src/components/color-picker-panel/HueAlphaSliders.vue @@ -1,8 +1,8 @@ diff --git a/src/components/ColorPickerPanel/OkhclFields.vue b/src/components/color-picker-panel/OkhclFields.vue similarity index 95% rename from src/components/ColorPickerPanel/OkhclFields.vue rename to src/components/color-picker-panel/OkhclFields.vue index 12beb038c..678121e3d 100644 --- a/src/components/ColorPickerPanel/OkhclFields.vue +++ b/src/components/color-picker-panel/OkhclFields.vue @@ -2,8 +2,8 @@ import { colorToCSS } from '@open-pencil/core/color' import { fromPercent, toPercent } from '@open-pencil/vue' -import PickerSlider from '@/components/PickerSlider.vue' -import { useColorPickerPanelContext } from '@/components/ColorPickerPanel/context' +import PickerSlider from '@/components/color-picker-panel/PickerSlider.vue' +import { useColorPickerPanelContext } from '@/components/color-picker-panel/context' const ctx = useColorPickerPanelContext() diff --git a/src/components/PickerSlider.vue b/src/components/color-picker-panel/PickerSlider.vue similarity index 97% rename from src/components/PickerSlider.vue rename to src/components/color-picker-panel/PickerSlider.vue index cd6cb6d64..2b01bb774 100644 --- a/src/components/PickerSlider.vue +++ b/src/components/color-picker-panel/PickerSlider.vue @@ -1,6 +1,6 @@ diff --git a/src/components/ColorPickerPanel/context.ts b/src/components/color-picker-panel/context.ts similarity index 100% rename from src/components/ColorPickerPanel/context.ts rename to src/components/color-picker-panel/context.ts diff --git a/src/components/ZoomDropdown.vue b/src/components/editor/ZoomDropdown.vue similarity index 100% rename from src/components/ZoomDropdown.vue rename to src/components/editor/ZoomDropdown.vue diff --git a/src/components/FillPicker.vue b/src/components/fill-picker/FillPicker.vue similarity index 94% rename from src/components/FillPicker.vue rename to src/components/fill-picker/FillPicker.vue index 9a47db347..f7b97a15f 100644 --- a/src/components/FillPicker.vue +++ b/src/components/fill-picker/FillPicker.vue @@ -4,10 +4,10 @@ import { twMerge } from 'tailwind-merge' import { applySolidFillColor, FillPickerRoot, useI18n } from '@open-pencil/vue' import GradientEditor from './GradientEditor.vue' -import ColorPickerPanel from '@/components/ColorPickerPanel/ColorPickerPanel.vue' +import ColorPickerPanel from '@/components/color-picker-panel/ColorPickerPanel.vue' import ImageFillPicker from './ImageFillPicker.vue' -import Tip from './ui/Tip.vue' -import { usePopoverUI } from './ui/popover' +import Tip from '@/components/ui/Tip.vue' +import { usePopoverUI } from '@/components/ui/popover' import type { Fill } from '@open-pencil/scene-graph' import type { OkHCLControls } from '@open-pencil/vue' diff --git a/src/components/GradientEditor.vue b/src/components/fill-picker/GradientEditor.vue similarity index 94% rename from src/components/GradientEditor.vue rename to src/components/fill-picker/GradientEditor.vue index 8784ba1b3..71ea8f23b 100644 --- a/src/components/GradientEditor.vue +++ b/src/components/fill-picker/GradientEditor.vue @@ -1,8 +1,8 @@