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 @@