mirror of
https://github.com/penpot/penpot.git
synced 2026-03-28 06:10:28 +01:00
329 lines
11 KiB
Markdown
329 lines
11 KiB
Markdown
# Penpot Frontend – Agent Instructions
|
||
|
||
ClojureScript based frontend application that uses React, RxJS as main
|
||
architectural pieces.
|
||
|
||
|
||
## General Guidelines
|
||
|
||
This is a golden rule for frontend development standards. To ensure consistency
|
||
across the penpot stack, all contributions must adhere to these criteria:
|
||
|
||
|
||
### 1. Testing & Validation
|
||
|
||
#### Unit Tests
|
||
|
||
If code is added or modified in `src/`, corresponding tests in
|
||
`test/frontend_tests/` must be added or updated.
|
||
|
||
* **Environment:** Tests should run in a Node.js or browser-isolated
|
||
environment without requiring the full application state or a
|
||
running backend. Test are developed using cljs.test.
|
||
* **Mocks & Stubs:** * Use proper mocks for any side-effecting
|
||
functions (e.g., API calls, storage access).
|
||
* Avoid testing through the UI (DOM), we have e2e tests for that/
|
||
* Use `with-redefs` or similar ClojureScript mocking utilities to isolate the logic under test.
|
||
* **No Flakiness:** Tests must be deterministic. Do not use `setTimeout` or real
|
||
network calls. Use synchronous mocks for asynchronous workflows where
|
||
possible.
|
||
* **Location:** Place tests in the `test/frontend_tests/` directory, following the
|
||
namespace structure of the source code (e.g., `app.utils.timers` ->
|
||
`frontend-tests.util-timers-test`).
|
||
* **Execution:**
|
||
* **Isolated:** To run a focused ClojureScript unit test: edit the
|
||
`test/frontend_tests/runner.cljs` to narrow the test suite, then `pnpm run
|
||
test`.
|
||
* **Regression:** Run `pnpm run test` without modifications on the runner (preferred)
|
||
|
||
|
||
#### Integration Tests (Playwright)
|
||
|
||
Integration tests are developed under `frontend/playwright` directory, we use
|
||
mocks for remove communication with backend.
|
||
|
||
You should not add, modify or run the integration tests unless it exlicitly asked for.
|
||
|
||
|
||
```
|
||
pnpm run test:e2e # Playwright e2e tests
|
||
pnpm run test:e2e --grep "pattern" # Single e2e test by pattern
|
||
```
|
||
|
||
Ensure everything installed before executing tests with `./scripts/setup` script.
|
||
|
||
|
||
### 2. Code Quality & Formatting
|
||
|
||
* **Linting:** All code changes must pass linter checks:
|
||
* Run `pnpm run lint:clj` for CLJ/CLJS/CLJC
|
||
* Run `pnpm run lint:js` for JS
|
||
* Run `pnpm run lint:scss` for SCSS
|
||
* **Formatting:** All code changes must pass the formatting check
|
||
* Run `pnpm run check-fmt:clj` for CLJ/CLJS/CLJC
|
||
* Run `pnpm run check-fmt:js` for JS
|
||
* Run `pnpm run check-fmt:scss` for SCSS
|
||
* Use the `pnpm run fmt` fix all the formatting issues (`pnpm run fmt:clj`,
|
||
`pnpm run fmt:js` or `pnpm run fmt:scss` for isolated formatting fix)
|
||
|
||
### 3. Implementation Rules
|
||
|
||
* **Logic vs. View:** If logic is embedded in an UI component, extract it into a
|
||
function in the same namespace if is only used locally or look for a helper
|
||
namespace to make it unit-testable.
|
||
|
||
|
||
## Code Conventions
|
||
|
||
### Namespace Overview
|
||
|
||
The source is located under `src` directory and this is a general overview of
|
||
namespaces structure:
|
||
|
||
- `app.main.ui.*` – React UI components (`workspace`, `dashboard`, `viewer`)
|
||
- `app.main.data.*` – Potok event handlers (state mutations + side effects)
|
||
- `app.main.refs` – Reactive subscriptions (okulary lenses)
|
||
- `app.main.store` – Potok event store
|
||
- `app.util.*` – Utilities (DOM, HTTP, i18n, keyboard shortcuts)
|
||
|
||
|
||
### State Management (Potok)
|
||
|
||
State is a single atom managed by a Potok store. Events implement protocols
|
||
(funcool/potok library):
|
||
|
||
```clojure
|
||
(defn my-event
|
||
"doc string"
|
||
[data]
|
||
(ptk/reify ::my-event
|
||
ptk/UpdateEvent
|
||
(update [_ state] ;; synchronous state transition
|
||
(assoc state :key data))
|
||
|
||
ptk/WatchEvent
|
||
(watch [_ state stream] ;; async: returns an observable
|
||
(->> (rp/cmd! :some-rpc-command params)
|
||
(rx/map success-event)
|
||
(rx/catch error-handler)))
|
||
|
||
ptk/EffectEvent
|
||
(effect [_ state _] ;; pure side effects (DOM, logging)
|
||
(dom/focus (dom/get-element "id")))))
|
||
```
|
||
|
||
The state is located under `app.main.store` namespace where we have
|
||
the `emit!` function responsible of emiting events.
|
||
|
||
Example:
|
||
|
||
```cljs
|
||
(ns some.ns
|
||
(:require
|
||
[app.main.data.my-events :refer [my-event]]
|
||
[app.main.store :as st]))
|
||
|
||
(defn on-click
|
||
[event]
|
||
(st/emit! (my-event)))
|
||
```
|
||
|
||
On `app.main.refs` we have reactive references which lookup into the main state
|
||
for just inner data or precalculated data. That references are very usefull but
|
||
should be used with care because, per example if we have complex operation, this
|
||
operation will be executed on each state change, and sometimes is better to have
|
||
simple references and use react `use-memo` for more granular memoization.
|
||
|
||
Prefer helpers from `app.util.dom` instead of using direct dom calls, if no helper is
|
||
available, prefer adding a new helper for handling it and the use the
|
||
new helper.
|
||
|
||
### UI Components (React & Rumext: mf/defc)
|
||
|
||
The codebase contains various component patterns. When creating or refactoring
|
||
components, follow the Modern Syntax rules outlined below.
|
||
|
||
#### 1. The * Suffix Convention
|
||
|
||
The most recent syntax uses a * suffix in the component name (e.g.,
|
||
my-component*). This suffix signals the mf/defc macro to apply specific rules
|
||
for props handling and destructuring and optimization.
|
||
|
||
#### 2. Component Definition
|
||
|
||
Modern components should use the following structure:
|
||
|
||
```clj
|
||
(mf/defc my-component*
|
||
{::mf/wrap [mf/memo]} ;; Equivalent to React.memo
|
||
[{:keys [name on-click]}] ;; Destructured props
|
||
[:div {:class (stl/css :root)
|
||
:on-click on-click}
|
||
name])
|
||
```
|
||
|
||
#### 3. Hooks
|
||
|
||
Use the mf namespace for hooks to maintain consistency with the macro's
|
||
lifecycle management. These are analogous to standard React hooks:
|
||
|
||
```clj
|
||
(mf/use-state) ;; analogous to React.useState adapted to cljs semantics
|
||
(mf/use-effect) ;; analogous to React.useEffect
|
||
(mf/use-memo) ;; analogous to React.useMemo
|
||
(mf/use-fn) ;; analogous to React.useCallback
|
||
```
|
||
|
||
The `mf/use-state` in difference with React.useState, returns an atom-like
|
||
object, where you can use `swap!` or `reset!` for to perform an update and
|
||
`deref` for get the current value.
|
||
|
||
You also has `mf/deref` hook (which does not follow the `use-` naming pattern)
|
||
and it's purpose is watch (subscribe to changes) on atom or derived atom (from
|
||
okulary) and get the current value. Is mainly used for subscribe to lenses
|
||
defined in `app.main.refs` or (private lenses defined in namespaces).
|
||
|
||
Rumext also comes with improved syntax macros as alternative to `mf/use-effect`
|
||
and `mf/use-memo` functions. Examples:
|
||
|
||
|
||
Example for `mf/with-memo` macro:
|
||
|
||
```clj
|
||
;; Using functions
|
||
(mf/use-effect
|
||
(mf/deps team-id)
|
||
(fn []
|
||
(st/emit! (dd/initialize team-id))
|
||
(fn []
|
||
(st/emit! (dd/finalize team-id)))))
|
||
|
||
;; The same effect but using mf/with-effect
|
||
(mf/with-effect [team-id]
|
||
(st/emit! (dd/initialize team-id))
|
||
(fn []
|
||
(st/emit! (dd/finalize team-id))))
|
||
```
|
||
|
||
Example for `mf/with-memo` macro:
|
||
|
||
```
|
||
;; Using functions
|
||
(mf/use-memo
|
||
(mf/deps projects team-id)
|
||
(fn []
|
||
(->> (vals projects)
|
||
(filterv #(= team-id (:team-id %))))))
|
||
|
||
;; Using the macro
|
||
(mf/with-memo [projects team-id]
|
||
(->> (vals projects)
|
||
(filterv #(= team-id (:team-id %)))))
|
||
```
|
||
|
||
Prefer using the macros for it syntax simplicity.
|
||
|
||
|
||
#### 4. Component Usage (Hiccup Syntax)
|
||
|
||
When invoking a component within Hiccup, always use the [:> component* props]
|
||
pattern.
|
||
|
||
Requirements for props:
|
||
|
||
- Must be a map literal or a symbol pointing to a JavaScript props object.
|
||
- To create a JS props object, use the `#js` literal or the `mf/spread-object` helper macro.
|
||
|
||
Examples:
|
||
|
||
```clj
|
||
;; Using object literal (no need of #js because macro already interprets it)
|
||
[:> my-component* {:data-foo "bar"}]
|
||
|
||
;; Using object literal (no need of #js because macro already interprets it)
|
||
(let [props #js {:data-foo "bar"
|
||
:className "myclass"}]
|
||
[:> my-component* props])
|
||
|
||
;; Using the spread helper
|
||
(let [props (mf/spread-object base-props {:extra "data"})]
|
||
[:> my-component* props])
|
||
```
|
||
|
||
#### 5. Styles
|
||
|
||
##### Styles on component code
|
||
Styles are co-located with components. Each `.cljs` file has a corresponding
|
||
`.scss` file.
|
||
|
||
Example of clojurescript code for reference classes defined on styles (we use
|
||
CSS modules pattern):
|
||
|
||
```clojure
|
||
;; In the component namespace:
|
||
(require '[app.main.style :as stl])
|
||
|
||
;; In the render function:
|
||
[:div {:class (stl/css :container :active)}]
|
||
|
||
;; Conditional:
|
||
[:div {:class (stl/css-case :some-class true :selected (= drawtool :rect))}]
|
||
|
||
;; When you need concat an existing class:
|
||
[:div {:class [existing-class (stl/css-case :some-class true :selected (= drawtool :rect))]}]
|
||
```
|
||
|
||
##### General rules for styling
|
||
|
||
- Prefer CSS custom properties ( `margin: var(--sp-xs);`) instead of scss
|
||
variables and get the already defined properties from `_sizes.scss`. The SCSS
|
||
variables are allowed and still used, just prefer properties if they are
|
||
already defined.
|
||
- If a value isn't in the DS, use the `px2rem(n)` mixin: `@use "ds/_utils.scss"
|
||
as *; padding: px2rem(23);`.
|
||
- Do **not** create new SCSS variables for one-off values.
|
||
- Use physical directions with logical ones to support RTL/LTR naturally.
|
||
- ❌ `margin-left`, `padding-right`, `left`, `right`.
|
||
- ✅ `margin-inline-start`, `padding-inline-end`, `inset-inline-start`.
|
||
- Always use the `use-typography` mixin from `ds/typography.scss`.
|
||
- ✅ `@include t.use-typography("title-small");`
|
||
- Use `$br-*` for radius and `$b-*` for thickness from `ds/_borders.scss`.
|
||
- Use only tokens from `ds/colors.scss`. Do **NOT** use `design-tokens.scss` or
|
||
legacy color variables.
|
||
- Use mixins only those defined in`ds/mixins.scss`. Avoid legacy mixins like
|
||
`@include flexCenter;`. Write standard CSS (flex/grid) instead.
|
||
- Use the `@use` instead of `@import`. If you go to refactor existing SCSS file,
|
||
try to replace all `@import` with `@use`. Example: `@use "ds/_sizes.scss" as
|
||
*;` (Use `as *` to expose variables directly).
|
||
- Avoid deep selector nesting or high-specificity (IDs). Flatten selectors:
|
||
- ❌ `.card { .title { ... } }`
|
||
- ✅ `.card-title { ... }`
|
||
- Leverage component-level CSS variables for state changes (hover/focus) instead
|
||
of rewriting properties.
|
||
|
||
##### Checklist
|
||
|
||
- [ ] No references to `common/refactor/`
|
||
- [ ] All `@import` converted to `@use` (only if refactoring)
|
||
- [ ] Physical properties (left/right) using logical properties (inline-start/end).
|
||
- [ ] Typography implemented via `use-typography()` mixin.
|
||
- [ ] Hardcoded pixel values wrapped in `px2rem()`.
|
||
- [ ] Selectors are flat (no deep nesting).
|
||
|
||
|
||
### Performance Macros (`app.common.data.macros`)
|
||
|
||
Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript:
|
||
|
||
```clojure
|
||
(dm/select-keys m [:a :b]) ;; ~6x faster than core/select-keys
|
||
(dm/get-in obj [:a :b :c]) ;; faster than core/get-in
|
||
(dm/str "a" "b" "c") ;; string concatenation
|
||
```
|
||
|
||
### Configuration
|
||
|
||
`src/app/config.clj` reads globally defined variables and exposes precomputed
|
||
configuration vars ready to be used from other parts of the application
|
||
|