Compare commits

..

5 Commits

Author SHA1 Message Date
Eva Marco
155683dbe0 WIP 2025-11-06 09:43:03 +01:00
Eva Marco
3e4b0124c5 ♻️ Remove unneeded code 2025-11-06 09:43:03 +01:00
Eva Marco
f22633d18b 🚧 Add dropdown to component 2025-11-06 09:43:01 +01:00
Eva Marco
942149ae87 🚧 Wip 2025-11-06 09:40:38 +01:00
Eva Marco
ed379013ef 🎉 Basic structure 2025-11-06 09:40:38 +01:00
118 changed files with 1070 additions and 8879 deletions

View File

@@ -37,43 +37,36 @@ jobs:
ref: ${{ steps.vars.outputs.gh_ref }}
# --- Publicly release the docker images ---
- name: Configure ECR credentials
uses: aws-actions/configure-aws-credentials@v4
- name: Login to private registry
uses: docker/login-action@v3
with:
aws-access-key-id: ${{ secrets.DOCKER_USERNAME }}
aws-secret-access-key: ${{ secrets.DOCKER_PASSWORD }}
aws-region: ${{ secrets.AWS_REGION }}
registry: ${{ secrets.DOCKER_REGISTRY }}
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Install Skopeo
run: |
sudo apt-get update -y
sudo apt-get install -y skopeo
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.PUB_DOCKER_USERNAME }}
password: ${{ secrets.PUB_DOCKER_PASSWORD }}
- name: Copy images from AWS ECR to Docker Hub
- name: Publish docker images to DockerHub
env:
AWS_REGION: ${{ secrets.AWS_REGION }}
DOCKER_REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
PUB_DOCKER_USERNAME: ${{ secrets.PUB_DOCKER_USERNAME }}
PUB_DOCKER_PASSWORD: ${{ secrets.PUB_DOCKER_PASSWORD }}
TAG: ${{ steps.vars.outputs.gh_ref }}
REGISTRY: ${{ secrets.DOCKER_REGISTRY }}
HUB: ${{ secrets.PUB_DOCKER_HUB }}
run: |
aws ecr get-login-password --region $AWS_REGION | \
skopeo login --username AWS --password-stdin \
$DOCKER_REGISTRY
echo "$PUB_DOCKER_PASSWORD" | skopeo login --username "$PUB_DOCKER_USERNAME" --password-stdin docker.io
IMAGES=("frontend" "backend" "exporter" "storybook")
IMAGES=("frontend" "backend" "exporter")
EXTRA_TAGS=("main" "latest")
for image in "${IMAGES[@]}"; do
skopeo copy --all \
docker://$DOCKER_REGISTRY/$image:$TAG \
docker://docker.io/$PUB_DOCKER_USERNAME/$image:$TAG
docker pull "$REGISTRY/$image:$TAG"
docker tag "$REGISTRY/$image:$TAG" "penpotapp/$image:$TAG"
docker push "penpotapp/$image:$TAG"
for alias in main latest; do
skopeo copy --all \
docker://$DOCKER_REGISTRY/$image:$TAG \
docker://docker.io/$PUB_DOCKER_USERNAME/$image:$alias
for tag in "${EXTRA_TAGS[@]}"; do
docker tag "$REGISTRY/$image:$TAG" "penpotapp/$image:$tag"
docker push "penpotapp/$image:$tag"
done
done

View File

@@ -4,20 +4,12 @@
### :boom: Breaking changes & Deprecations
- The backend RPC API URLS are changed from `/api/rpc/command/<name>`
to `/api/main/methods/<name>` (the previou PATH is preserved for
backward compatibility; however, if you are a user of this API, it
is strongly recommended that you adapt your code to use the new
PATH.
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
- Select boards to export as PDF [Taiga #12320](https://tree.taiga.io/project/penpot/issue/12320)
- Toggle for switching boolean property values [Taiga #12341](https://tree.taiga.io/project/penpot/us/12341)
### :bug: Bugs fixed
@@ -31,9 +23,8 @@
- Fix search shortcut [Taiga #10265](https://tree.taiga.io/project/penpot/issue/10265)
- Fix shortcut conflict in text editor (increase/decrease font size vs word selection)
- Fix problem with plugins generating code for pages different than current one [Taiga #12312](https://tree.taiga.io/project/penpot/issue/12312)
- Fix input confirmation behavior is not uniform [Taiga #12294](https://tree.taiga.io/project/penpot/issue/12294)
## 2.11.0
## 2.11.0 (Unreleased)
### :boom: Breaking changes & Deprecations
@@ -61,6 +52,10 @@
services which use netty internally (redis connection, S3 SDK client). This
configuration is not very commonly used so don't expected real impact on any user.
### :rocket: Epics and highlights
### :heart: Community contributions (Thank you!)
### :sparkles: New features & Enhancements
- New composite token: Typography [Taiga #10200](https://tree.taiga.io/project/penpot/us/10200)
@@ -105,9 +100,6 @@
- Fix options button does not work for comments created in the lower part of the screen [Taiga #12422](https://tree.taiga.io/project/penpot/issue/12422)
- Fix problem when checking usage with removed teams [Taiga #12442](https://tree.taiga.io/project/penpot/issue/12442)
- Fix focus mode persisting across page/file navigation [Taiga #12469](https://tree.taiga.io/project/penpot/issue/12469)
- Fix shadow color validation [Github #7705](https://github.com/penpot/penpot/pull/7705)
- Fix exception on selection blend-mode using keyboard [Github #7710](https://github.com/penpot/penpot/pull/7710)
- Fix crash when using decimal (floating-point) values for X/Y or width/height [Taiga #12543](https://tree.taiga.io/project/penpot/issue/12543)
## 2.10.1

View File

@@ -27,7 +27,6 @@
[app.common.transit :as t]
[app.common.types.file :as ctf]
[app.common.uuid :as uuid]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.main :as main]

View File

@@ -4,7 +4,7 @@
<meta charset="utf-8" />
<meta name="robots" content="noindex,nofollow">
<meta http-equiv="x-ua-compatible" content="ie=edge" />
<title>{{label|upper}} API Documentation</title>
<title>Builtin API Documentation - Penpot</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
@@ -19,7 +19,7 @@
<body>
<main>
<header>
<h1>{{label|upper}}: API Documentation (v{{version}})</h1>
<h1>Penpot API Documentation (v{{version}})</h1>
<small class="menu">
[
<nav>
@@ -31,10 +31,9 @@
</header>
<section class="doc-content">
<h2>INTRODUCTION</h2>
<p>This documentation is intended to be a general overview of
the {{label}} API. If you prefer, you can
use <a href="{{openapi}}">Swagger/OpenAPI</a> as
alternative.</p>
<p>This documentation is intended to be a general overview of the penpot RPC API.
If you prefer, you can use <a href="/api/openapi.json">OpenAPI</a>
and/or <a href="/api/openapi">SwaggerUI</a> as alternative.</p>
<h2>GENERAL NOTES</h2>
@@ -44,7 +43,7 @@
that starts with <b>get-</b> in the name, can use GET HTTP
method which in many cases benefits from the HTTP cache.</p>
{% block auth-section %}
<h3>Authentication</h3>
<p>The penpot backend right now offers two way for authenticate the request:
<b>cookies</b> (the same mechanism that we use ourselves on accessing the API from the
@@ -57,10 +56,9 @@
<p>The access token can be obtained on the appropriate section on profile settings
and it should be provided using <b>`Authorization`</b> header with <b>`Token
&lt;token-string&gt;`</b> value.</p>
{% endblock %}
<h3>Content Negotiation</h3>
<p>This API operates indistinctly with: <b>`application/json`</b>
<p>The penpot API by default operates indistinctly with: <b>`application/json`</b>
and <b>`application/transit+json`</b> content types. You should specify the
desired content-type on the <b>`Accept`</b> header, the transit encoding is used
by default.</p>
@@ -77,16 +75,13 @@
standard <a href="https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API">Fetch
API</a></p>
{% block limits-section %}
<h3>Limits</h3>
<p>The rate limit work per user basis (this means that different api keys share
the same rate limit). For now the limits are not documented because we are
studying and analyzing the data. As a general rule, it should not be abused, if an
abusive use is detected, we will proceed to block the user's access to the
API.</p>
{% endblock %}
{% block webhooks-section %}
<h3>Webhooks</h3>
<p>All methods that emit webhook events are marked with flag <b>WEBHOOK</b>, the
data structure defined on each method represents the <i>payload</i> of the
@@ -102,11 +97,9 @@
"profileId": "db601c95-045f-808b-8002-361312e63531"
}
</pre>
{% endblock %}
</section>
<section class="rpc-doc-content">
<h2>METHODS REFERENCE:</h2>
<h2>RPC METHODS REFERENCE:</h2>
<ul class="rpc-items">
{% for item in methods %}
{% include "app/templates/api-doc-entry.tmpl" with item=item %}

View File

@@ -1 +0,0 @@
{% extends "app/templates/api-doc.tmpl" %}

View File

@@ -1,10 +0,0 @@
{% extends "app/templates/api-doc.tmpl" %}
{% block auth-section %}
{% endblock %}
{% block limits-section %}
{% endblock %}
{% block webhooks-section %}
{% endblock %}

View File

@@ -7,7 +7,7 @@
name="description"
content="SwaggerUI"
/>
<title>{{label|upper}} API</title>
<title>PENPOT Swagger UI</title>
<style>{{swagger-css|safe}}</style>
</head>
<body>
@@ -16,7 +16,7 @@
<script>
window.onload = () => {
window.ui = SwaggerUIBundle({
url: '{{uri}}',
url: '{{public-uri}}/api/openapi.json',
dom_id: '#swagger-ui',
presets: [
SwaggerUIBundle.presets.apis,

View File

@@ -21,8 +21,6 @@
[app.email.whitelist :as email.whitelist]
[app.http.client :as http]
[app.http.errors :as errors]
[app.http.middleware :as mw]
[app.http.security :as sec]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.rpc :as rpc]
@@ -692,9 +690,8 @@
(defmethod ig/init-key ::routes
[_ cfg]
(let [cfg (update cfg :providers d/without-nils)]
["/api" {:middleware [[mw/cors]
[sec/client-header-check]
[provider-lookup cfg]]}
["" {:middleware [[session/authz cfg]
[provider-lookup cfg]]}
["/auth/oauth"
["/:provider"
{:handler auth-handler

View File

@@ -47,7 +47,6 @@
:auto-file-snapshot-timeout "3h"
:public-uri "http://localhost:3449"
:host "localhost"
:tenant "default"
@@ -58,8 +57,6 @@
:objects-storage-backend "fs"
:objects-storage-fs-directory "assets"
:auth-token-cookie-name "auth-token"
:assets-path "/internal/assets/"
:smtp-default-reply-to "Penpot <no-reply@example.com>"
:smtp-default-from "Penpot <no-reply@example.com>"
@@ -93,7 +90,7 @@
[:secret-key {:optional true} :string]
[:tenant {:optional false} :string]
[:public-uri {:optional false} ::sm/uri]
[:public-uri {:optional false} :string]
[:host {:optional false} :string]
[:http-server-port {:optional true} ::sm/int]

View File

@@ -25,6 +25,7 @@
[app.main :as-alias main]
[app.metrics :as mtx]
[app.rpc :as-alias rpc]
[app.rpc.doc :as-alias rpc.doc]
[app.setup :as-alias setup]
[integrant.core :as ig]
[reitit.core :as r]
@@ -148,6 +149,7 @@
[:map
[::ws/routes schema:routes]
[::rpc/routes schema:routes]
[::rpc.doc/routes schema:routes]
[::oidc/routes schema:routes]
[::assets/routes schema:routes]
[::debug/routes schema:routes]
@@ -169,9 +171,8 @@
[sec/sec-fetch-metadata]
[mw/params]
[mw/format-response]
[mw/auth {:bearer (partial session/decode-token cfg)
:cookie (partial session/decode-token cfg)
:token (partial actoken/decode-token cfg)}]
[session/soft-auth cfg]
[actoken/soft-auth cfg]
[mw/parse-request]
[mw/errors errors/handle]
[mw/restrict-methods]]}
@@ -187,5 +188,9 @@
(::mgmt/routes cfg)]
(::ws/routes cfg)
(::oidc/routes cfg)
(::rpc/routes cfg)]]))
["/api" {:middleware [[mw/cors]
[sec/client-header-check]]}
(::oidc/routes cfg)
(::rpc.doc/routes cfg)
(::rpc/routes cfg)]]]))

View File

@@ -9,19 +9,23 @@
[app.common.logging :as l]
[app.config :as cf]
[app.db :as db]
[app.http.auth :as-alias http.auth]
[app.main :as-alias main]
[app.setup :as-alias setup]
[app.tokens :as tokens]))
[app.tokens :as tokens]
[yetti.request :as yreq]))
(defn decode-token
(def header-re #"(?i)^Token\s+(.*)")
(defn get-token
[request]
(some->> (yreq/get-header request "authorization")
(re-matches header-re)
(second)))
(defn- decode-token
[cfg token]
(try
(tokens/verify cfg {:token token :iss "access-token"})
(catch Throwable cause
(l/trc :hint "exception on decoding token"
:token token
:cause cause))))
(when token
(tokens/verify cfg {:token token :iss "access-token"})))
(def sql:get-token-data
"SELECT perms, profile_id, expires_at
@@ -31,27 +35,47 @@
OR (expires_at > now()));")
(defn- get-token-data
[pool claims]
[pool token-id]
(when-not (db/read-only? pool)
(when-let [token-id (-> (deref claims) (get :tid))]
(some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{})))))
(some-> (db/exec-one! pool [sql:get-token-data token-id])
(update :perms db/decode-pgarray #{}))))
(defn- wrap-soft-auth
"Soft Authentication, will be executed synchronously on the undertow
worker thread."
[handler cfg]
(letfn [(handle-request [request]
(try
(let [token (get-token request)
claims (decode-token cfg token)]
(cond-> request
(map? claims)
(assoc ::id (:tid claims))))
(catch Throwable cause
(l/trace :hint "exception on decoding malformed token" :cause cause)
request)))]
(fn [request]
(handler (handle-request request)))))
(defn- wrap-authz
"Authorization middleware, will be executed synchronously on vthread."
[handler {:keys [::db/pool]}]
(fn [{:keys [::http.auth/token-type] :as request}]
(if (= :token token-type)
(let [{:keys [perms profile-id expires-at]} (some->> (get request ::http.auth/claims)
(get-token-data pool))]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))
(fn [request]
(let [{:keys [perms profile-id expires-at]} (some->> (::id request) (get-token-data pool))]
(handler (cond-> request
(some? perms)
(assoc ::perms perms)
(some? profile-id)
(assoc ::profile-id profile-id)
(some? expires-at)
(assoc ::expires-at expires-at))))))
(handler request))))
(def soft-auth
{:name ::soft-auth
:compile (fn [& _]
(when (contains? cf/flags :access-tokens)
wrap-soft-auth))})
(def authz
{:name ::authz

View File

@@ -13,7 +13,6 @@
[app.config :as cf]
[app.http :as-alias http]
[app.http.access-token :as-alias actoken]
[app.http.auth :as-alias auth]
[app.http.session :as-alias session]
[app.util.inet :as inet]
[clojure.spec.alpha :as s]
@@ -23,14 +22,16 @@
(defn request->context
"Extracts error report relevant context data from request."
[request]
(let [claims (some-> (get request ::auth/claims) deref)]
(let [claims (-> {}
(into (::session/token-claims request))
(into (::actoken/token-claims request)))]
(-> (cf/logging-context)
(assoc :request/path (:path request))
(assoc :request/method (:method request))
(assoc :request/params (:params request))
(assoc :request/user-agent (yreq/get-header request "user-agent"))
(assoc :request/ip-addr (inet/parse-request request))
(assoc :request/profile-id (get claims :uid))
(assoc :request/profile-id (:uid claims))
(assoc :version/frontend (or (yreq/get-header request "x-frontend-version") "unknown")))))
(defmulti handle-error

View File

@@ -13,7 +13,7 @@
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
[app.http.middleware :as mw]
[app.http.access-token :refer [get-token]]
[app.main :as-alias main]
[app.rpc.commands.profile :as cmd.profile]
[app.setup :as-alias setup]
@@ -32,6 +32,20 @@
[_ params]
(assert (db/pool? (::db/pool params)) "expect valid database pool"))
(def ^:private auth
{:name ::auth
:compile
(fn [_ _]
(fn [handler shared-key]
(if shared-key
(fn [request]
(let [token (get-token request)]
(if (= token shared-key)
(handler request)
{::yres/status 403})))
(fn [_ _]
{::yres/status 403}))))})
(def ^:private default-system
{:name ::default-system
:compile
@@ -51,7 +65,7 @@
(defmethod ig/init-key ::routes
[_ cfg]
["" {:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
["" {:middleware [[auth (cf/get :management-api-shared-key)]
[default-system cfg]
[transaction]]}
["/authenticate"

View File

@@ -12,7 +12,6 @@
[app.common.schema :as-alias sm]
[app.common.transit :as t]
[app.config :as cf]
[app.http.auth :as-alias auth]
[app.http.errors :as errors]
[app.util.pointer-map :as pmap]
[cuerdas.core :as str]
@@ -241,61 +240,3 @@
(if (contains? allowed method)
(handler request)
{::yres/status 405}))))))})
(defn- wrap-auth
[handler decoders]
(let [token-re
#"(?i)^(Token|Bearer)\s+(.*)"
get-token-from-authorization
(fn [request]
(when-let [[_ token-type token] (some->> (yreq/get-header request "authorization")
(re-matches token-re))]
(if (= "token" (str/lower token-type))
[:token token]
[:bearer token])))
get-token-from-cookie
(fn [request]
(let [cname (cf/get :auth-token-cookie-name)
token (some-> (yreq/get-cookie request cname) :value)]
(when-not (str/empty? token)
[:cookie token])))
get-token
(some-fn get-token-from-cookie get-token-from-authorization)
process-request
(fn [request]
(if-let [[token-type token] (get-token request)]
(let [request (-> request
(assoc ::auth/token token)
(assoc ::auth/token-type token-type))
decoder (get decoders token-type)]
(if (fn? decoder)
(assoc request ::auth/claims (delay (decoder token)))
request))
request))]
(fn [request]
(-> request process-request handler))))
(def auth
{:name ::auth
:compile (constantly wrap-auth)})
(defn- wrap-shared-key-auth
[handler shared-key]
(if shared-key
(fn [request]
(let [key (yreq/get-header request "x-shared-key")]
(if (= key shared-key)
(handler request)
{::yres/status 403})))
(fn [_ _]
{::yres/status 403})))
(def shared-key-auth
{:name ::shared-key-auth
:compile (constantly wrap-shared-key-auth)})

View File

@@ -14,7 +14,6 @@
[app.config :as cf]
[app.db :as db]
[app.db.sql :as sql]
[app.http.auth :as-alias http.auth]
[app.http.session.tasks :as-alias tasks]
[app.main :as-alias main]
[app.setup :as-alias setup]
@@ -27,6 +26,13 @@
;; DEFAULTS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; A default cookie name for storing the session.
(def default-auth-token-cookie-name "auth-token")
;; A cookie that we can use to check from other sites of the same
;; domain if a user is authenticated.
(def default-auth-data-cookie-name "auth-data")
;; Default value for cookie max-age
(def default-cookie-max-age (ct/duration {:days 7}))
@@ -163,7 +169,7 @@
[{:keys [::manager]}]
(assert (manager? manager) "expected valid session manager")
(fn [request response]
(let [cname (cf/get :auth-token-cookie-name)
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (yreq/get-cookie request cname)]
(l/trc :hint "delete" :profile-id (:profile-id request))
(some->> (:value cookie) (delete! manager))
@@ -177,14 +183,21 @@
(tokens/generate cfg {:iss "authentication"
:iat created-at
:uid profile-id}))
(defn decode-token
(defn- decode-token
[cfg token]
(try
(tokens/verify cfg {:token token :iss "authentication"})
(catch Throwable cause
(l/trc :hint "exception on decoding token"
:token token
:cause cause))))
(when token
(tokens/verify cfg {:token token :iss "authentication"})))
(defn- get-token
[request]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
cookie (some-> (yreq/get-cookie request cname) :value)]
(when-not (str/empty? cookie)
cookie)))
(defn- get-session
[manager token]
(some->> token (read manager)))
(defn- renew-session?
[{:keys [updated-at] :as session}]
@@ -192,38 +205,44 @@
(let [elapsed (ct/diff updated-at (ct/now))]
(neg? (compare default-renewal-max-age elapsed)))))
(defn- wrap-authz
(defn- wrap-soft-auth
[handler {:keys [::manager] :as cfg}]
(assert (manager? manager) "expected valid session manager")
(fn [{:keys [::http.auth/token-type] :as request}]
(cond
(= token-type :cookie)
(let [session (some->> (get request ::http.auth/token)
(read manager))
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::id (:id session))))
(letfn [(handle-request [request]
(try
(let [token (get-token request)
claims (decode-token cfg token)]
(cond-> request
(map? claims)
(-> (assoc ::token-claims claims)
(assoc ::token token))))
(catch Throwable cause
(l/trc :hint "exception on decoding malformed token" :cause cause)
request)))]
response (handler request)]
(fn [request]
(handler (handle-request request)))))
(if (renew-session? session)
(let [session (update! manager session)]
(-> response
(assign-auth-token-cookie session)))
response))
(defn- wrap-authz
[handler {:keys [::manager]}]
(assert (manager? manager) "expected valid session manager")
(fn [request]
(let [session (get-session manager (::token request))
request (cond-> request
(some? session)
(assoc ::profile-id (:profile-id session)
::id (:id session)))
response (handler request)]
(= token-type :bearer)
(let [session (some->> (get request ::http.auth/token)
(read manager))
request (cond-> request
(some? session)
(-> (assoc ::profile-id (:profile-id session))
(assoc ::id (:id session))))]
(handler request))
(if (renew-session? session)
(let [session (update! manager session)]
(-> response
(assign-auth-token-cookie session)))
response))))
:else
(handler request))))
(def soft-auth
{:name ::soft-auth
:compile (constantly wrap-soft-auth)})
(def authz
{:name ::authz
@@ -240,7 +259,7 @@
secure? (contains? cf/flags :secure-session-cookies)
strict? (contains? cf/flags :strict-session-cookies)
cors? (contains? cf/flags :cors)
name (cf/get :auth-token-cookie-name)
name (cf/get :auth-token-cookie-name default-auth-token-cookie-name)
comment (str "Renewal at: " (ct/format-inst renewal :rfc1123))
cookie {:path "/"
:http-only true
@@ -253,7 +272,7 @@
(defn- clear-auth-token-cookie
[response]
(let [cname (cf/get :auth-token-cookie-name)]
(let [cname (cf/get :auth-token-cookie-name default-auth-token-cookie-name)]
(update response :cookies assoc cname {:path "/" :value "" :max-age 0})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -21,7 +21,7 @@
[app.http.client :as-alias http.client]
[app.http.debug :as-alias http.debug]
[app.http.management :as mgmt]
[app.http.session :as session]
[app.http.session :as-alias session]
[app.http.session.tasks :as-alias session.tasks]
[app.http.websocket :as http.ws]
[app.loggers.webhooks :as-alias webhooks]
@@ -31,6 +31,7 @@
[app.redis :as-alias rds]
[app.rpc :as-alias rpc]
[app.rpc.climit :as-alias climit]
[app.rpc.doc :as-alias rpc.doc]
[app.setup :as-alias setup]
[app.srepl :as-alias srepl]
[app.storage :as-alias sto]
@@ -279,6 +280,7 @@
{::session/manager (ig/ref ::session/manager)
::db/pool (ig/ref ::db/pool)
::rpc/routes (ig/ref ::rpc/routes)
::rpc.doc/routes (ig/ref ::rpc.doc/routes)
::setup/props (ig/ref ::setup/props)
::mtx/routes (ig/ref ::mtx/routes)
::oidc/routes (ig/ref ::oidc/routes)
@@ -319,7 +321,6 @@
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
:app.nitrate/instance (ig/ref :app.nitrate/instance)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::ldap/provider (ig/ref ::ldap/provider)
@@ -336,29 +337,14 @@
::email/blacklist (ig/ref ::email/blacklist)
::email/whitelist (ig/ref ::email/whitelist)}
:app.nitrate/instance
{::http.client/client (ig/ref ::http.client/client)}
:app.rpc/management-methods
{::http.client/client (ig/ref ::http.client/client)
::db/pool (ig/ref ::db/pool)
::rds/pool (ig/ref ::rds/pool)
::wrk/executor (ig/ref ::wrk/netty-executor)
::session/manager (ig/ref ::session/manager)
::sto/storage (ig/ref ::sto/storage)
::mtx/metrics (ig/ref ::mtx/metrics)
::mbus/msgbus (ig/ref ::mbus/msgbus)
::rds/client (ig/ref ::rds/client)
::setup/props (ig/ref ::setup/props)}
:app.rpc.doc/routes
{:app.rpc/methods (ig/ref :app.rpc/methods)}
::rpc/routes
{::rpc/methods (ig/ref :app.rpc/methods)
::rpc/management-methods (ig/ref :app.rpc/management-methods)
;; FIXME: revisit if db/pool is necessary here
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
{::rpc/methods (ig/ref :app.rpc/methods)
::db/pool (ig/ref ::db/pool)
::session/manager (ig/ref ::session/manager)
::setup/props (ig/ref ::setup/props)}
::wrk/registry
{::mtx/metrics (ig/ref ::mtx/metrics)

View File

@@ -13,14 +13,11 @@
[app.common.schema :as sm]
[app.common.spec :as us]
[app.common.time :as ct]
[app.common.uri :as u]
[app.config :as cf]
[app.db :as db]
[app.http :as-alias http]
[app.http.access-token :as actoken]
[app.http.client :as-alias http.client]
[app.http.middleware :as mw]
[app.http.security :as sec]
[app.http.session :as session]
[app.loggers.audit :as audit]
[app.main :as-alias main]
@@ -29,7 +26,6 @@
[app.redis :as rds]
[app.rpc.climit :as climit]
[app.rpc.cond :as cond]
[app.rpc.doc :as doc]
[app.rpc.helpers :as rph]
[app.rpc.retry :as retry]
[app.rpc.rlimit :as rlimit]
@@ -40,6 +36,7 @@
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[promesa.core :as p]
[yetti.request :as yreq]
[yetti.response :as yres]))
@@ -47,7 +44,7 @@
(defn- default-handler
[_]
(ex/raise :type :not-found))
(p/rejected (ex/error :type :not-found)))
(defn- handle-response-transformation
[response request mdata]
@@ -95,46 +92,43 @@
(str/blank? origin))
origin)))
(defn- make-rpc-handler
(defn- rpc-handler
"Ring handler that dispatches cmd requests and convert between
internal async flow into ring async flow."
[methods]
(let [methods (update-vals methods peek)]
(fn [{:keys [params path-params method] :as request}]
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
[methods {:keys [params path-params method] :as request}]
(let [handler-name (:type path-params)
etag (yreq/get-header request "if-none-match")
profile-id (or (::session/profile-id request)
(::actoken/profile-id request))
ip-addr (inet/parse-request request)
session-id (get-external-session-id request)
event-origin (get-external-event-origin request)
ip-addr (inet/parse-request request)
session-id (get-external-session-id request)
event-origin (get-external-event-origin request)
data (-> params
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::external-session-id session-id)
(assoc ::external-event-origin event-origin)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (-> params
(assoc ::handler-name handler-name)
(assoc ::ip-addr ip-addr)
(assoc ::request-at (ct/now))
(assoc ::external-session-id session-id)
(assoc ::external-event-origin event-origin)
(assoc ::session/id (::session/id request))
(assoc ::cond/key etag)
(cond-> (uuid? profile-id)
(assoc ::profile-id profile-id)))
data (vary-meta data assoc ::http/request request)
handler-fn (get methods (keyword handler-name) default-handler)]
data (vary-meta data assoc ::http/request request)
handler-fn (get methods (keyword handler-name) default-handler)]
(when (and (or (= method :get)
(= method :head))
(not (str/starts-with? handler-name "get-")))
(ex/raise :type :restriction
:code :method-not-allowed
:hint "method not allowed for this request"))
(when (and (or (= method :get)
(= method :head))
(not (str/starts-with? handler-name "get-")))
(ex/raise :type :restriction
:code :method-not-allowed
:hint "method not allowed for this request"))
;; FIXME: why we have this cond enabled here, we need to move it outside this handler
(binding [cond/*enabled* true]
(let [response (handler-fn data)]
(handle-response request response)))))))
(binding [cond/*enabled* true]
(let [response (handler-fn data)]
(handle-response request response)))))
(defn- wrap-metrics
"Wrap service method with metrics measurement."
@@ -211,7 +205,7 @@
::sm/explain (explain params)))))))
f))
(defn- wrap
(defn- wrap-all
[cfg f mdata]
(as-> f $
(wrap-db-transaction cfg $ mdata)
@@ -225,30 +219,17 @@
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(defn- wrap-management
(defn- wrap
[cfg f mdata]
(as-> f $
(wrap-db-transaction cfg $ mdata)
(retry/wrap-retry cfg $ mdata)
(climit/wrap cfg $ mdata)
(wrap-metrics cfg $ mdata)
(wrap-audit cfg $ mdata)
(wrap-spec-conform cfg $ mdata)
(wrap-params-validation cfg $ mdata)
(wrap-authentication cfg $ mdata)))
(l/trc :hint "register method" :name (::sv/name mdata))
(let [f (wrap-all cfg f mdata)]
(partial f cfg)))
(defn- process-method
[cfg module wrap-fn [f mdata]]
(l/trc :hint "add method" :module module :name (::sv/name mdata))
(let [f (wrap-fn cfg f mdata)
k (keyword (::sv/name mdata))]
[k [mdata (partial f cfg)]]))
[cfg [vfn mdata]]
[(keyword (::sv/name mdata)) [mdata (wrap cfg vfn mdata)]])
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; API METHODS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- resolve-methods
(defn- resolve-command-methods
[cfg]
(let [cfg (assoc cfg ::type "command" ::metrics-id :rpc-command-timing)]
(->> (sv/scan-ns
@@ -277,7 +258,7 @@
'app.rpc.commands.verify-token
'app.rpc.commands.viewer
'app.rpc.commands.webhooks)
(map (partial process-method cfg "rpc" wrap))
(map (partial process-method cfg))
(into {}))))
(def ^:private schema:methods-params
@@ -301,49 +282,7 @@
(defmethod ig/init-key ::methods
[_ cfg]
(let [cfg (d/without-nils cfg)]
(resolve-methods cfg)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; MANAGEMENT METHODS
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- resolve-management-methods
[cfg]
(let [cfg (assoc cfg ::type "management" ::metrics-id :rpc-management-timing)]
(->> (sv/scan-ns
'app.rpc.management.subscription)
(map (partial process-method cfg "management" wrap-management))
(into {}))))
(def ^:private schema:management-methods-params
[:map {:title "management-methods-params"}
::session/manager
::http.client/client
::db/pool
::rds/pool
::mbus/msgbus
::sto/storage
::mtx/metrics
::setup/props])
(defmethod ig/assert-key ::management-methods
[_ params]
(assert (sm/check schema:management-methods-params params)))
(defmethod ig/init-key ::management-methods
[_ cfg]
(let [cfg (d/without-nils cfg)]
(resolve-management-methods cfg)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ROUTES
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- redirect
[href]
(fn [_]
{::yres/status 308
::yres/headers {"location" (str href)}}))
(resolve-command-methods cfg)))
(def ^:private schema:methods
[:map-of :keyword [:tuple :map ::sm/fn]])
@@ -358,49 +297,11 @@
(assert (db/pool? (::db/pool params)) "expect valid database pool")
(assert (some? (::setup/props params)))
(assert (session/manager? (::session/manager params)) "expect valid session manager")
(assert (valid-methods? (::methods params)) "expect valid methods map")
(assert (valid-methods? (::management-methods params)) "expect valid methods map"))
(assert (valid-methods? (::methods params)) "expect valid methods map"))
(defmethod ig/init-key ::routes
[_ {:keys [::methods ::management-methods] :as cfg}]
(let [public-uri (cf/get :public-uri)]
["/api"
["/management"
["/methods/:type"
{:middleware [[mw/shared-key-auth (cf/get :management-api-shared-key)]
[session/authz cfg]]
:handler (make-rpc-handler management-methods)}]
(doc/routes :methods management-methods
:label "management"
:base-uri (u/join public-uri "/api/management")
:description "MANAGEMENT API")]
["/main"
["/methods/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
:handler (make-rpc-handler methods)}]
(doc/routes :methods methods
:label "main"
:base-uri (u/join public-uri "/api/main")
:description "MAIN API")]
;; BACKWARD COMPATIBILITY
["/_doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
["/doc" {:handler (redirect (u/join public-uri "/api/main/doc"))}]
["/openapi" {:handler (redirect (u/join public-uri "/api/main/doc/openapi"))}]
["/openapi.join" {:handler (redirect (u/join public-uri "/api/main/doc/openapi.json"))}]
["/rpc/command/:type"
{:middleware [[mw/cors]
[sec/client-header-check]
[session/authz cfg]
[actoken/authz cfg]]
:handler (make-rpc-handler methods)}]]))
[_ {:keys [::methods] :as cfg}]
(let [methods (update-vals methods peek)]
[["/rpc" {:middleware [[session/authz cfg]
[actoken/authz cfg]]}
["/command/:type" {:handler (partial rpc-handler methods)}]]]))

View File

@@ -28,7 +28,6 @@
expires-at (some-> expiration (ct/in-future))
created-at (ct/now)
token (tokens/generate cfg {:iss "access-token"
:uid profile-id
:iat created-at
:tid token-id})

View File

@@ -18,7 +18,6 @@
[app.db.sql :as-alias sql]
[app.email :as eml]
[app.http.session :as session]
[app.nitrate :as nitrate]
[app.loggers.audit :as audit]
[app.main :as-alias main]
[app.media :as media]
@@ -99,14 +98,9 @@
;; no profile-id is in session, and when db call raises not found. In all other
;; cases we need to reraise the exception.
(try
(let [nitrate (get cfg ::nitrate/instance)
;; org ((get nitrate :get-organization) profile-id)
;; org (nitrate/call cfg :get-organization {:profile-id profile-id})
(-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props)))
(-> (get-profile pool profile-id)
(strip-private-attrs)
(update :props filter-props))
(catch Throwable _
{:id uuid/zero :fullname "Anonymous User"})))

View File

@@ -16,7 +16,6 @@
[app.common.schema.desc-native :as smdn]
[app.common.schema.openapi :as oapi]
[app.common.schema.registry :as sr]
[app.common.uri :as u]
[app.config :as cf]
[app.http.sse :as-alias sse]
[app.loggers.webhooks :as-alias webhooks]
@@ -26,6 +25,7 @@
[clojure.java.io :as io]
[clojure.spec.alpha :as s]
[cuerdas.core :as str]
[integrant.core :as ig]
[pretty-spec.core :as ps]
[yetti.response :as-alias yres]))
@@ -33,8 +33,8 @@
;; DOC (human readable)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- context
[{:keys [methods entrypoint label openapi]}]
(defn- prepare-doc-context
[methods]
(letfn [(fmt-spec [mdata]
(when-let [spec (ex/ignoring (s/spec (::sv/spec mdata)))]
(with-out-str
@@ -62,10 +62,8 @@
:added (::added mdata)
:changes (some->> (::changes mdata) (partition-all 2) (map vec))
:spec (fmt-spec mdata)
:entrypoint (-> entrypoint
(u/ensure-path-slash)
(u/join (::sv/name mdata))
(str))
:entrypoint (str (cf/get :public-uri) "/api/rpc/command/" (::sv/name mdata))
:params-schema-js (fmt-schema :js mdata ::sm/params)
:result-schema-js (fmt-schema :js mdata ::sm/result)
:webhook-schema-js (fmt-schema :js mdata ::sm/webhook)
@@ -74,9 +72,6 @@
:webhook-schema-clj (fmt-schema :clj mdata ::sm/webhook)})]
{:version (:main cf/version)
:label label
:entrypoint (str entrypoint)
:openapi (str openapi)
:methods
(->> methods
(map val)
@@ -85,19 +80,17 @@
(map get-context)
(sort-by (juxt :module :name)))}))
(defn- handler
[& {:keys [template] :as options}]
(defn- doc-handler
[context]
(if (contains? cf/flags :backend-api-doc)
(let [context (delay (context options))
template (or template "app/templates/api-doc.tmpl")]
(fn [request]
(let [params (:query-params request)
pstyle (:type params "js")
context (assoc @context :param-style pstyle)]
(fn [request]
(let [params (:query-params request)
pstyle (:type params "js")
context (assoc @context :param-style pstyle)]
{::yres/status 200
::yres/body (-> (io/resource template)
(tmpl/render context))})))
{::yres/status 200
::yres/body (-> (io/resource "app/templates/api-doc.tmpl")
(tmpl/render context))}))
(fn [_]
{::yres/status 404})))
@@ -105,8 +98,8 @@
;; OPENAPI / SWAGGER (v3.1)
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn- openapi-context
[{:keys [methods entrypoint description]}]
(defn prepare-openapi-context
[methods]
(let [definitions (atom {})
options {:registry sr/default-registry
::oapi/definitions-path "#/components/schemas/"
@@ -119,9 +112,7 @@
(fn [tsx schema]
(let [schema (sm/schema schema)
example (sm/generate schema)
example (sm/encode schema example output-transformer)
example (json/encode example :key-fn json/write-camel-key)]
example (sm/encode schema example output-transformer)]
{:default
{:description "A default response"
:content
@@ -132,9 +123,7 @@
gen-params-doc
(fn [tsx schema]
(let [example (sm/generate schema)
example (sm/encode schema example output-transformer)
example (json/encode example :key-fn json/write-camel-key)]
example (sm/encode schema example output-transformer)]
{:required true
:content
{"application/json"
@@ -169,35 +158,34 @@
(map gen-method-doc)
(sort-by (juxt :module :name))
(map (fn [doc]
[(:name doc) (:repr doc)]))
[(str/ffmt "/command/%" (:name doc)) (:repr doc)]))
(into {})))]
{:openapi "3.0.0"
:info {:version (:main cf/version)}
:servers [{:url (str entrypoint)
:description (or description "")}]
:servers [{:url (str/ffmt "%/api/rpc" (cf/get :public-uri))
;; :description "penpot backend"
}]
:paths paths
:components {:schemas @definitions}}))
(defn- openapi-json-handler
[& {:as options}]
(defn openapi-json-handler
[context]
(if (contains? cf/flags :backend-openapi-doc)
(let [context (delay (openapi-context options))]
(fn [_]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode @context)}))
(fn [_]
{::yres/status 200
::yres/headers {"content-type" "application/json; charset=utf-8"}
::yres/body (json/encode @context)})
(fn [_]
{::yres/status 404})))
(defn- openapi-handler
[& {:keys [uri label]}]
(defn openapi-handler
[]
(if (contains? cf/flags :backend-openapi-doc)
(fn [_]
(let [swagger-js (slurp (io/resource "app/assets/swagger-ui-4.18.3.js"))
swagger-cs (slurp (io/resource "app/assets/swagger-ui-4.18.3.css"))
context {:uri (str uri)
:label label
context {:public-uri (cf/get :public-uri)
:swagger-js swagger-js
:swagger-css swagger-cs}]
{::yres/status 200
@@ -208,43 +196,27 @@
{::yres/status 404})))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; ROUTES HELPER
;; MODULE INIT
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(defn routes
[& {:keys [label base-uri description methods]}]
(let [entrypoint
(-> base-uri
(u/ensure-path-slash)
(u/join "methods"))
(defmethod ig/assert-key ::routes
[_ params]
(assert (sm/valid? ::rpc/methods (::rpc/methods params)) "expected valid methods"))
openapi
(-> base-uri
(u/ensure-path-slash)
(u/join "doc/openapi"))
(defmethod ig/init-key ::routes
[_ {:keys [::rpc/methods] :as cfg}]
[(let [context (delay (prepare-doc-context methods))]
[["/_doc"
{:handler (doc-handler context)
:allowed-methods #{:get}}]
["/doc"
{:handler (doc-handler context)
:allowed-methods #{:get}}]])
template
(case label
"management" "app/templates/management-api-doc.tmpl"
"main" "app/templates/main-api-doc.tmpl")]
["/doc"
["" {:handler (handler :methods methods
:label label
:entrypoint entrypoint
:openapi openapi
:template template)
:allowed-methods #{:get}}]
["/openapi"
{:handler (openapi-handler
:uri (u/join openapi "openapi.json")
:label label)
:allowed-methods #{:get}}]
["/openapi.json"
{:handler (openapi-json-handler {:entrypoint entrypoint
:description description
:methods methods})
:allowed-methods #{:get}}]]))
(let [context (delay (prepare-openapi-context methods))]
[["/openapi"
{:handler (openapi-handler)
:allowed-methods #{:get}}]
["/openapi.json"
{:handler (openapi-json-handler context)
:allowed-methods #{:get}}]])])

View File

@@ -1,183 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.rpc.management.subscription
(:require
[app.common.logging :as l]
[app.common.schema :as sm]
[app.common.schema.generators :as sg]
[app.common.time :as ct]
[app.db :as db]
[app.rpc :as-alias rpc]
[app.rpc.commands.profile :as profile]
[app.rpc.doc :as doc]
[app.util.services :as sv]))
;; ---- RPC METHOD: AUTHENTICATE
(def ^:private
schema:authenticate-params
[:map {:title "authenticate-params"}])
(def ^:private
schema:authenticate-result
[:map {:title "authenticate-result"}
[:profile-id ::sm/uuid]])
(sv/defmethod ::auth
{::doc/added "2.12"
::sm/params schema:authenticate-params
::sm/result schema:authenticate-result}
[_ {:keys [::rpc/profile-id]}]
{:profile-id profile-id})
;; ---- RPC METHOD: GET-CUSTOMER
;; FIXME: move to app.common.time
(def ^:private schema:timestamp
(sm/type-schema
{:type ::timestamp
:pred ct/inst?
:type-properties
{:title "inst"
:description "The same as :app.common.time/inst but encodes to epoch"
:error/message "should be an instant"
:gen/gen (->> (sg/small-int)
(sg/fmap (fn [v] (ct/inst v))))
:decode/string #(some-> % ct/inst)
:encode/string #(some-> % inst-ms)
:decode/json #(some-> % ct/inst)
:encode/json #(some-> % inst-ms)}}))
(def ^:private schema:subscription
[:map {:title "Subscription"}
[:id ::sm/text]
[:customer-id ::sm/text]
[:type [:enum
"unlimited"
"professional"
"enterprise"]]
[:status [:enum
"active"
"canceled"
"incomplete"
"incomplete_expired"
"past_due"
"paused"
"trialing"
"unpaid"]]
[:billing-period [:enum
"month"
"day"
"week"
"year"]]
[:quantity :int]
[:description [:maybe ::sm/text]]
[:created-at schema:timestamp]
[:start-date [:maybe schema:timestamp]]
[:ended-at [:maybe schema:timestamp]]
[:trial-end [:maybe schema:timestamp]]
[:trial-start [:maybe schema:timestamp]]
[:cancel-at [:maybe schema:timestamp]]
[:canceled-at [:maybe schema:timestamp]]
[:current-period-end [:maybe schema:timestamp]]
[:current-period-start [:maybe schema:timestamp]]
[:cancel-at-period-end :boolean]
[:cancellation-details
[:map {:title "CancellationDetails"}
[:comment [:maybe ::sm/text]]
[:reason [:maybe ::sm/text]]
[:feedback [:maybe
[:enum
"customer_service"
"low_quality"
"missing_feature"
"other"
"switched_service"
"too_complex"
"too_expensive"
"unused"]]]]]])
(def ^:private sql:get-customer-slots
"WITH teams AS (
SELECT tpr.team_id AS id,
tpr.profile_id AS profile_id
FROM team_profile_rel AS tpr
WHERE tpr.is_owner IS true
AND tpr.profile_id = ?
), teams_with_slots AS (
SELECT tpr.team_id AS id,
count(*) AS total
FROM team_profile_rel AS tpr
WHERE tpr.team_id IN (SELECT id FROM teams)
AND tpr.can_edit IS true
GROUP BY 1
ORDER BY 2
)
SELECT max(total) AS total FROM teams_with_slots;")
(defn- get-customer-slots
[cfg profile-id]
(let [result (db/exec-one! cfg [sql:get-customer-slots profile-id])]
(:total result)))
(def ^:private schema:get-customer-params
[:map])
(def ^:private schema:get-customer-result
[:map
[:id ::sm/uuid]
[:name :string]
[:num-editors ::sm/int]
[:subscription {:optional true} schema:subscription]])
(sv/defmethod ::get-customer
{::doc/added "2.12"
::sm/params schema:get-customer-params
::sm/result schema:get-customer-result}
[cfg {:keys [::rpc/profile-id]}]
(let [profile (profile/get-profile cfg profile-id)]
{:id (get profile :id)
:name (get profile :fullname)
:email (get profile :email)
:num-editors (get-customer-slots cfg profile-id)
:subscription (-> profile :props :subscription)}))
;; ---- RPC METHOD: GET-CUSTOMER
(def ^:private schema:update-customer-params
[:map
[:subscription [:maybe schema:subscription]]])
(def ^:private schema:update-customer-result
[:map])
(sv/defmethod ::update-customer
{::doc/added "2.12"
::sm/params schema:update-customer-params
::sm/result schema:update-customer-result}
[cfg {:keys [::rpc/profile-id subscription]}]
(let [{:keys [props] :as profile}
(profile/get-profile cfg profile-id ::db/for-update true)
props
(assoc props :subscription subscription)]
(l/dbg :hint "update customer"
:profile-id (str profile-id)
:subscription-type (get subscription :type)
:subscription-status (get subscription :status)
:subscription-quantity (get subscription :quantity))
(db/update! cfg :profile
{:props (db/tjson props)}
{:id profile-id}
{::db/return-keys false})
nil))

View File

@@ -218,9 +218,6 @@
(when (or (nil? revn) (= revn (:revn file)))
file)))
;; FIXME: we should skip files that does not match the revn on the
;; props and add proper schema for this task props
(defn- process-file!
[cfg {:keys [file-id] :as props}]
(if-let [file (get-file cfg props)]

View File

@@ -8,7 +8,6 @@
"A maintenance task that is responsible of properly scheduling the
file-gc task for all files that matches the eligibility threshold."
(:require
[app.common.logging :as l]
[app.common.time :as ct]
[app.config :as cf]
[app.db :as db]
@@ -22,24 +21,25 @@
f.modified_at
FROM file AS f
WHERE f.has_media_trimmed IS false
AND f.modified_at < ?
AND f.modified_at < now() - ?::interval
AND f.deleted_at IS NULL
ORDER BY f.modified_at DESC
FOR UPDATE OF f
SKIP LOCKED")
(defn- get-candidates
[{:keys [::db/conn ::min-age] :as cfg}]
(let [min-age (db/interval min-age)]
(db/plan conn [sql:get-candidates min-age] {:fetch-size 10})))
(defn- schedule!
[{:keys [::db/conn] :as cfg} threshold]
[cfg]
(let [total (reduce (fn [total {:keys [id modified-at revn]}]
(let [params {:file-id id :revn revn}]
(l/trc :hint "schedule"
:file-id (str id)
:revn revn
:modified-at (ct/format-inst modified-at))
(let [params {:file-id id :modified-at modified-at :revn revn}]
(wrk/submit! (assoc cfg ::wrk/params params))
(inc total)))
0
(db/plan conn [sql:get-candidates threshold] {:fetch-size 10}))]
(get-candidates cfg))]
{:processed total}))
(defmethod ig/assert-key ::handler
@@ -53,12 +53,12 @@
(defmethod ig/init-key ::handler
[_ cfg]
(fn [{:keys [props] :as task}]
(let [threshold (-> (ct/duration (or (:min-age props) (::min-age cfg)))
(ct/in-past))]
(let [min-age (ct/duration (or (:min-age props) (::min-age cfg)))]
(-> cfg
(assoc ::db/rollback (:rollback? props))
(assoc ::min-age min-age)
(assoc ::wrk/task :file-gc)
(assoc ::wrk/priority 10)
(assoc ::wrk/mark-retries 0)
(assoc ::wrk/delay 10000)
(db/tx-run! schedule! threshold)))))
(assoc ::wrk/delay 1000)
(db/tx-run! schedule!)))))

View File

@@ -9,7 +9,7 @@
[app.common.exceptions :as ex]
[selmer.parser :as sp]))
(sp/cache-off!)
;; (sp/cache-off!)
(defn render
[path context]

View File

@@ -77,8 +77,8 @@
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
(def ^:private sql:insert-new-task
"insert into task (id, name, props, queue, label, priority, max_retries, created_at, modified_at, scheduled_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
"insert into task (id, name, props, queue, label, priority, max_retries, scheduled_at)
values (?, ?, ?, ?, ?, ?, ?, now() + ?)
returning id")
(def ^:private
@@ -88,7 +88,7 @@
AND queue=?
AND label=?
AND status = 'new'
AND scheduled_at > ?")
AND scheduled_at > now()")
(def ^:private schema:options
[:map {:title "submit-options"}
@@ -111,19 +111,17 @@
(check-options! options)
(let [delay (ct/duration delay)
now (ct/now)
scheduled-at (-> (ct/plus now delay)
(ct/truncate :millisecond))
props (db/tjson params)
id (uuid/next)
tenant (cf/get :tenant)
task (d/name task)
queue (str/ffmt "%:%" tenant (d/name queue))
conn (db/get-connectable options)
deleted (when dedupe
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label now])
(db/get-update-count)))]
(let [duration (ct/duration delay)
interval (db/interval duration)
props (db/tjson params)
id (uuid/next)
tenant (cf/get :tenant)
task (d/name task)
queue (str/ffmt "%:%" tenant (d/name queue))
conn (db/get-connectable options)
deleted (when dedupe
(-> (db/exec-one! conn [sql:remove-not-started-tasks task queue label])
:next.jdbc/update-count))]
(l/trc :hint "submit task"
:name task
@@ -131,13 +129,11 @@
:queue queue
:label label
:dedupe (boolean dedupe)
:delay (ct/format-duration delay)
:delay (ct/format-duration duration)
:replace (or deleted 0))
(db/exec-one! conn [sql:insert-new-task id task props queue
label priority max-retries
now now scheduled-at])
label priority max-retries interval])
id))
(defn invoke!

View File

@@ -158,9 +158,7 @@
(inst-ms (:scheduled-at task)))
(l/wrn :hint "skiping task, rescheduled"
:task-id task-id
:runner-id id
:scheduled-at (ct/format-inst (:scheduled-at task))
:expected-scheduled-at (ct/format-inst scheduled-at))
:runner-id id)
:else
(let [result (run-task cfg task)]
@@ -181,8 +179,7 @@
{:error explain
:status "retry"
:modified-at now
:scheduled-at (-> (ct/plus now delay)
(ct/truncate :millisecond))
:scheduled-at (ct/plus now delay)
:retry-num nretry}
{:id (:id task)})
nil))

View File

@@ -549,44 +549,6 @@
(io/copy r sw)
(.toString sw))))
(defn parse-sse
[content]
(let [state
(reduce (fn [{:keys [events data event id] :as state} line]
(cond
;; empty line → dispatch event if we have data
(str/blank? line)
(if (seq data)
(-> state
(update :events conj {:event (or event "message")
:data (-> (str/join "\n" data))})
(assoc :data [] :event nil))
state)
;; comment line (starts with :)
(str/starts-with? line ":")
state
:else
(let [[field raw-value] (str/split line #":" 2)
value (some-> raw-value (str/replace #"^ " ""))]
(case field
"data" (update state :data conj (or value ""))
"event" (assoc state :event value)
;; ignore retry and unknown fields
state))))
{:events [] :data [] :event nil}
(str/split content #"\r?\n"))
;; handle unterminated last event (no trailing blank line)
state (if (seq (:data state))
(update state :events conj
{:event (or (:event state) "message")
:data (str/join "\n" (:data state))})
state)]
(:events state)))
(defn consume-sse
[callback]
(let [{:keys [::yres/status ::yres/body ::yres/headers] :as response} (callback {})
@@ -596,9 +558,12 @@
(try
(px/exec! :virtual #(rcp/write-body-to-stream body nil output))
(into []
(map (fn [{:keys [event data]}]
[(keyword event)
(tr/decode-str data)]))
(parse-sse (slurp' input)))
(map (fn [event]
(let [[item1 item2] (re-seq #"(.*): (.*)\n?" event)]
[(keyword (nth item1 2))
(tr/decode-str (nth item2 2))])))
(-> (slurp' input)
(str/split "\n\n")))
(finally
(.close input)))))

View File

@@ -0,0 +1,57 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.http-middleware-access-token-test
(:require
[app.db :as db]
[app.http.access-token]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(t/deftest soft-auth-middleware
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! nil)
handler (#'app.http.access-token/wrap-soft-auth
(fn [req] (vreset! request req))
th/*system*)]
(with-mocks [m1 {:target 'app.http.access-token/get-token
:return nil}]
(handler {})
(t/is (= {} @request)))
(with-mocks [m1 {:target 'app.http.access-token/get-token
:return (:token token)}]
(handler {})
(let [token-id (get @request :app.http.access-token/id)]
(t/is (= token-id (:id token)))))))
(t/deftest authz-middleware
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! {})
handler (#'app.http.access-token/wrap-authz
(fn [req] (vreset! request req))
th/*system*)]
(handler nil)
(t/is (nil? @request))
(handler {:app.http.access-token/id (:id token)})
(t/is (= #{} (:app.http.access-token/perms @request)))
(t/is (= (:id profile) (:app.http.access-token/profile-id @request)))))

View File

@@ -1,161 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns backend-tests.http-middleware-test
(:require
[app.common.time :as ct]
[app.db :as db]
[app.http.access-token]
[app.http.auth :as-alias auth]
[app.http.middleware :as mw]
[app.http.session :as session]
[app.main :as-alias main]
[app.rpc :as-alias rpc]
[app.rpc.commands.access-token]
[app.tokens :as tokens]
[backend-tests.helpers :as th]
[clojure.test :as t]
[mockery.core :refer [with-mocks]]
[yetti.request :as yreq]
[yetti.response :as yres]))
(t/use-fixtures :once th/state-init)
(t/use-fixtures :each th/database-reset)
(defrecord DummyRequest [headers cookies]
yreq/IRequestCookies
(get-cookie [_ name]
{:value (get cookies name)})
yreq/IRequest
(get-header [_ name]
(get headers name)))
(t/deftest auth-middleware-1
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(handler (->DummyRequest {"authorization" "Token aaaa"} {}))
(t/is (= :token (::auth/token-type @request)))
(t/is (= "aaaa" (::auth/token @request)))))
(t/deftest auth-middleware-2
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {"authorization" "Bearer aaaa"} {}))
(t/is (= :bearer (::auth/token-type @request)))
(t/is (= "aaaa" (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))))
(t/deftest auth-middleware-3
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(t/is (= :cookie (::auth/token-type @request)))
(t/is (= "foobar" (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))))
(t/deftest auth-middleware-4
(let [request (volatile! nil)
handler (#'app.http.middleware/wrap-auth
(fn [req] (vreset! request req))
{:cookie (fn [_] "foobaz")})]
(handler (->DummyRequest {} {}))
(t/is (nil? (::auth/token-type @request)))
(t/is (nil? (::auth/token @request)))
(t/is (nil? (::auth/claims @request)))
(handler (->DummyRequest {} {"auth-token" "foobar"}))
(t/is (= :cookie (::auth/token-type @request)))
(t/is (= "foobar" (::auth/token @request)))
(t/is (delay? (::auth/claims @request)))
(t/is (= "foobaz" (-> @request ::auth/claims deref)))))
(t/deftest shared-key-auth
(let [handler (#'app.http.middleware/wrap-shared-key-auth
(fn [req] {::yres/status 200})
"secret-key")]
(let [response (handler (->DummyRequest {} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key2"} {}))]
(t/is (= 403 (::yres/status response))))
(let [response (handler (->DummyRequest {"x-shared-key" "secret-key"} {}))]
(t/is (= 200 (::yres/status response))))))
(t/deftest access-token-authz
(let [profile (th/create-profile* 1)
token (db/tx-run! th/*system* app.rpc.commands.access-token/create-access-token (:id profile) "test" nil)
request (volatile! {})
handler (#'app.http.access-token/wrap-authz
(fn [req] (vreset! request req))
th/*system*)]
(handler nil)
(t/is (nil? @request))
(handler {::auth/claims (delay {:tid (:id token)})
::auth/token-type :token})
(t/is (= #{} (:app.http.access-token/perms @request)))
(t/is (= (:id profile) (:app.http.access-token/profile-id @request)))))
(t/deftest session-authz
(let [manager (session/inmemory-manager)
profile (th/create-profile* 1)
handler (-> (fn [req] req)
(#'session/wrap-authz {::session/manager manager})
(#'mw/wrap-auth {}))]
(let [response (handler (->DummyRequest {} {"auth-token" "foobar"}))]
(t/is (= :cookie (::auth/token-type response)))
(t/is (= "foobar" (::auth/token response))))
(session/write! manager "foobar" {:profile-id (:id profile)
:user-agent "user agent"
:created-at (ct/now)})
(let [response (handler (->DummyRequest {} {"auth-token" "foobar"}))]
(t/is (= :cookie (::auth/token-type response)))
(t/is (= "foobar" (::auth/token response)))
(t/is (= (:id profile) (::session/profile-id response)))
(t/is (= "foobar" (::session/id response))))))

View File

@@ -23,7 +23,7 @@
(smt/check!
(smt/for [context (->> sg/int
(sg/fmap (fn [_]
(#'rpc.doc/openapi-context (::rpc/methods th/*system*)))))]
(rpc.doc/prepare-openapi-context (::rpc/methods th/*system*)))))]
(try
(json/encode context)
true

View File

@@ -1048,12 +1048,6 @@
(into [elem])
(into (subvec without-elem insert-pos)))))))
(defn invert-map
"Returns a map with keys and values swapped.
If the input map has duplicate values, later entries overwrite earlier ones."
[m]
(into {} (map (fn [[k v]] [v k]) m)))
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;
;; String Functions
;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;

View File

@@ -1357,6 +1357,38 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0004-clean-shadow-color"
[data _]
(let [decode-color (sm/decoder types.color/schema:color sm/json-transformer)
clean-shadow-color
(fn [color]
(let [ref-id (get color :id)
ref-file (get color :file-id)]
(-> (d/without-qualified color)
(select-keys [:opacity :color :gradient :image :ref-id :ref-file])
(cond-> ref-id
(assoc :ref-id ref-id))
(cond-> ref-file
(assoc :ref-file ref-file))
(decode-color))))
clean-shadow
(fn [shadow]
(update shadow :color clean-shadow-color))
update-object
(fn [object]
(d/update-when object :shadow #(mapv clean-shadow %)))
update-container
(fn [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0005-deprecate-image-type"
[data _]
(letfn [(update-object [object]
@@ -1665,45 +1697,6 @@
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
(defmethod migrate-data "0015-clean-shadow-color"
[data _]
(let [decode-shadow-color
(sm/decoder ctss/schema:color sm/json-transformer)
clean-shadow-color
(fn [color]
(let [ref-id (get color :id)
ref-file (get color :file-id)]
(-> (d/without-qualified color)
(select-keys ctss/color-attrs)
(cond-> ref-id
(assoc :ref-id ref-id))
(cond-> ref-file
(assoc :ref-file ref-file))
(decode-shadow-color)
(d/without-nils))))
clean-shadow
(fn [shadow]
(update shadow :color clean-shadow-color))
clean-xform
(comp
(keep clean-shadow)
(filter ctss/valid-shadow?))
update-object
(fn [object]
(d/update-when object :shadow #(into [] clean-xform %)))
update-container
(fn [container]
(d/update-when container :objects d/update-vals update-object))]
(-> data
(update :pages-index d/update-vals update-container)
(d/update-when :components d/update-vals update-container))))
;; Copy fills from position-data to text nodes when all text nodes lack fills,
;; all position-data have fills, and the counts match
(defmethod migrate-data "0016-copy-fills-from-position-data-to-text-node"
@@ -1825,6 +1818,7 @@
"0002-clean-shape-interactions"
"0003-fix-root-shape"
"0003-convert-path-content-v2"
"0004-clean-shadow-color"
"0005-deprecate-image-type"
"0006-fix-old-texts-fills"
"0008-fix-library-colors-v4"
@@ -1838,5 +1832,4 @@
"0014-fix-tokens-lib-duplicate-ids"
"0014-clear-components-nil-objects"
"0015-fix-text-attrs-blank-strings"
"0015-clean-shadow-color"
"0016-copy-fills-from-position-data-to-text-node"]))

View File

@@ -36,7 +36,7 @@
(defn type
[s]
(m/type s default-options))
(m/-type s))
(defn properties
[s]
@@ -46,10 +46,6 @@
[s]
(m/type-properties s))
(defn children
[s]
(m/children s default-options))
(defn schema
[s]
(if (schema? s)
@@ -131,19 +127,9 @@
(defn keys
"Given a map schema, return all keys as set"
[schema']
(let [schema' (m/schema schema' default-options)]
(case (m/type schema')
:map
(->> (entries schema')
(into #{} xf:map-key))
:merge
(->> (m/children schema')
(mapcat m/entries)
(into #{} xf:map-key))
(throw (ex-info "not supported schema type" {:type (m/type schema')})))))
[schema]
(->> (entries schema)
(into #{} xf:map-key)))
(defn update-properties
[s f & args]

View File

@@ -11,14 +11,6 @@
(def styles #{:drop-shadow :inner-shadow})
(def schema:color
[:merge {:title "ShadowColor"}
ctc/schema:color-attrs
ctc/schema:plain-color])
(def color-attrs
(sm/keys schema:color))
(def schema:shadow
[:map {:title "Shadow"}
[:id [:maybe ::sm/uuid]]
@@ -28,7 +20,7 @@
[:blur ::sm/safe-number]
[:spread ::sm/safe-number]
[:hidden :boolean]
[:color schema:color]])
[:color ctc/schema:color]])
(def check-shadow
(sm/check-fn schema:shadow))

View File

@@ -310,17 +310,3 @@
the real name of the shape joined by the properties values separated by '/'"
[variant]
(cpn/merge-path-item (:name variant) (str/replace (:variant-name variant) #", " " / ")))
(defn find-boolean-pair
"Given a vector, return the map from 'bool-values' that contains both as keys.
Returns nil if none match."
[v]
(let [bool-values [{"on" true "off" false}
{"yes" true "no" false}
{"true" true "false" false}]]
(when (= (count v) 2)
(some (fn [b]
(when (and (contains? b (first v))
(contains? b (last v)))
b))
bool-values))))

View File

@@ -8,7 +8,6 @@
(:refer-clojure :exclude [uri?])
(:require
[app.common.data.macros :as dm]
[cuerdas.core :as str]
[lambdaisland.uri :as u]
[lambdaisland.uri.normalize :as un])
#?(:clj
@@ -59,14 +58,6 @@
(map (fn [[k v]] [(key-fn k) (value-fn v)]))))
(u/map->query-string))))
(defn ensure-path-slash
[u]
(update (uri u) :path
(fn [path]
(if (str/ends-with? path "/")
path
(str path "/")))))
#?(:clj
(defmethod print-method lambdaisland.uri.URI [^URI this ^java.io.Writer writer]
(.write writer "#")

View File

@@ -159,13 +159,3 @@
(t/testing "update-number-in-repeated-prop-names"
(t/is (= (ctv/update-number-in-repeated-prop-names props) numbered-props)))))
(t/deftest find-boolean-pair
(t/testing "find-boolean-pair"
(t/is (= (ctv/find-boolean-pair ["off" "on"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["on" "off"]) {"on" true "off" false}))
(t/is (= (ctv/find-boolean-pair ["off" "on" "other"]) nil))
(t/is (= (ctv/find-boolean-pair ["yes" "no"]) {"yes" true "no" false}))
(t/is (= (ctv/find-boolean-pair ["false" "true"]) {"true" true "false" false}))
(t/is (= (ctv/find-boolean-pair ["hello" "bye"]) nil))))

View File

@@ -1,27 +0,0 @@
[
{
"~:features": {
"~#set": [
"plugins/runtime",
"design-tokens/v1",
"layout/grid",
"styles/v2",
"fdata/pointer-map",
"fdata/objects-map",
"components/v2",
"fdata/shape-data-type"
]
},
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true
},
"~:name": "Default",
"~:modified-at": "~m1743598498553",
"~:id": "~u5116481d-f4e1-80c0-8005-f8e885bdc14d",
"~:created-at": "~m1743598498553",
"~:is-default": true
}
]

View File

@@ -1,146 +0,0 @@
{
"~:features": {
"~#set": [
"fdata/path-data",
"plugins/runtime",
"design-tokens/v1",
"variants/v1",
"layout/grid",
"styles/v2",
"fdata/objects-map",
"render-wasm/v1",
"components/v2",
"fdata/shape-data-type"
]
},
"~:team-id": "~u6bd7c17d-4f59-815e-8006-5c1f6882469a",
"~:permissions": {
"~:type": "~:membership",
"~:is-owner": true,
"~:is-admin": true,
"~:can-edit": true,
"~:can-read": true,
"~:is-logged": true
},
"~:has-media-trimmed": false,
"~:comment-thread-seqn": 0,
"~:name": "group_with_text_shadows",
"~:revn": 31,
"~:modified-at": "~m1762430368134",
"~:vern": 0,
"~:id": "~u58c5cc60-d124-81bd-8007-0f30f1ac452a",
"~:is-shared": false,
"~:migrations": {
"~#ordered-set": [
"legacy-2",
"legacy-3",
"legacy-5",
"legacy-6",
"legacy-7",
"legacy-8",
"legacy-9",
"legacy-10",
"legacy-11",
"legacy-12",
"legacy-13",
"legacy-14",
"legacy-16",
"legacy-17",
"legacy-18",
"legacy-19",
"legacy-25",
"legacy-26",
"legacy-27",
"legacy-28",
"legacy-29",
"legacy-31",
"legacy-32",
"legacy-33",
"legacy-34",
"legacy-36",
"legacy-37",
"legacy-38",
"legacy-39",
"legacy-40",
"legacy-41",
"legacy-42",
"legacy-43",
"legacy-44",
"legacy-45",
"legacy-46",
"legacy-47",
"legacy-48",
"legacy-49",
"legacy-50",
"legacy-51",
"legacy-52",
"legacy-53",
"legacy-54",
"legacy-55",
"legacy-56",
"legacy-57",
"legacy-59",
"legacy-62",
"legacy-65",
"legacy-66",
"legacy-67",
"0001-remove-tokens-from-groups",
"0002-normalize-bool-content-v2",
"0002-clean-shape-interactions",
"0003-fix-root-shape",
"0003-convert-path-content-v2",
"0004-clean-shadow-color",
"0005-deprecate-image-type",
"0006-fix-old-texts-fills",
"0008-fix-library-colors-v4",
"0009-clean-library-colors",
"0009-add-partial-text-touched-flags",
"0010-fix-swap-slots-pointing-non-existent-shapes",
"0011-fix-invalid-text-touched-flags",
"0012-fix-position-data",
"0013-fix-component-path",
"0013-clear-invalid-strokes-and-fills",
"0014-fix-tokens-lib-duplicate-ids",
"0014-clear-components-nil-objects",
"0015-fix-text-attrs-blank-strings",
"0016-copy-fills-from-position-data-to-text-node"
]
},
"~:version": 67,
"~:project-id": "~u6bd7c17d-4f59-815e-8006-5c1f68846e43",
"~:created-at": "~m1762273747633",
"~:backend": "legacy-db",
"~:data": {
"~:pages": [
"~u58c5cc60-d124-81bd-8007-0f30f1ac452b"
],
"~:pages-index": {
"~u58c5cc60-d124-81bd-8007-0f30f1ac452b": {
"~:objects": {
"~#penpot/objects-map/v2": {
"~u00000000-0000-0000-0000-000000000000": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:hide-fill-on-export\",false,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"Root Frame\",\"~:width\",0.01,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0.0,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.0]],[\"^:\",[\"^ \",\"~:x\",0.01,\"~:y\",0.01]],[\"^:\",[\"^ \",\"~:x\",0.0,\"~:y\",0.01]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^3\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:r1\",0,\"~:id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1.0,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",0.01,\"~:height\",0.01,\"~:x1\",0,\"~:y1\",0,\"~:x2\",0.01,\"~:y2\",0.01]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#FFFFFF\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^H\",0.01,\"~:flip-y\",null,\"~:shapes\",[\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~u457f223c-eff4-8043-8007-1186366cf83f\"]]]",
"~u457f223c-eff4-8043-8007-1186366cf83f": "[\"~#shape\",[\"^ \",\"~:y\",1127.9999542236328,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",3,\"~:name\",\"Text shadow\",\"~:width\",937.0000171528263,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",1127.9999542236328]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1127.9999542236328]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1434.9999937907205]],[\"^:\",[\"^ \",\"~:x\",182,\"~:y\",1434.9999937907205]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:blocked\",false,\"~:proportion\",1,\"~:shadow\",[[\"^ \",\"~:color\",[\"^ \",\"^I\",\"#d276ff\",\"~:opacity\",1],\"~:spread\",1,\"~:offset-y\",4,\"~:style\",\"~:drop-shadow\",\"~:blur\",0,\"~:hidden\",false,\"^B\",\"~u376e6303-a232-8017-8004-908433a7d495\",\"~:offset-x\",4]],\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",1127.9999542236328,\"^6\",937.0000171528263,\"~:height\",307.0000395670877,\"~:x1\",182,\"~:y1\",1127.9999542236328,\"~:x2\",1119.0000171528263,\"~:y2\",1434.9999937907205]],\"~:fills\",[],\"~:flip-x\",false,\"^T\",307.0000395670877,\"~:flip-y\",false,\"~:shapes\",[\"~u457f223c-eff4-8043-8007-1186366cf840\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~u457f223c-eff4-8043-8007-1186366cf842\"]]]",
"~u22590301-48da-807a-8007-0f30f2c3c7a3": "[\"~#shape\",[\"^ \",\"~:y\",565.9999791979773,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",3,\"~:name\",\"Text shadow\",\"~:width\",937.0000171528263,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",565.9999791979773]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",565.9999791979773]],[\"^:\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",873.0000187650649]],[\"^:\",[\"^ \",\"~:x\",182,\"~:y\",873.0000187650649]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:blocked\",false,\"~:proportion\",1,\"~:shadow\",[[\"^ \",\"~:color\",[\"^ \",\"^I\",\"#166ada\",\"~:opacity\",1],\"~:spread\",40,\"~:offset-y\",4,\"~:style\",\"~:drop-shadow\",\"~:blur\",50,\"~:hidden\",false,\"^B\",\"~u376e6303-a232-8017-8004-908433a7d495\",\"~:offset-x\",4]],\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",565.9999791979773,\"^6\",937.0000171528263,\"~:height\",307.0000395670876,\"~:x1\",182,\"~:y1\",565.9999791979773,\"~:x2\",1119.0000171528263,\"~:y2\",873.0000187650649]],\"~:fills\",[],\"~:flip-x\",false,\"^T\",307.0000395670876,\"~:flip-y\",false,\"~:shapes\",[\"~u22590301-48da-807a-8007-0f30f2c3c7a4\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~udbebc08f-fd4a-800a-8007-11852f2de796\"]]]",
"~u22590301-48da-807a-8007-0f30f2c3c7a7": "[\"~#shape\",[\"^ \",\"~:y\",782.9999720950955,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-height\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"7hmohksim0\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"\",\"^;\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2fc3qdybqwr\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:typography-ref-file\",null,\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the text body\"]],\"^<\",null,\"^=\",\"none\",\"~:text-align\",\"left\",\"^>\",\"gfont-karla\",\"^8\",\"29emf8fbblr\",\"^?\",\"0\",\"^@\",\"500\",\"^A\",null,\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^B\",\"500\",\"^C\",\"none\",\"^D\",\"0\",\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^H\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Example of UI design, flex + grid layouts, prototyping, light + dark mode colors, typographies and components.\",\"~:width\",702.99999999999,\"^7\",\"^I\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",782.9999720950955]],[\"^Q\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",782.9999720950955]],[\"^Q\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",824.9999708433805]],[\"^Q\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",824.9999708433805]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a7\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:position-data\",[[\"^ \",\"~:y\",824.1999486982821,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"~:y1\",0.4000000059604645,\"^O\",662.6312866210938,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"~:x2\",662.6312866210938,\"~:direction\",\"ltr\",\"^H\",\"Karla\",\"~:height\",40.79999923706055,\"^I\",\"Example of UI design, flex + grid layouts, \"],[\"^ \",\"~:y\",866.199954032898,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"^[\",42.400001525878906,\"^O\",615.8500366210938,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"^10\",0,\"^11\",83.20000457763672,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^12\",615.8500366210938,\"^13\",\"ltr\",\"^H\",\"Karla\",\"^14\",40.80000305175781,\"^I\",\"prototyping, light + dark mode colors, \"],[\"^ \",\"~:y\",908.199954032898,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"^[\",84.4000015258789,\"^O\",503.13751220703125,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"^10\",0,\"^11\",125.20000457763672,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^12\",503.13751220703125,\"^13\",\"ltr\",\"^H\",\"Karla\",\"^14\",40.80000305175781,\"^I\",\"typographies and components.\"]],\"~:frame-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:strokes\",[],\"~:x\",415.99998795994475,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",782.9999720950955,\"^O\",702.99999999999,\"^14\",41.999998748285066,\"^10\",415.99998795994475,\"^[\",782.9999720950955,\"^12\",1118.9999879599347,\"^11\",824.9999708433805]],\"^E\",[],\"~:flip-x\",null,\"^14\",41.999998748285066,\"~:flip-y\",null]]",
"~u22590301-48da-807a-8007-0f30f2c3c7a6": "[\"~#shape\",[\"^ \",\"~:y\",840.0000141561163,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"dxn5loqivn\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"11x47j94xkq\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:typography-ref-file\",null,\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is just another text at the bottom\"]],\"^<\",null,\"^=\",\"none\",\"~:text-align\",\"left\",\"^>\",\"gfont-karla\",\"^8\",\"1rfpnyrk4it\",\"^?\",\"35\",\"^@\",\"500\",\"^A\",null,\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^B\",\"500\",\"^C\",\"none\",\"^D\",\"0\",\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"^H\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Version 1. June 2024\",\"~:width\",702.99999999999,\"^7\",\"^I\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.9999879599491,\"~:y\",840.0000141561163]],[\"^Q\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",840.0000141561163]],[\"^Q\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",873.000022023929]],[\"^Q\",[\"^ \",\"~:x\",415.9999879599491,\"~:y\",873.000022023929]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:hidden\",false,\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a6\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:position-data\",[[\"^ \",\"~:y\",881.199900329113,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"35px\",\"^@\",\"500\",\"~:y1\",0.4000000059604645,\"^O\",323.5187683105469,\"^C\",\"none solid rgb(94, 24, 175)\",\"^D\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^E\",[[\"^ \",\"^F\",\"#5E18AF\",\"^G\",1]],\"~:x2\",323.5187683105469,\"~:direction\",\"ltr\",\"^H\",\"Karla\",\"~:height\",40.79999923706055,\"^I\",\"Version 1. June 2024\"]],\"~:frame-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:strokes\",[],\"~:x\",415.9999879599491,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.9999879599491,\"~:y\",840.0000141561163,\"^O\",702.99999999999,\"^15\",33.000007867812656,\"^11\",415.9999879599491,\"^10\",840.0000141561163,\"^13\",1118.999987959939,\"^12\",873.000022023929]],\"^E\",[],\"~:flip-x\",null,\"^15\",33.000007867812656,\"~:flip-y\",null]]",
"~u22590301-48da-807a-8007-0f30f2c3c7a5": "[\"~#shape\",[\"^ \",\"~:y\",782.9999810452875,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",-2.842170943040401E-14,\"~:p2\",0,\"~:p3\",-2.842170943040401E-14,\"~:p4\",0],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"description\",\"~:layout-align-items\",\"~:start\",\"~:width\",703.0000076883839,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",782.9999810452875]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",782.9999810452875]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",873.0000080957647]],[\"^J\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",873.0000080957647]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",15,\"~:column-gap\",15],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:layout-item-v-sizing\",\"^M\",\"~:layout-justify-content\",\"^C\",\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a5\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",416.00000946444237,\"~:blocked\",false,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",416.00000946444237,\"~:y\",782.9999810452875,\"^D\",703.0000076883839,\"~:height\",90.00002705047712,\"~:x1\",416.00000946444237,\"~:y1\",782.9999810452875,\"~:x2\",1119.0000171528263,\"~:y2\",873.0000080957647]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",90.00002705047712,\"~:flip-y\",null,\"~:shapes\",[\"~u22590301-48da-807a-8007-0f30f2c3c7a6\",\"~u22590301-48da-807a-8007-0f30f2c3c7a7\"]]]",
"~u22590301-48da-807a-8007-0f30f2c3c7a4": "[\"~#shape\",[\"^ \",\"~:y\",565.9999637007554,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"8gvslj04p9\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2eqbmpbto3f\",\"~:font-size\",\"60\",\"~:font-weight\",\"700\",\"~:typography-ref-file\",null,\"~:font-variant-id\",\"700\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#805ad5\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the title\"]],\"^<\",null,\"^=\",\"none\",\"~:text-align\",\"left\",\"^>\",\"gfont-karla\",\"^8\",\"yl00fqu977\",\"^?\",\"60\",\"^@\",\"700\",\"^A\",null,\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^B\",\"700\",\"^C\",\"none\",\"^D\",\"0\",\"^E\",[[\"^ \",\"^F\",\"#805ad5\",\"^G\",1]],\"^H\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Sales dashboard example\",\"~:width\",428.000002264963,\"^7\",\"^I\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",386.99999294062314,\"~:y\",565.9999637007555]],[\"^Q\",[\"^ \",\"~:x\",814.9999952055861,\"~:y\",565.9999637007555]],[\"^Q\",[\"^ \",\"~:x\",814.9999952055861,\"~:y\",638.0000023244937]],[\"^Q\",[\"^ \",\"~:x\",386.99999294062314,\"~:y\",638.0000023244937]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:hidden\",false,\"~:id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a4\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:position-data\",[[\"^ \",\"~:y\",601.1998026371002,\"^;\",\"normal\",\"^=\",\"none\",\"^?\",\"60px\",\"^@\",\"700\",\"~:y1\",0.800000011920929,\"^O\",736.3812866210938,\"^C\",\"none solid rgb(128, 90, 213)\",\"^D\",\"normal\",\"~:x\",306.9999465942383,\"~:x1\",0,\"~:y2\",71.20000153779984,\"^E\",[[\"^ \",\"^F\",\"#805ad5\",\"^G\",1]],\"~:x2\",736.3812866210938,\"~:direction\",\"ltr\",\"^H\",\"Karla\",\"~:height\",70.4000015258789,\"^I\",\"Sales dashboard example\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",386.9999929406232,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",386.9999929406232,\"~:y\",565.9999637007554,\"^O\",428.000002264963,\"^11\",72.00003862373819,\"^Y\",386.9999929406232,\"^X\",565.9999637007554,\"^[\",814.9999952055862,\"^Z\",638.0000023244936]],\"^E\",[],\"~:flip-x\",null,\"^11\",72.00003862373819,\"~:flip-y\",null]]",
"~udbebc08f-fd4a-800a-8007-11852f2de796": "[\"~#shape\",[\"^ \",\"~:y\",704.9999632537276,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",123,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",704.9999632537276]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",704.9999632537276]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",758.9999020993622]],[\"^<\",[\"^ \",\"~:x\",182,\"~:y\",758.9999020993622]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:scale\",\"~:constraints-h\",\"^B\",\"~:r1\",0,\"~:id\",\"~udbebc08f-fd4a-800a-8007-11852f2de796\",\"~:parent-id\",\"~u22590301-48da-807a-8007-0f30f2c3c7a3\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",704.9999632537276,\"^8\",123,\"~:height\",53.999938845634574,\"~:x1\",182,\"~:y1\",704.9999632537276,\"~:x2\",305,\"~:y2\",758.9999020993622]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#476fe7\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",53.999938845634574,\"~:flip-y\",null]]",
"~u457f223c-eff4-8043-8007-1186366cf841": "[\"~#shape\",[\"^ \",\"~:y\",1344.9999721046204,\"~:hide-fill-on-export\",false,\"~:layout-gap-type\",\"~:multiple\",\"~:layout-padding\",[\"^ \",\"~:p1\",-2.842170943040401E-14,\"~:p2\",0,\"~:p3\",-2.842170943040401E-14,\"~:p4\",0],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:layout-wrap-type\",\"~:nowrap\",\"~:layout\",\"~:flex\",\"~:hide-in-viewer\",true,\"~:name\",\"description\",\"~:layout-align-items\",\"~:start\",\"~:width\",703.0000076883839,\"~:layout-padding-type\",\"~:simple\",\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",1344.9999721046204]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1344.9999721046204]],[\"^J\",[\"^ \",\"~:x\",1119.0000171528263,\"~:y\",1434.9999937907205]],[\"^J\",[\"^ \",\"~:x\",416.0000094644423,\"~:y\",1434.9999937907205]]],\"~:show-content\",true,\"~:layout-item-h-sizing\",\"~:auto\",\"~:proportion-lock\",false,\"~:layout-gap\",[\"^ \",\"~:row-gap\",15,\"~:column-gap\",15],\"~:transform-inverse\",[\"^:\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:layout-item-v-sizing\",\"^M\",\"~:layout-justify-content\",\"^C\",\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:layout-flex-dir\",\"~:column\",\"~:layout-align-content\",\"~:stretch\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",416.0000094644424,\"~:blocked\",false,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",416.0000094644424,\"~:y\",1344.9999721046204,\"^D\",703.0000076883839,\"~:height\",90.00002168610013,\"~:x1\",416.0000094644424,\"~:y1\",1344.9999721046204,\"~:x2\",1119.0000171528263,\"~:y2\",1434.9999937907205]],\"~:fills\",[],\"~:flip-x\",null,\"^16\",90.00002168610013,\"~:flip-y\",null,\"~:shapes\",[\"~u457f223c-eff4-8043-8007-1186366cf843\",\"~u457f223c-eff4-8043-8007-1186366cf844\"]]]",
"~u457f223c-eff4-8043-8007-1186366cf840": "[\"~#shape\",[\"^ \",\"~:y\",1127.9999542236328,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-width\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"8gvslj04p9\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2eqbmpbto3f\",\"~:font-size\",\"60\",\"~:font-weight\",\"700\",\"~:font-variant-id\",\"700\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#805ad5\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the title\"]],\"^<\",\"none\",\"~:text-align\",\"left\",\"^=\",\"gfont-karla\",\"^8\",\"yl00fqu977\",\"^>\",\"60\",\"^?\",\"700\",\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"700\",\"^A\",\"none\",\"^B\",\"0\",\"^C\",[[\"^ \",\"^D\",\"#805ad5\",\"^E\",1]],\"^F\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Sales dashboard example\",\"~:width\",428.000002264963,\"^7\",\"^G\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",386.9999929406231,\"~:y\",1127.9999542236328]],[\"^O\",[\"^ \",\"~:x\",814.999995205586,\"~:y\",1127.9999542236328]],[\"^O\",[\"^ \",\"~:x\",814.999995205586,\"~:y\",1199.9999928474213]],[\"^O\",[\"^ \",\"~:x\",386.9999929406231,\"~:y\",1199.9999928474213]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:hidden\",false,\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf840\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:position-data\",[[\"^ \",\"~:y\",1163.1997547745723,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"60px\",\"^?\",\"700\",\"~:y1\",0.800000011920929,\"^M\",736.3812866210938,\"^A\",\"none solid rgb(128, 90, 213)\",\"^B\",\"normal\",\"~:x\",306.9999465942383,\"~:x1\",0,\"~:y2\",71.20000153779984,\"^C\",[[\"^ \",\"^D\",\"#805ad5\",\"^E\",1]],\"~:x2\",736.3812866210938,\"~:direction\",\"ltr\",\"^F\",\"Karla\",\"~:height\",70.4000015258789,\"^G\",\"Sales dashboard example\"]],\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",386.9999929406232,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",386.9999929406232,\"~:y\",1127.9999542236328,\"^M\",428.000002264963,\"^[\",72.00003862378844,\"^W\",386.9999929406232,\"^V\",1127.9999542236328,\"^Y\",814.9999952055862,\"^X\",1199.9999928474213]],\"^C\",[],\"~:flip-x\",null,\"^[\",72.00003862378844,\"~:flip-y\",null]]",
"~u457f223c-eff4-8043-8007-1186366cf843": "[\"~#shape\",[\"^ \",\"~:y\",1401.999989181772,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"dxn5loqivn\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"1.2\",\"^;\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"11x47j94xkq\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is just another text at the bottom\"]],\"^<\",\"none\",\"~:text-align\",\"left\",\"^=\",\"gfont-karla\",\"^8\",\"1rfpnyrk4it\",\"^>\",\"35\",\"^?\",\"500\",\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"500\",\"^A\",\"none\",\"^B\",\"0\",\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^F\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Version 1. June 2024\",\"~:width\",702.99999999999,\"^7\",\"^G\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.99998795994907,\"~:y\",1401.999989181772]],[\"^O\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",1401.999989181772]],[\"^O\",[\"^ \",\"~:x\",1118.999987959939,\"~:y\",1434.9999970495846]],[\"^O\",[\"^ \",\"~:x\",415.99998795994907,\"~:y\",1434.9999970495846]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:hidden\",false,\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf843\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:position-data\",[[\"^ \",\"~:y\",1443.1998753547687,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"~:y1\",0.4000000059604645,\"^M\",323.5187683105469,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"~:x2\",323.5187683105469,\"~:direction\",\"ltr\",\"^F\",\"Karla\",\"~:height\",40.79999923706055,\"^G\",\"Version 1. June 2024\"]],\"~:frame-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:strokes\",[],\"~:x\",415.99998795994907,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.99998795994907,\"~:y\",1401.999989181772,\"^M\",702.99999999999,\"^13\",33.000007867812656,\"^[\",415.99998795994907,\"^Z\",1401.999989181772,\"^11\",1118.999987959939,\"^10\",1434.9999970495846]],\"^C\",[],\"~:flip-x\",null,\"^13\",33.000007867812656,\"~:flip-y\",null]]",
"~u457f223c-eff4-8043-8007-1186366cf842": "[\"~#shape\",[\"^ \",\"~:y\",1266.9999993145393,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:fixed\",\"~:hide-in-viewer\",false,\"~:name\",\"Rectangle\",\"~:width\",123,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",182,\"~:y\",1266.9999993145393]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",1266.9999993145393]],[\"^<\",[\"^ \",\"~:x\",305,\"~:y\",1320.999938160174]],[\"^<\",[\"^ \",\"~:x\",182,\"~:y\",1320.999938160174]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:scale\",\"~:constraints-h\",\"^B\",\"~:r1\",0,\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf842\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf83f\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",182,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",182,\"~:y\",1266.9999993145393,\"^8\",123,\"~:height\",53.99993884563446,\"~:x1\",182,\"~:y1\",1266.9999993145393,\"~:x2\",305,\"~:y2\",1320.9999381601738]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#476fe7\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^M\",53.99993884563446,\"~:flip-y\",null]]",
"~u457f223c-eff4-8043-8007-1186366cf844": "[\"~#shape\",[\"^ \",\"~:y\",1345.0000691910636,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:grow-type\",\"~:auto-height\",\"~:content\",[\"^ \",\"~:type\",\"root\",\"~:key\",\"7hmohksim0\",\"~:children\",[[\"^ \",\"^7\",\"paragraph-set\",\"^9\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^9\",[[\"^ \",\"^:\",\"\",\"^;\",\"normal\",\"~:text-transform\",\"none\",\"~:font-id\",\"gfont-karla\",\"^8\",\"2fc3qdybqwr\",\"~:font-size\",\"35\",\"~:font-weight\",\"500\",\"~:font-variant-id\",\"500\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#5E18AF\",\"~:fill-opacity\",1]],\"~:font-family\",\"Karla\",\"~:text\",\"This is the text body\"]],\"^<\",\"none\",\"~:text-align\",\"left\",\"^=\",\"gfont-karla\",\"^8\",\"29emf8fbblr\",\"^>\",\"0\",\"^?\",\"500\",\"~:text-direction\",\"ltr\",\"^7\",\"paragraph\",\"^@\",\"500\",\"^A\",\"none\",\"^B\",\"0\",\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^F\",\"Karla\"]]]],\"~:vertical-align\",\"\"],\"~:hide-in-viewer\",false,\"~:name\",\"Example of UI design, flex + grid layouts, prototyping, light + dark mode colors, typographies and components.\",\"~:width\",702.99999999999,\"^7\",\"^G\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",1345.0000691910636]],[\"^O\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",1345.0000691910636]],[\"^O\",[\"^ \",\"~:x\",1118.9999879599347,\"~:y\",1387.0000679393486]],[\"^O\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",1387.0000679393486]]],\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:page-id\",\"~u4d2e83b3-f32c-80ca-8004-85fe278b278d\",\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:id\",\"~u457f223c-eff4-8043-8007-1186366cf844\",\"~:parent-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:position-data\",[[\"^ \",\"~:y\",1386.2000457942502,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"~:y1\",0.4000000059604645,\"^M\",662.6312866210938,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"~:x1\",0,\"~:y2\",41.19999924302101,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"~:x2\",662.6312866210938,\"~:direction\",\"ltr\",\"^F\",\"Karla\",\"~:height\",40.79999923706055,\"^G\",\"Example of UI design, flex + grid layouts, \"],[\"^ \",\"~:y\",1428.200051128866,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"^Y\",42.400001525878906,\"^M\",615.8500366210938,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"^Z\",0,\"^[\",83.20000457763672,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^10\",615.8500366210938,\"^11\",\"ltr\",\"^F\",\"Karla\",\"^12\",40.80000305175781,\"^G\",\"prototyping, light + dark mode colors, \"],[\"^ \",\"~:y\",1470.200051128866,\"^;\",\"normal\",\"^<\",\"none\",\"^>\",\"35px\",\"^?\",\"500\",\"^Y\",84.4000015258789,\"^M\",503.13751220703125,\"^A\",\"none solid rgb(94, 24, 175)\",\"^B\",\"normal\",\"~:x\",416.00003051766544,\"^Z\",0,\"^[\",125.20000457763672,\"^C\",[[\"^ \",\"^D\",\"#5E18AF\",\"^E\",1]],\"^10\",503.13751220703125,\"^11\",\"ltr\",\"^F\",\"Karla\",\"^12\",40.80000305175781,\"^G\",\"typographies and components.\"]],\"~:frame-id\",\"~u457f223c-eff4-8043-8007-1186366cf841\",\"~:strokes\",[],\"~:x\",415.99998795994475,\"~:blocked\",false,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",415.99998795994475,\"~:y\",1345.0000691910636,\"^M\",702.99999999999,\"^12\",41.99999874828518,\"^Z\",415.99998795994475,\"^Y\",1345.0000691910636,\"^10\",1118.9999879599347,\"^[\",1387.0000679393488]],\"^C\",[],\"~:flip-x\",null,\"^12\",41.99999874828518,\"~:flip-y\",null]]"
}
},
"~:id": "~u58c5cc60-d124-81bd-8007-0f30f1ac452b",
"~:name": "Page 1"
}
},
"~:id": "~u58c5cc60-d124-81bd-8007-0f30f1ac452a",
"~:options": {
"~:components-v2": true,
"~:base-font-size": "16px"
}
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,7 @@ export class BasePage {
);
}
const url = typeof path === "string" ? `**/api/main/methods/${path}` : path;
const url = typeof path === "string" ? `**/api/rpc/command/${path}` : path;
const interceptConfig = {
status: 200,
contentType: "application/transit+json",

View File

@@ -387,42 +387,6 @@ test("Renders a file with texts with empty lines", async ({
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with texts with breaking words", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-empty-lines.json");
await workspace.goToWorkspace({
id: "58c5cc60-d124-81bd-8007-0ecbaf9da983",
pageId: "15222a7a-d3bc-80f1-8007-0d8e166e650f",
});
await workspace.waitForFirstRender({ hideUI: false });
await workspace.clickLeafLayer("text-with-empty-lines-3");
await workspace.hideUI();
await workspace.page.keyboard.press("Enter");
await expect(workspace.canvas).toHaveScreenshot();
});
test("Renders a file with group with text with inherited shadows", async ({
page,
}) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();
await workspace.mockGetFile("render-wasm/get-file-group-with-shadows.json");
await workspace.goToWorkspace({
id: "58c5cc60-d124-81bd-8007-0f30f1ac452a",
pageId: "58c5cc60-d124-81bd-8007-0f30f1ac452b",
});
await workspace.waitForFirstRender();
await expect(workspace.canvas).toHaveScreenshot();
});
test.skip("Updates text alignment edition - part 1", async ({ page }) => {
const workspace = new WasmWorkspacePage(page);
await workspace.setupEmptyFile();

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 124 KiB

After

Width:  |  Height:  |  Size: 124 KiB

View File

@@ -1,7 +1,7 @@
import { test, expect } from "@playwright/test";
test("Has title", async ({ page }) => {
await page.route("**/api/main/methods/get-profile", (route) => {
await page.route("**/api/rpc/command/get-profile", (route) => {
route.fulfill({
status: 200,
contentType: "application/transit+json",

View File

@@ -1,672 +0,0 @@
import { test, expect } from "@playwright/test";
import { WorkspacePage } from "../pages/WorkspacePage";
const flags = ["enable-inspect-styles"];
test.beforeEach(async ({ page }) => {
await WorkspacePage.init(page);
});
const setupFile = async (workspacePage) => {
await workspacePage.setupEmptyFile();
await workspacePage.mockConfigFlags(flags);
await workspacePage.mockRPC(
/get\-file\?/,
"workspace/get-file-inspect-tab.json",
);
await workspacePage.goToWorkspace({
fileId: "7b2da435-6186-815a-8007-0daa95d2f26d",
pageId: "ce79274b-11ab-8088-8007-0487ad43f789",
});
};
const shapeToLayerName = {
flex: "shape - layout - flex",
flexElement: "shape - layout - flex - element",
grid: "shape - layout - grid",
gridElement: "shape - layout - grid - element",
shadow: "shape - shadow - single",
shadowMultiple: "shape - shadow - multiple",
shadowComposite: "shape - shadow - composite",
blur: "shape - blur",
borderRadius: {
main: "shape - borderRadius",
individual: "shape - borderRadius - individual",
multiple: "shape - borderRadius - multiple",
token: "shape - borderRadius - token",
},
fill: {
solid: "shape - fill - single - solid",
gradient: "shape - fill - single - gradient",
image: "shape - fill - single - image",
multiple: "shape - fill - multiple",
style: "shape - fill - style",
token: "shape - fill - token",
},
stroke: {
solid: "shape - stroke - single - solid",
gradient: "shape - stroke - single - gradient",
image: "shape - stroke - single - image",
multiple: "shape - stroke - multiple",
style: "shape - stroke - style",
token: "shape - stroke - token",
},
text: {
simple: "shape - text",
token: "shape - text - token - simple",
compositeToken: "shape - text - token - composite",
},
};
/**
* Copy the shorthand CSS from a full panel property
* @param {object} panel - The style panel locator
*/
const copyShorthand = async (panel) => {
const panelShorthandButton = panel.getByRole("button", {
name: "Copy CSS shorthand to clipboard",
});
await panelShorthandButton.click();
};
/**
* Copy the CSS property from a property row by clicking its copy button
* @param {object} panel - The style panel locator
* @param {string} property - The property name to filter by
*/
const copyPropertyFromPropertyRow = async (panel, property) => {
const propertyRow = panel
.getByTestId("property-row")
.filter({ hasText: property });
const copyButton = propertyRow.getByRole("button");
await copyButton.click();
};
/**
* Returns the style panel by its title
* @param {WorkspacePage} workspacePage - The workspace page instance
* @param {string} title - The title of the panel to retrieve
*/
const getPanelByTitle = async (workspacePage, title) => {
const sidebar = workspacePage.page.getByTestId("right-sidebar");
const article = sidebar.getByRole("article");
const panel = article.filter({ hasText: title });
return panel;
};
/**
* Selects a layer in the layers panel
* @param {WorkspacePage} workspacePage - The workspace page instance
* @param {string} layerName - The name of the layer to select
* @param {string} parentLayerName - The name of the parent layer to expand (optional)
*/
const selectLayer = async (workspacePage, layerName, parentLayerName) => {
await workspacePage.clickToggableLayer("Board");
if (parentLayerName) {
await workspacePage.clickToggableLayer(parentLayerName);
}
await workspacePage.clickLeafLayer(layerName);
};
/**
* Opens the Inspect tab
* @param {WorkspacePage} workspacePage - The workspace page instance
*/
const openInspectTab = async (workspacePage) => {
const inspectButton = workspacePage.page.getByRole("tab", {
name: "Inspect",
});
await inspectButton.click();
};
test.describe("Inspect tab - Styles", () => {
test("Open Inspect tab", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.flex);
await openInspectTab(workspacePage);
const switcherLabel = workspacePage.page.getByText("Layer info", {
exact: true,
});
await expect(switcherLabel).toBeVisible();
await expect(switcherLabel).toHaveText("Layer info");
});
test.describe("Inspect tab - Flex", () => {
test("Shape Layout Flex ", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.flex);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Layout");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
test("Shape Layout Flex Element", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(
workspacePage,
shapeToLayerName.flexElement,
shapeToLayerName.flex,
);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Flex Element");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
});
test("Shape Layout Grid", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.grid);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Layout");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
test.describe("Inspect tab - Shadow", () => {
test("Shape Shadow - Single shadow", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.shadow);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Shadow");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(2);
});
test("Shape Shadow - Multiple shadow", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.shadowMultiple);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Shadow");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
});
test("Shape Shadow - Composite shadow", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.shadowComposite);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Shadow");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(3);
const compositeShadowRow = propertyRow.first();
await expect(compositeShadowRow).toBeVisible();
const compositeShadowTerm = compositeShadowRow.locator("dt");
const compositeShadowDefinition = compositeShadowRow.locator("dd");
expect(compositeShadowTerm).toHaveText("Shadow", { exact: true });
expect(compositeShadowDefinition).toContainText("shadowToken");
});
});
test("Shape - Blur", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.blur);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Blur");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
test.describe("Inspect tab - Border radius", () => {
test("Shape - Border radius - individual", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(
workspacePage,
shapeToLayerName.borderRadius.individual,
shapeToLayerName.borderRadius.main,
);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Size & position");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(2);
const borderStartStartRadius = propertyRow.filter({
hasText: "Border start start radius",
});
await expect(borderStartStartRadius).toBeVisible();
const borderEndEndRadius = propertyRow.filter({
hasText: "Border end end radius",
});
await expect(borderEndEndRadius).toBeVisible();
});
test("Shape - Border radius - multiple", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(
workspacePage,
shapeToLayerName.borderRadius.multiple,
shapeToLayerName.borderRadius.main,
);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Size & position");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
const borderStartStartRadius = propertyRow.filter({
hasText: "Border start start radius",
});
await expect(borderStartStartRadius).toBeVisible();
const borderStartEndRadius = propertyRow.filter({
hasText: "Border start end radius",
});
await expect(borderStartEndRadius).toBeVisible();
const borderEndEndRadius = propertyRow.filter({
hasText: "Border end end radius",
});
await expect(borderEndEndRadius).toBeVisible();
const borderEndStartRadius = propertyRow.filter({
hasText: "Border end start radius",
});
await expect(borderEndStartRadius).toBeVisible();
});
test("Shape - Border radius - token", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(
workspacePage,
shapeToLayerName.borderRadius.token,
shapeToLayerName.borderRadius.main,
);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Size & position");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(4);
const borderStartEndRadius = propertyRow.filter({
hasText: "Border start end radius",
});
await expect(borderStartEndRadius).toBeVisible();
expect(borderStartEndRadius).toContainText("radius");
const borderEndStartRadius = propertyRow.filter({
hasText: "Border end start radius",
});
expect(borderEndStartRadius).toContainText("radius");
await expect(borderEndStartRadius).toBeVisible();
});
});
test.describe("Inspect tab - Fill", () => {
test("Shape - Fill - Solid", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.fill.solid);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Fill");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
test("Shape - Fill - Gradient", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.fill.gradient);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Fill");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
test("Shape - Fill - Image", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.fill.image);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Fill");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
const imagePreview = panel.getByRole("img", {
name: "Preview of the shape's fill",
});
await expect(imagePreview).toBeVisible();
});
test("Shape - Fill - Multiple", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.fill.multiple);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Fill");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(3);
const imagePreview = panel.getByRole("img", {
name: "Preview of the shape's fill",
});
await expect(imagePreview).toBeVisible();
});
test("Shape - Fill - Token", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.fill.token);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Fill");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
const fillToken = propertyRow.filter({
hasText: "Background",
});
expect(fillToken).toContainText("primary");
await expect(fillToken).toBeVisible();
});
});
test.describe("Inspect tab - Stroke", () => {
test("Shape - Stroke - Solid", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.stroke.solid);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Stroke");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
test("Shape - Stroke - Gradient", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.stroke.gradient);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Stroke");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
});
test("Shape - Stroke - Image", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.stroke.image);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Stroke");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
const imagePreview = panel.getByRole("img", {
name: "Preview of the shape's fill",
});
await expect(imagePreview).toBeVisible();
});
test("Shape - Stroke - Multiple", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.stroke.multiple);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Stroke");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(3);
const imagePreview = panel.getByRole("img", {
name: "Preview of the shape's fill",
});
await expect(imagePreview).toBeVisible();
});
test("Shape - Stroke - Token", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.stroke.token);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Stroke");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
const fillToken = propertyRow.filter({
hasText: "Border color",
});
expect(fillToken).toContainText("primary");
await expect(fillToken).toBeVisible();
});
});
test.describe("Inspect tab - Typography", () => {
test("Text - simple", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.text.simple);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Text");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
const textPreview = panel.getByRole("presentation");
await expect(textPreview).toBeVisible();
});
test("Text - token", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.text.token);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Text");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
// Test with multiple tokens
const fontFamilyToken = propertyRow.filter({
hasText: "Font family",
});
await expect(fontFamilyToken).toBeVisible();
expect(fontFamilyToken).toContainText("font.sans");
const fontSizeToken = propertyRow.filter({
hasText: "Font size",
});
await expect(fontSizeToken).toBeVisible();
expect(fontSizeToken).toContainText("medium");
const fontWeightToken = propertyRow.filter({
hasText: "Font weight",
});
await expect(fontWeightToken).toBeVisible();
expect(fontWeightToken).toContainText("bold");
const textPreview = panel.getByRole("presentation");
await expect(textPreview).toBeVisible();
});
test("Text - composite token", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.text.compositeToken);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Text");
await expect(panel).toBeVisible();
const propertyRow = panel.getByTestId("property-row");
const propertyRowCount = await propertyRow.count();
expect(propertyRowCount).toBeGreaterThanOrEqual(1);
const compositeTypographyRow = propertyRow.filter({
hasText: "Typography",
});
await expect(compositeTypographyRow).toBeVisible();
expect(compositeTypographyRow).toContainText("body");
const textPreview = panel.getByRole("presentation");
await expect(textPreview).toBeVisible();
});
});
test.describe("Copy properties", () => {
test("Copy single property", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.flex);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Layout");
await expect(panel).toBeVisible();
await copyPropertyFromPropertyRow(panel, "Display");
const shorthand = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(shorthand).toBe("display: flex;");
});
test("Copy shorthand - multiple properties", async ({ page }) => {
const workspacePage = new WorkspacePage(page);
await setupFile(workspacePage);
await selectLayer(workspacePage, shapeToLayerName.shadow);
await openInspectTab(workspacePage);
const panel = await getPanelByTitle(workspacePage, "Shadow");
await expect(panel).toBeVisible();
await copyShorthand(panel);
const shorthand = await page.evaluate(() =>
navigator.clipboard.readText(),
);
expect(shorthand).toBe("box-shadow: 4px 4px 4px 0px #00000033;");
});
});
});

View File

@@ -243,7 +243,7 @@
(defn- persist-events
[events]
(if (seq events)
(let [uri (u/join cf/public-uri "api/main/methods/push-audit-events")
(let [uri (u/join cf/public-uri "api/rpc/command/push-audit-events")
params {:uri uri
:method :post
:credentials "include"

View File

@@ -56,19 +56,16 @@
([shape]
(resize-wasm-text-modifiers shape (:content shape)))
([{:keys [id points selrect grow-type] :as shape} content]
([{:keys [id points selrect] :as shape} content]
(wasm.api/use-shape id)
(wasm.api/set-shape-text-content id content)
(wasm.api/set-shape-text-images id content)
(let [dimension (wasm.api/get-text-dimensions)
width-scale (if (#{:fixed :auto-height} grow-type)
1.0
(/ (:width dimension) (:width selrect)))
height-scale (if (= :fixed grow-type)
1.0
(/ (:height dimension) (:height selrect)))
resize-v (gpt/point width-scale height-scale)
resize-v (gpt/point
(/ (:width dimension) (-> selrect :width))
(/ (:height dimension) (-> selrect :height)))
origin (first points)]
{id

View File

@@ -115,7 +115,7 @@
request
{:method method
:uri (u/join cf/public-uri "api/main/methods/" nid)
:uri (u/join cf/public-uri "api/rpc/command/" nid)
:credentials "include"
:headers {"accept" "application/transit+json,text/event-stream,*/*"
"x-external-session-id" (cf/external-session-id)
@@ -207,7 +207,7 @@
(defmethod cmd! ::multipart-upload
[id params]
(->> (http/send! {:method :post
:uri (u/join cf/public-uri "api/main/methods/" (name id))
:uri (u/join cf/public-uri "api/rpc/command/" (name id))
:credentials "include"
:headers {"x-external-session-id" (cf/external-session-id)
"x-event-origin" (::ev/origin (meta params))}

View File

@@ -70,7 +70,7 @@
:default-value value
:on-key-up on-key-up
:max-length max-input-length
:on-blur accept-edition}]
:on-blur cancel-edition}]
[:span {:class [(stl/css :editable-label-text) class-label]
:title tooltip}

View File

@@ -89,14 +89,14 @@
(let [value (rotate-option-backward options index length)]
(swap! state* assoc :current-value value)
(when (fn? on-change)
(on-change value)))
(on-change (dm/str value))))
(or (kbd/right-arrow? e)
(kbd/down-arrow? e))
(let [value (rotate-option-forward options index)]
(swap! state* assoc :current-value value)
(when (fn? on-change)
(on-change value)))
(on-change (dm/str value))))
(or (kbd/enter? e)
(kbd/space? e))

View File

@@ -13,7 +13,6 @@
[app.main.ui.ds.controls.input :refer [input*]]
[app.main.ui.ds.controls.numeric-input :refer [numeric-input*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
@@ -61,7 +60,6 @@
:Loader loader*
:RawSvg raw-svg*
:Select select*
:Switch switch*
:Combobox combobox*
:Text text*
:TabSwitcher tab-switcher*

View File

@@ -7,10 +7,9 @@
@use "ds/_utils.scss" as *;
// TODO: create actual tokens once we have them from design
$br-8: px2rem(8);
$br-4: px2rem(4);
$br-6: px2rem(6);
$br-8: px2rem(8);
$br-12: px2rem(12);
$br-circle: 50%;
$b-1: px2rem(1);

View File

@@ -79,7 +79,6 @@ $grayish-red: #bfbfbf;
--color-accent-select: #{$purple-600-10};
--color-accent-action: #{$purple-400};
--color-accent-action-hover: #{$purple-500};
--color-accent-off: #{$gray-50};
--color-accent-success: #{$green-500};
--color-background-success: #{$green-200};
@@ -93,7 +92,6 @@ $grayish-red: #bfbfbf;
--color-background-default: #{$white};
--color-accent-default: #{$gray-100};
--color-icon-default: #{$blue-teal-700};
--color-background-disabled: #{$gray-200};
--color-background-primary: #{$white};
--color-background-secondary: #{$gray-200};
@@ -107,7 +105,6 @@ $grayish-red: #bfbfbf;
--color-static-black: #{$black};
--color-shadow-dark: #{color.change($gray-200, $alpha: 0.6)};
--color-shadow-light: #{color.change($black, $alpha: 0.3)};
--color-overlay-default: #{$white-60};
--color-overlay-onboarding: #{$white-90};
--color-canvas: #{$grayish-red};
@@ -130,7 +127,6 @@ $grayish-red: #bfbfbf;
--color-accent-select: #{$mint-250-10};
--color-accent-action: #{$purple-400};
--color-accent-action-hover: #{$purple-500};
--color-accent-off: #{$gray-50};
--color-accent-success: #{$green-500};
--color-background-success: #{$green-950};
@@ -144,7 +140,6 @@ $grayish-red: #bfbfbf;
--color-background-default: #{$gray-950};
--color-accent-default: #{$gray-800};
--color-icon-default: #{$grayish-blue-500};
--color-background-disabled: #{$gray-800};
--color-background-primary: #{$gray-950};
--color-background-secondary: #{$black};
@@ -158,7 +153,6 @@ $grayish-red: #bfbfbf;
--color-static-black: #{$black};
--color-shadow-dark: #{color.change($black, $alpha: 0.6)};
--color-shadow-light: #{color.change($black, $alpha: 0.3)};
--color-overlay-default: #{$gray-950-60};
--color-overlay-onboarding: #{$gray-950-90};
--color-canvas: #{$grayish-red};

View File

@@ -202,9 +202,7 @@
dom/get-value)]
(reset! selected-id* value)
(reset! filter-id* value)
(reset! focused-id* nil)
(when (fn? on-change)
(on-change value)))))
(reset! focused-id* nil))))
selected-option
(mf/with-memo [options selected-id]

View File

@@ -58,24 +58,14 @@
.variant-ghost {
--select-background-color: transparent;
--select-text-color: var(--color-foreground-secondary);
inline-size: fit-content;
padding-inline: var(--sp-xxs);
& .arrow {
margin-inline-start: var(--sp-xs);
}
&:is(:hover, [aria-expanded="true"]) {
--select-text-color: var(--color-foreground-primary);
--select-icon-color: var(--color-foreground-primary);
}
&:is(:focus-visible, :disabled) {
--select-background-color: transparent;
--select-text-color: var(--color-foreground-primary);
--select-icon-color: var(--color-foreground-primary);
}
}

View File

@@ -100,7 +100,8 @@
(mf/defc options-dropdown*
{::mf/schema schema:options-dropdown}
;; TODO: Review schema
;; {::mf/schema schema:options-dropdown}
[{:keys [ref on-click options selected focused empty-to-end align] :rest props}]
(let [align
(d/nilv align :left)

View File

@@ -25,7 +25,7 @@
[:focused {:optional true} :boolean]])
(mf/defc token-option*
{::mf/schema schema:token-option}
;; {::mf/schema schema:token-option}
[{:keys [id name on-click selected ref focused resolved] :rest props}]
(let [internal-id (mf/use-id)
id (d/nilv id internal-id)]
@@ -56,5 +56,12 @@
[:span {:aria-labelledby (dm/str id "-name")}
name]]
(when resolved
[:> :span {:class (stl/css :option-pill)}
resolved])]))
(cond
(map? resolved)
(for [[k v] resolved]
[:div {:key (str k)}
[:span (dm/str (d/name k) ": ")]
[:strong (str v)]])
:else
[:span {:class (stl/css :option-pill)}
resolved]))]))

View File

@@ -1,77 +0,0 @@
;; This Source Code Form is subject to the terms of the Mozilla Public
;; License, v. 2.0. If a copy of the MPL was not distributed with this
;; file, You can obtain one at http://mozilla.org/MPL/2.0/.
;;
;; Copyright (c) KALEIDOS INC
(ns app.main.ui.ds.controls.switch
(:require-macros
[app.main.style :as stl])
(:require
[app.common.data :as d]
[app.util.dom :as dom]
[app.util.keyboard :as kbd]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def ^:private schema:switch
[:map
[:id {:optional true} :string]
[:class {:optional true} :string]
[:label {:optional true} [:maybe :string]]
[:aria-label {:optional true} [:maybe :string]]
[:default-checked {:optional true} [:maybe :boolean]]
[:on-change {:optional true} [:maybe fn?]]
[:disabled {:optional true} :boolean]])
(mf/defc switch*
{::mf/schema schema:switch}
[{:keys [id class label aria-label default-checked on-change disabled] :rest props} ref]
(let [checked* (mf/use-state default-checked)
checked? (deref checked*)
disabled? (d/nilv disabled false)
has-label? (not (str/blank? label))
handle-toggle
(mf/use-fn
(mf/deps on-change checked? disabled?)
#(when-not disabled?
(let [updated-checked? (not checked?)]
(reset! checked* updated-checked?)
(when on-change
(on-change updated-checked?)))))
handle-keydown
(mf/use-fn
(mf/deps handle-toggle)
(fn [event]
(dom/prevent-default event)
(when-not disabled?
(when (or (kbd/space? event) (kbd/enter? event))
(handle-toggle event)))))
props
(mf/spread-props props {:ref ref
:role "switch"
:aria-label (when-not has-label?
aria-label)
:class [class (stl/css-case :switch true
:off (false? checked?)
:neutral (nil? checked?)
:on (true? checked?))]
:aria-checked checked?
:tab-index (if disabled? -1 0)
:on-click handle-toggle
:on-key-down handle-keydown
:disabled disabled?})]
[:> :div props
[:div {:id id
:class (stl/css :switch-track)}
[:div {:class (stl/css :switch-thumb)}]]
(when has-label?
[:label {:for id
:class (stl/css :switch-label)}
label])]))

View File

@@ -1,52 +0,0 @@
{ /* This Source Code Form is subject to the terms of the Mozilla Public
License, v. 2.0. If a copy of the MPL was not distributed with this
file, You can obtain one at http://mozilla.org/MPL/2.0/.
Copyright (c) KALEIDOS INC */ }
import { Canvas, Meta } from '@storybook/addon-docs/blocks';
import * as Switch from "./switch.stories";
<Meta title="Controls/Switch" />
# Switch
The `switch*` component is a toggle control. It allows users to switch between two mutually exclusive states (`false` or `true`), while also accepting an initial third state (`nil`).
<Canvas of={Switch.Default} />
## Anatomy
The switch component consists of three main parts:
- **Label** (optional): the text that describes what the switch controls. Clicking on this text also works for toggling.
- **Track**: the pill-shaped background.
- **Thumb**: the knob that moves between positions.
## Basic usage
```clj
[:> switch* {:label "Toggle something"
:default-checked nil
:on-change handle-change
:disabled false}]
```
## Accesibility
When no visible label is provided, use `:aria-label` for accessibility.
## Usage Guidelines
### When to Use
- For boolean settings that take effect immediately.
- In preference panels and configuration screens.
- For ternary states where the third state is only relevant at the beginning (e.g., selection of multiple elements with opposite states).
### When Not to Use
- For actions that require confirmation (use buttons instead).
- For multiple choice selections (use radio buttons or select).
- For temporary states that need explicit "Apply" action.
- For ternary states that require passing through all three states.

View File

@@ -1,118 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
@use "ds/_borders.scss" as *;
@use "ds/_sizes.scss" as *;
@use "ds/_utils.scss" as *;
@use "ds/colors.scss" as *;
@use "ds/spacing.scss" as *;
@use "ds/typography.scss" as t;
.switch {
--switch-label-foreground-color: var(--color-foreground-primary);
--switch-track-outline-color: none;
--switch-track-shadow: inset 0 1px 2px var(--color-shadow-light);
--switch-thumb-shadow: 0 1px 2px var(--color-shadow-light);
display: grid;
grid-template-columns: 1fr auto;
align-items: center;
gap: var(--sp-s);
inline-size: fit-content;
outline: none;
&.off {
--switch-track-justify-content: start;
--switch-track-background-color: var(--color-foreground-secondary);
--switch-thumb-width: #{px2rem(14)};
--switch-thumb-height: #{px2rem(14)};
--switch-thumb-background-color: var(--color-accent-off);
--switch-thumb-border-radius: #{$br-circle};
}
&.neutral {
--switch-track-justify-content: center;
--switch-track-background-color: var(--color-accent-tertiary);
--switch-thumb-width: #{px2rem(14)};
--switch-thumb-height: #{px2rem(4)};
--switch-thumb-background-color: var(--color-accent-off);
--switch-thumb-border-radius: #{$br-8};
}
&.on {
--switch-track-justify-content: end;
--switch-track-background-color: var(--color-accent-tertiary);
--switch-thumb-width: #{px2rem(14)};
--switch-thumb-height: #{px2rem(14)};
--switch-thumb-background-color: var(--color-accent-off);
--switch-thumb-border-radius: #{$br-circle};
}
&[disabled] {
pointer-events: none;
--switch-label-foreground-color: var(--color-foreground-secondary);
--switch-track-shadow: none;
--switch-thumb-shadow: none;
}
&.off[disabled] {
--switch-track-background-color: var(--color-background-primary);
--switch-track-border-color: var(--color-background-disabled);
--switch-thumb-background-color: var(--color-background-disabled);
}
&.on[disabled],
&.neutral[disabled] {
--switch-track-background-color: var(--color-background-disabled);
--switch-thumb-background-color: var(--color-background-primary);
}
&:focus-visible {
--switch-track-outline-color: var(--color-accent-primary);
}
&:hover {
--switch-thumb-background-color: var(--color-static-white);
}
}
.switch-label {
@include t.use-typography("body-small");
color: var(--switch-label-foreground-color);
user-select: none;
}
.switch-track {
display: flex;
align-items: center;
justify-content: var(--switch-track-justify-content);
inline-size: px2rem(30);
block-size: px2rem(18);
padding: var(--sp-xxs);
border-radius: $br-12;
border: $b-1 solid var(--switch-track-border-color);
outline: $b-1 solid var(--switch-track-outline-color);
outline-offset: $b-1;
box-shadow: var(--switch-track-shadow);
background-color: var(--switch-track-background-color);
}
.switch-thumb {
inline-size: var(--switch-thumb-width);
block-size: var(--switch-thumb-height);
border-radius: var(--switch-thumb-border-radius);
box-shadow: var(--switch-thumb-shadow);
background-color: var(--switch-thumb-background-color);
}

View File

@@ -1,65 +0,0 @@
// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
//
// Copyright (c) KALEIDOS INC
import * as React from "react";
import Components from "@target/components";
const { Switch } = Components;
export default {
title: "Controls/Switch",
component: Switch,
argTypes: {
label: {
control: { type: "text" },
description: "Label text displayed next to the switch",
},
disabled: {
control: { type: "boolean" },
description: "Whether the switch is disabled",
}
},
args: {
disabled: false
},
parameters: {
controls: { exclude: ["id", "class", "aria-label", "default-checked", "on-change"] },
},
render: ({ ...args }) => (
<Switch {...args} />
),
};
export const Default = {
args: {
label: "Toggle something",
disabled: false,
},
render: ({ ...args }) => (
<Switch {...args} />
),
};
export const WithoutLabel = {
args: {
disabled: false,
},
render: ({ ...args }) => (
<Switch {...args} />
),
};
export const WithLongLabel = {
args: {
label: "This is a very long label that demonstrates how the switch component handles text wrapping and layout when the label content is extensive",
disabled: false,
},
render: ({ ...args }) => (
<div style={{ maxWidth: "300px" }}>
<Switch {...args} />
</div>
),
};

View File

@@ -176,7 +176,7 @@
props
(mf/spread-props props {:class [class (stl/css :tabs)]})]
[:> :div props
[:> :article props
[:div {:class (stl/css :padding-wrapper)}
[:> tab-nav* {:button-position action-button-position
:action-button action-button

View File

@@ -25,25 +25,20 @@
(mf/defc input-with-meta*
{::mf/schema schema:input-with-meta}
[{:keys [value meta max-length is-editing on-blur] :rest props}]
(let [title (if meta (str value ": " meta) value)
editing* (mf/use-state (d/nilv is-editing false))
editing? (deref editing*)
input-ref (mf/use-ref)
last-node* (mf/use-ref nil)
(let [editing* (mf/use-state (d/nilv is-editing false))
editing? (deref editing*)
ref-cb (mf/use-fn
(fn [node]
;; We need to keep the last not-null node for cleanup
(mf/set-ref-val! input-ref node)
(when node
(mf/set-ref-val! last-node* node))))
input-ref (mf/use-ref)
input (mf/ref-val input-ref)
title (if meta (str value ": " meta) value)
on-edit
(mf/use-fn
(fn [event]
(dom/stop-propagation event)
(reset! editing* true)
(dom/focus! (mf/ref-val input-ref))))
(dom/focus! input)))
on-stop-edit
(mf/use-fn
@@ -69,7 +64,7 @@
(when ^boolean enter? (dom/blur! node))
(when ^boolean esc? (dom/blur! node)))))
props (mf/spread-props props {:ref ref-cb
props (mf/spread-props props {:ref input-ref
:default-value value
:max-length (d/nilv max-length max-input-length)
:auto-focus true
@@ -77,18 +72,6 @@
:on-blur on-stop-edit
:on-key-down handle-key-down})]
;; Cleanup: Simulate a blur event
(mf/with-effect [on-blur last-node*]
(fn []
(let [input (mf/ref-val last-node*)
fake-blur-event #js {:type "blur"
:target input
:currentTarget input
:stopPropagation (fn [])
:preventDefault (fn [])}]
(when input
(on-blur fake-blur-event)))))
(if editing?
[:div {:class (stl/css :input-with-meta-edit-container)}
[:> input* props]]

View File

@@ -250,7 +250,6 @@
[:> style-box* {:panel :shadow
:shorthand (:shadow shorthands)}
[:> shadow-panel* {:shapes shapes
:resolved-tokens resolved-active-tokens
:color-space color-space
:on-shadow-shorthand set-shorthands}]]))

View File

@@ -15,17 +15,6 @@
[app.util.code-gen.style-css-formats :as scf]
[rumext.v2 :as mf]))
(defn- get-applied-tokens-in-shape
[shape-tokens property]
(get shape-tokens property))
(defn- get-resolved-token
[property shape resolved-tokens]
(let [shape-tokens (:applied-tokens shape)
applied-tokens-in-shape (get-applied-tokens-in-shape shape-tokens property)
token (get resolved-tokens applied-tokens-in-shape)]
token))
(defn- generate-shadow-shorthand
[shapes]
(when (= (count shapes) 1)
@@ -41,7 +30,7 @@
(dm/str shorthand-property shorthand-value ";"))))
(mf/defc shadow-panel*
[{:keys [shapes resolved-tokens color-space on-shadow-shorthand]}]
[{:keys [shapes color-space on-shadow-shorthand]}]
(let [shorthand* (mf/use-state #(generate-shadow-shorthand shapes))
shorthand (deref shorthand*)]
(mf/use-effect
@@ -52,25 +41,16 @@
:property shorthand})))
[:div {:class (stl/css :shadow-panel)}
(for [shape shapes]
(let [composite-shadow-token (get-resolved-token :shadow shape resolved-tokens)]
(for [[idx shadow] (map-indexed vector (:shadow shape))]
[:div {:key (dm/str idx) :class (stl/css :shadow-shape)}
(when composite-shadow-token
[:> properties-row* {:term "Shadow"
:detail (:name composite-shadow-token)
:token composite-shadow-token
:property (:name composite-shadow-token)
:copiable true}])
[:> color-properties-row* {:term "Shadow Color"
:color (:color shadow)
:format color-space
:copiable true}]
(let [value (dm/str (:offset-x shadow) "px" " " (:offset-y shadow) "px" " " (:blur shadow) "px" " " (:spread shadow) "px")
property-name (cmm/get-css-rule-humanized (:style shadow))
property-value (css/shadow->css shadow)]
[:> properties-row* {:term property-name
:detail (dm/str value)
:property property-value
:copiable true}])])))]))
(for [[idx shadow] (map-indexed vector (:shadow shape))]
[:div {:key (dm/str idx) :class (stl/css :shadow-shape)}
[:> color-properties-row* {:term "Shadow Color"
:color (:color shadow)
:format color-space
:copiable true}]
(let [value (dm/str (:offset-x shadow) "px" " " (:offset-y shadow) "px" " " (:blur shadow) "px" " " (:spread shadow) "px")
property-name (cmm/get-css-rule-humanized (:style shadow))
property-value (css/shadow->css shadow)]
[:> properties-row* {:term property-name
:detail (dm/str value)
:property property-value
:copiable true}])]))]))

View File

@@ -62,7 +62,7 @@
property-value (:name typography)]
(when typography
[:> properties-row* {:term "Typography"
:detail property-value
:detail (:name typography)
:property property-value
:copiable true}])))
@@ -197,8 +197,7 @@
(reset! copied* true)
(wapi/write-to-clipboard formatted-text)
(tm/schedule 1000 #(reset! copied* false)))))]
[:pre {:class (stl/css :text-content-wrapper)
:role "presentation"}
[:div {:class (stl/css :text-content-wrapper)}
[:> property-detail-copiable* {:copied copied
:on-click copy-text}
[:span {:class (stl/css :text-content)

View File

@@ -89,8 +89,7 @@
(wapi/write-to-clipboard copiable-value)
(tm/schedule 1000 #(reset! copied* false))))]
[:*
[:dl {:class [(stl/css :property-row) class]
:data-testid "property-row"}
[:dl {:class [(stl/css :property-row) class]}
[:dt {:class (stl/css :property-term)} term]
[:dd {:class (stl/css :property-detail)}
(if token
@@ -112,12 +111,12 @@
:copied copied
:on-click copy-attr} formatted-color-value])]]
(when (:image color)
[:figure {:class (stl/css :color-image-preview)}
[:div {:class (stl/css :color-image-preview)}
[:div {:class (stl/css :color-image-preview-wrapper)}
[:img {:class (stl/css :color-image)
:src color-image-url
:title color-image-name
:alt (tr "inspect.attributes.image.preview")}]]
:alt ""}]]
[:> button* {:variant "secondary"
:to color-image-url
:target "_blank"

View File

@@ -45,37 +45,24 @@
(reset! copied* true)
(wapi/write-to-clipboard copiable-value)
(tm/schedule 1000 #(reset! copied* false))))]
[:dl {:class [(stl/css :property-row) class]
:data-testid "property-row"}
[:dl {:class [(stl/css :property-row) class]}
[:dt {:class (stl/css :property-term)} term]
[:dd {:class (stl/css :property-detail)}
(if copiable?
(if token
(let [token-type (:type token)]
[:> tooltip* {:id (:name token)
:class (stl/css :tooltip-token-wrapper)
:content #(mf/html
[:div {:class (stl/css :tooltip-token)}
[:div {:class (stl/css :tooltip-token-title)}
(tr "inspect.tabs.styles.token.resolved-value")]
[:div {:class (stl/css :tooltip-token-value)}
(cond
(= :typography token-type)
[:ul {:class (stl/css :tooltip-token-resolved-values)}
(for [[property value] (:resolved-value token)]
[:li {:key property}
(str (category-dictionary property) ": " (format-token-value value))])]
(= :shadow token-type)
[:ul {:class (stl/css :tooltip-token-resolved-values)}
(for [property (:resolved-value token)
[key value] property]
[:li {:key key}
(str (category-dictionary key) ": " (format-token-value value))])]
:else
(:resolved-value token))]])}
[:> property-detail-copiable* {:token token
:copied copied
:on-click copy-attr} detail]])
[:> tooltip* {:id (:name token)
:class (stl/css :tooltip-token-wrapper)
:content #(mf/html
[:div {:class (stl/css :tooltip-token)}
[:div {:class (stl/css :tooltip-token-title)} (tr "inspect.tabs.styles.token.resolved-value")]
[:div {:class (stl/css :tooltip-token-value)} (if (= :typography (:type token))
[:ul {:class (stl/css :tooltip-token-resolved-values)}
(for [[property value] (:resolved-value token)]
[:li {:key property} (str (category-dictionary property) ": " (format-token-value value))])]
(:resolved-value token))]])}
[:> property-detail-copiable* {:token token
:copied copied
:on-click copy-attr} detail]]
[:> property-detail-copiable* {:copied copied
:on-click copy-attr} detail])
detail)]]))

View File

@@ -12,7 +12,6 @@
[app.common.pprint :as pp]
[app.common.uri :as u]
[app.common.uuid :as uuid]
[app.config :as cf]
[app.main.data.auth :refer [is-authenticated?]]
[app.main.data.common :as dcm]
[app.main.data.event :as ev]
@@ -65,7 +64,7 @@
[:div {:class (stl/css :container)} children]]
[:div {:class (stl/css :deco-after2)}
[:span (tr "labels.copyright-period")]
[:span (tr "labels.copyright")]
deprecated-icon/logo-error-screen
[:span (tr "not-found.made-with-love")]]]))
@@ -316,10 +315,9 @@
trace (:app.main.errors/trace data)
instance (:app.main.errors/instance data)]
(with-out-str
(println "Hint: " (or (:hint data) (ex-message instance) "--"))
(println "Prof ID: " (str (or profile-id "--")))
(println "Team ID: " (str (or team-id "--")))
(println "URI: " cf/public-uri)
(println "Hint: " (or (:hint data) (ex-message instance) "--"))
(println "Prof ID:" (str (or profile-id "--")))
(println "Team ID:" (str (or team-id "--")))
(when-let [file-id (:file-id data)]
(println "File ID:" (str file-id)))

View File

@@ -94,7 +94,7 @@
(dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to)))))
(mf/defc colorpicker
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}]
[{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab]}]
(let [state (mf/deref refs/colorpicker)
node-ref (mf/use-ref)
@@ -542,7 +542,6 @@
[:> token-section* {:combined-tokens combined-tokens
:on-token-change on-token-change
:applied-token applied-token
:color-origin color-origin}])]
(when (fn? on-accept)
[:div {:class (stl/css :actions)}
@@ -729,7 +728,6 @@
on-token-change
on-close
tab
applied-token
on-accept]}]
(let [vport (mf/deref viewport)
dirty? (mf/use-var false)
@@ -792,7 +790,6 @@
:disable-opacity disable-opacity
:disable-image disable-image
:on-token-change on-token-change
:applied-token applied-token
:on-change on-change'
:origin origin
:tab tab

View File

@@ -103,7 +103,7 @@
(mf/defc set-section*
{::mf/private true}
[{:keys [collapsed toggle-sets-open group-or-set name color-origin on-token-change applied-token] :rest props}]
[{:keys [collapsed toggle-sets-open group-or-set name color-origin on-token-change] :rest props}]
(let [list-style* (mf/use-state :list)
list-style (deref list-style*)
@@ -207,7 +207,8 @@
(let [selected? (case color-origin
:fill (= has-color-tokens? (:name token))
:stroke-color (= has-stroke-tokens? (:name token))
:color-selection (= applied-token (:name token))
:color-selection (or (= has-color-tokens? (:name token))
(= has-stroke-tokens? (:name token)))
false)]
(if (= :grid list-style)
[:> grid-item* {:key (str "token-grid-" (:id token))
@@ -265,7 +266,7 @@
(mf/defc token-section*
{}
[{:keys [combined-tokens color-origin on-token-change applied-token] :rest props}]
[{:keys [combined-tokens color-origin on-token-change] :rest props}]
(let [sets (set (mapv label-group-or-set combined-tokens))
filter-term* (mf/use-state "")
filter-term (deref filter-term*)
@@ -310,7 +311,6 @@
:color-origin color-origin
:on-token-change on-token-change
:name name
:applied-token applied-token
:group-or-set combined-sets}]))]
[:> token-empty-state*])]
[:> token-empty-state*])))

View File

@@ -88,9 +88,6 @@
.color-tokens-inputs {
overflow: auto;
display: flex;
flex-direction: column;
gap: var(--sp-s);
}
// Title bar

View File

@@ -35,13 +35,11 @@
}
[data-itype="inline"] {
box-sizing: content-box;
display: inline;
line-break: auto;
line-height: inherit;
caret-color: var(--text-editor-caret-color);
white-space-collapse: pre;
word-break: normal;
overflow-wrap: break-word;
tab-size: 2;
-o-tab-size: 2;
}

View File

@@ -155,61 +155,65 @@
tabs-action-button
(mf/with-memo []
(mf/html [:> collapse-button* {}]))]
(mf/html [:> collapse-button* {}]))
[:> (mf/provider muc/sidebar) {:value :left}
[:aside {:ref parent-ref
:id "left-sidebar-aside"
:data-testid "left-sidebar"
:data-left-sidebar-width (str width)
:class aside-class
:style {:--left-sidebar-width (dm/str width "px")}}
active-tokens-by-type
(mf/with-memo [resolved-active-tokens]
(delay (ctob/group-by-type resolved-active-tokens)))]
[:> left-header*
{:file file
:layout layout
:project project
:page-id page-id
:class (stl/css :left-header)}]
[:> (mf/provider muc/active-tokens-by-type) {:value active-tokens-by-type}
[:> (mf/provider muc/sidebar) {:value :left}
[:aside {:ref parent-ref
:id "left-sidebar-aside"
:data-testid "left-sidebar"
:data-left-sidebar-width (str width)
:class aside-class
:style {:--left-sidebar-width (dm/str width "px")}}
[:div {:on-pointer-down on-pointer-down
:on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move
:class (stl/css :resize-area)}]
[:> left-header*
{:file file
:layout layout
:project project
:page-id page-id
:class (stl/css :left-header)}]
(cond
(true? shortcuts?)
[:> shortcuts-container* {:class (stl/css :settings-bar-content)}]
[:div {:on-pointer-down on-pointer-down
:on-lost-pointer-capture on-lost-pointer-capture
:on-pointer-move on-pointer-move
:class (stl/css :resize-area)}]
(true? show-debug?)
[:> debug-panel* {:class (stl/css :settings-bar-content)}]
(cond
(true? shortcuts?)
[:> shortcuts-container* {:class (stl/css :settings-bar-content)}]
:else
[:div {:class (stl/css :settings-bar-content)}
[:> tab-switcher* {:tabs tabs
:default "layers"
:selected (name section)
:on-change on-tab-change
:class (stl/css :left-sidebar-tabs)
:action-button-position "start"
:action-button tabs-action-button}
(true? show-debug?)
[:> debug-panel* {:class (stl/css :settings-bar-content)}]
(case section
:assets
[:> assets-toolbox*
{:size (- width 58)
:file-id file-id}]
:else
[:div {:class (stl/css :settings-bar-content)}
[:> tab-switcher* {:tabs tabs
:default "layers"
:selected (name section)
:on-change on-tab-change
:class (stl/css :left-sidebar-tabs)
:action-button-position "start"
:action-button tabs-action-button}
:tokens
[:> tokens-sidebar-tab*
{:tokens-lib tokens-lib
:active-tokens active-tokens
:resolved-active-tokens resolved-active-tokens}]
(case section
:assets
[:> assets-toolbox*
{:size (- width 58)
:file-id file-id}]
:layers
[:> layers-content*
{:layout layout
:width width}])]])]]))
:tokens
[:> tokens-sidebar-tab*
{:tokens-lib tokens-lib
:active-tokens active-tokens}]
:layers
[:> layers-content*
{:layout layout
:width width}])]])]]]))
;; --- Right Sidebar (Component)

View File

@@ -38,7 +38,6 @@
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.combobox :refer [combobox*]]
[app.main.ui.ds.controls.select :refer [select*]]
[app.main.ui.ds.controls.switch :refer [switch*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.product.input-with-meta :refer [input-with-meta*]]
[app.main.ui.hooks :as h]
@@ -502,26 +501,15 @@
(reset! key* (uuid/next))
(st/emit! (ntf/error error-msg)))}
params {:shapes shapes :pos pos :val val}]
(st/emit! (dwv/variants-switch (with-meta params mdata)))))))
switch-component-toggle
(mf/use-fn
(mf/deps shapes)
(fn [pos boolean-pair val]
(let [inverted-boolean-pair (d/invert-map boolean-pair)
val (get inverted-boolean-pair val)]
(switch-component pos val))))]
(st/emit! (dwv/variants-switch (with-meta params mdata)))))))]
[:*
[:div {:class (stl/css :variant-property-list)}
(for [[pos prop] (map-indexed vector props-first)]
(let [mixed-value? (not-every? #(= (:value prop) (:value (nth % pos))) properties)
options (get-options (:name prop))
boolean-pair (ctv/find-boolean-pair (mapv :id options))
options (cond-> options
mixed-value?
(conj {:id mixed-label :label mixed-label :dimmed true}))]
options (cond-> (get-options (:name prop))
mixed-value?
(conj {:id mixed-label, :label mixed-label :dimmed true}))]
[:div {:key (str pos mixed-value?)
:class (stl/css :variant-property-container)}
@@ -530,17 +518,12 @@
[:div {:class (stl/css :variant-property-name)}
(:name prop)]]
(if boolean-pair
[:div {:class (stl/css :variant-property-value-switch-wrapper)}
[:> switch* {:default-checked (if mixed-value? nil (get boolean-pair (:value prop)))
:on-change (partial switch-component-toggle pos boolean-pair)
:key (str (:value prop) "-" key)}]]
[:div {:class (stl/css :variant-property-value-wrapper)}
[:> select* {:default-selected (if mixed-value? mixed-label (:value prop))
:options options
:empty-to-end true
:on-change (partial switch-component pos)
:key (str (:value prop) "-" key)}]])]))]
[:div {:class (stl/css :variant-property-value-wrapper)}
[:> select* {:default-selected (if mixed-value? mixed-label (:value prop))
:options options
:empty-to-end true
:on-change (partial switch-component pos)
:key (str (:value prop) "-" key)}]]]))]
(if (seq malformed-comps)
[:div {:class (stl/css :variant-warning)}

View File

@@ -662,17 +662,8 @@
align-self: center;
}
.variant-property-value-switch-wrapper {
grid-column: span 5;
display: flex;
align-items: center;
block-size: $sz-32;
padding-inline-start: var(--sp-s);
}
.variant-property-name {
@include t.use-typography("body-small");
margin-inline-start: var(--sp-s);
color: var(--color-foreground-secondary);
display: block;
overflow: hidden;

View File

@@ -21,7 +21,6 @@
.flex-element-menu {
@include sidebar.option-grid-structure;
gap: var(--sp-xs);
}
.behaviour-menu {

View File

@@ -301,7 +301,7 @@
(mf/use-fn
(mf/deps ids shapes)
(fn [value attr]
(if (or (string? value) (number? value))
(if (or (string? value) (int? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(run! #(do-size-change value attr) shapes))
@@ -330,7 +330,7 @@
(mf/use-fn
(mf/deps ids)
(fn [value attr]
(if (or (string? value) (number? value))
(if (or (string? value) (int? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(run! #(do-position-change %1 value attr) shapes))
@@ -353,7 +353,7 @@
(mf/use-fn
(mf/deps ids)
(fn [value]
(if (or (string? value) (number? value))
(if (or (string? value) (int? value))
(do
(st/emit! (udw/trigger-bounding-box-cloaking ids))
(run! #(do-rotation-change value) shapes))

View File

@@ -239,7 +239,7 @@
open-modal
(mf/use-fn
(mf/deps disable-gradient disable-opacity disable-image disable-picker on-change on-close on-open tokens index applied-token)
(mf/deps disable-gradient disable-opacity disable-image disable-picker on-change on-close on-open tokens index)
(fn [color pos tab]
(let [color (cond
^boolean has-multiple-colors
@@ -263,7 +263,6 @@
(when on-close
(on-close value opacity id file-id)))
:active-tokens tokens
:applied-token applied-token
:color-origin origin
:tab tab
:origin :sidebar

View File

@@ -11,6 +11,7 @@
[app.main.data.workspace.tokens.library-edit :as dwtl]
[app.main.refs :as refs]
[app.main.store :as st]
[app.main.ui.context :as muc]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.typography.text :refer [text*]]
[app.main.ui.workspace.tokens.management.context-menu :refer [token-context-menu]]
@@ -115,7 +116,9 @@
[empty-group filled-group]
(mf/with-memo [tokens-by-type]
(get-sorted-token-groups tokens-by-type))]
(get-sorted-token-groups tokens-by-type))
active-theme-tokens (mf/use-ctx muc/active-tokens-by-type)]
(mf/with-effect [tokens-lib selected-token-set-id]
(when (and tokens-lib
@@ -150,7 +153,7 @@
:selected-ids selected
:selected-shapes selected-shapes
:is-selected-inside-layout is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:active-theme-tokens active-theme-tokens
:tokens tokens}]))
(for [type empty-group]
@@ -158,5 +161,5 @@
:type type
:selected-shapes selected-shapes
:is-selected-inside-layout :is-selected-inside-layout
:active-theme-tokens resolved-active-tokens
:active-theme-tokens active-theme-tokens
:tokens []}])]))

View File

@@ -119,7 +119,7 @@
(defn- validate-token-with [token validators]
(if-let [error (some (fn [validate] (validate token)) validators)]
(rx/throw {:errors [error]})
(rx/throw {:errors [error] :pepito "kakota"})
(rx/of token)))
(def ^:private default-validators
@@ -151,7 +151,10 @@
;; Simple validation of the editing token
(rx/mapcat #(validate-token-with % validators))
;; Resolving token via StyleDictionary
(rx/mapcat #(validate-resolve-token % prev-token tokens)))))
(rx/mapcat #(validate-resolve-token % prev-token tokens))
(rx/catch (fn [e] (if (contains? e :errors)
{:errors (:errors e)}
(rx/throw e)))))))
(defn- check-coll-self-reference
"Invalidate a collection of `token-vals` for a self-refernce against `token-name`.,"
@@ -294,7 +297,7 @@
selected-token-set-id
action
input-value-placeholder
tokens
;; Callbacks
validate-token
on-value-resolve
@@ -622,8 +625,11 @@
:label label
:default-value default-value
:ref ref
:tokens tokens
:type token-type
:on-blur on-update-value
:on-change on-update-value
:on-external-update-value on-external-update-value
:token-resolve-result token-resolve-result}]))]
[:div {:class (stl/css :input-row)}
[:> input* {:label (tr "workspace.tokens.token-description")
@@ -662,13 +668,15 @@
;; Tabs Component --------------------------------------------------------------
(mf/defc composite-reference-input*
[{:keys [default-value on-blur on-update-value token-resolve-result reference-label reference-icon is-reference-fn]}]
[{:keys [default-value on-blur on-update-value token-resolve-result reference-label reference-icon is-reference-fn tokens]}]
[:> input-token*
{:aria-label (tr "labels.reference")
:placeholder reference-label
:icon reference-icon
:default-value (when (is-reference-fn default-value) default-value)
:on-blur on-blur
:tokens tokens
:type "composite-reference"
:on-change on-update-value
:token-resolve-result (when (or
(:errors token-resolve-result)
@@ -681,6 +689,7 @@
on-external-update-value
on-value-resolve
clear-resolve-value
tokens
custom-input-token-value-props]
:rest props}]
(let [;; Active Tab State
@@ -751,6 +760,7 @@
:on-update-value on-update-value'
:reference-icon reference-icon
:reference-label reference-label
:tokens tokens
:is-reference-fn is-reference-fn})]
[:> composite-tab
(mf/spread-props props {:default-value default-value
@@ -760,7 +770,7 @@
(mf/defc composite-form*
"Wrapper around form* that manages composite/reference tab state.
Takes the same props as form* plus a function to determine if a token value is a reference."
[{:keys [token is-reference-fn composite-tab reference-icon title update-composite-backup-value] :rest props}]
[{:keys [token is-reference-fn composite-tab reference-icon title update-composite-backup-value tokens] :rest props}]
(let [active-tab* (mf/use-state (if (is-reference-fn (:value token)) :reference :composite))
active-tab (deref active-tab*)
@@ -772,6 +782,7 @@
:set-active-tab #(reset! active-tab* %)
:composite-tab composite-tab
:reference-icon reference-icon
:tokens tokens
:reference-label (tr "workspace.tokens.reference-composite")
:title title
:update-composite-backup-value update-composite-backup-value
@@ -848,8 +859,8 @@
:on-change on-change'}]]))
(mf/defc color-picker*
[{:keys [placeholder label default-value input-ref on-blur on-update-value on-external-update-value custom-input-token-value-props token-resolve-result]}]
(let [{:keys [color on-display-colorpicker]} custom-input-token-value-props
[{:keys [ placeholder label default-value input-ref on-blur on-update-value on-external-update-value custom-input-token-value-props token-resolve-result]}]
(let [{:keys [color on-display-colorpicker tokens]} custom-input-token-value-props
color-ramp-open* (mf/use-state false)
color-ramp-open? (deref color-ramp-open*)
@@ -903,6 +914,8 @@
:default-value default-value
:ref input-ref
:on-blur on-blur
:tokens tokens
:type "color"
:on-change on-update-value
:slot-start swatch}]
(when color-ramp-open?
@@ -912,7 +925,7 @@
[:> token-value-hint* {:result token-resolve-result}]]))
(mf/defc color-form*
[{:keys [token on-display-colorpicker] :rest props}]
[{:keys [token on-display-colorpicker tokens] :rest props}]
(let [color* (mf/use-state (:value token))
color (deref color*)
on-value-resolve (mf/use-fn
@@ -926,6 +939,7 @@
(mf/deps color on-display-colorpicker)
(fn []
{:color color
:tokens tokens
:on-display-colorpicker on-display-colorpicker}))
on-get-token-value
@@ -1233,7 +1247,7 @@
:full-size true}]]))
(mf/defc font-picker-combobox*
[{:keys [default-value label aria-label input-ref on-blur on-update-value on-external-update-value token-resolve-result placeholder]}]
[{:keys [tokens default-value label aria-label input-ref on-blur on-update-value on-external-update-value token-resolve-result placeholder]}]
(let [font* (mf/use-state (fonts/find-font-family default-value))
font (deref font*)
set-font (mf/use-fn
@@ -1287,6 +1301,8 @@
:ref input-ref
:on-blur on-blur
:on-change on-update-value'
:tokens tokens
:type "font-family"
:icon i/text-font-family
:slot-end font-selector-button
:token-resolve-result token-resolve-result}]
@@ -1359,7 +1375,7 @@
:placeholder (tr "workspace.tokens.text-decoration-value-enter")}))
(mf/defc typography-value-inputs*
[{:keys [default-value on-blur on-update-value token-resolve-result]}]
[{:keys [default-value on-blur on-update-value token-resolve-result tokens]}]
(let [composite-token? (not (cto/typography-composite-token-reference? (:value token-resolve-result)))
typography-inputs (mf/use-memo typography-inputs)
errors-by-key (sd/collect-typography-errors token-resolve-result)]
@@ -1407,6 +1423,7 @@
:input-ref input-ref
:default-value (when value (cto/join-font-family value))
:on-blur on-blur
:tokens tokens
:on-update-value on-change
:on-external-update-value on-external-update-value
:token-resolve-result token-prop}]
@@ -1415,12 +1432,14 @@
:placeholder placeholder
:default-value value
:on-blur on-blur
:tokens tokens
:icon icon
:type "typography-subvalue"
:on-change on-change
:token-resolve-result token-prop}])]))]))
(mf/defc typography-form*
[{:keys [token] :rest props}]
[{:keys [token tokens] :rest props}]
(let [on-get-token-value
(mf/use-fn
(fn [e prev-composite-value]
@@ -1451,13 +1470,15 @@
:is-reference-fn cto/typography-composite-token-reference?
:title (tr "labels.typography")
:validate-token validate-typography-token
:tokens tokens
:on-get-token-value on-get-token-value
:update-composite-backup-value update-composite-backup-value})]))
(mf/defc form-wrapper*
[{:keys [token token-type] :rest props}]
[{:keys [token token-type tokens] :rest props}]
(let [token-type' (or (:type token) token-type)
props (mf/spread-props props {:token-type token-type'
props (mf/spread-props props {:token-type token-type'
:tokens tokens
:token token})]
(case token-type'
:color [:> color-form* props]

View File

@@ -7,18 +7,117 @@
(ns app.main.ui.workspace.tokens.management.create.input-tokens-value
(:require-macros [app.main.style :as stl])
(:require
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.main.data.workspace.tokens.errors :as wte]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.data.workspace.tokens.warnings :as wtw]
[app.main.ui.ds.buttons.icon-button :refer [icon-button*]]
[app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown*]]
[app.main.ui.ds.controls.utilities.hint-message :refer [hint-message*]]
[app.main.ui.ds.controls.utilities.input-field :refer [input-field*]]
[app.main.ui.ds.controls.utilities.label :refer [label*]]
[app.main.ui.ds.foundations.assets.icon :refer [icon-list]]
[app.main.ui.ds.foundations.assets.icon :refer [icon-list] :as i]
[app.util.dom :as dom]
[app.util.i18n :refer [tr]]
[app.util.object :as obj]
[cuerdas.core :as str]
[rumext.v2 :as mf]))
(def token-type->reference-types
{:color #{:color}
:dimensions #{:dimensions}
:spacing #{:spacing :dimensions}
:border-radius #{:border-radius :dimensions :sizing}
:font-family #{:font-family}
:font-size #{:font-size :sizing :dimension}
:opacity #{:opacity :number}
:rotation #{:rotation :number}
:stroke-width #{:stroke-width :dimension :sizing}
:sizing #{:sizing :dimensions}
:number #{:number}
:letter-spacing #{:letter-spacing}
:typography #{:typography}
:text-case #{:text-case}
:text-decoration #{:text-decoration}
:line-height #{:number}})
;; TODO; duplucated code with numeric-input.cljs, consider refactoring
(defn- sort-groups-and-tokens
"Sorts both the groups and the tokens inside them alphabetically.
Input:
A map where:
- keys are groups (keywords or strings, e.g. :dimensions, :colors)
- values are vectors of token maps, each containing at least a :name key
Example input:
{:dimensions [{:name \"tres\"} {:name \"quini\"}]
:colors [{:name \"azul\"} {:name \"rojo\"}]}
Output:
A sorted map where:
- groups are ordered alphabetically by key
- tokens inside each group are sorted alphabetically by :name
Example output:
{:colors [{:name \"azul\"} {:name \"rojo\"}]
:dimensions [{:name \"quini\"} {:name \"tres\"}]}"
[groups->tokens]
(into (sorted-map) ;; ensure groups are ordered alphabetically by their key
(for [[group tokens] groups->tokens]
[group (sort-by :name tokens)])))
(defn- extract-partial-brace-text
[string]
(when-let [start (str/last-index-of string "{")]
(subs string (inc start))))
(defn- filter-token-groups-by-name
[tokens filter-text]
(let [lc-filter (str/lower filter-text)]
(into {}
(keep (fn [[group tokens]]
(let [filtered (filter #(str/includes? (str/lower (:name %)) lc-filter) tokens)]
(when (seq filtered)
[group filtered]))))
tokens)))
(defn- token->dropdown-option
[token]
{:id (str (get token :id))
:type :token
:resolved-value (get token :resolved-value)
:name (get token :name)})
(defn- generate-dropdown-options
[tokens no-sets]
(if (empty? tokens)
[{:type :empty
:label (if no-sets
(tr "ds.inputs.numeric-input.no-applicable-tokens")
(tr "ds.inputs.numeric-input.no-matches"))}]
(->> tokens
(map (fn [[type items]]
(cons {:group true
:type :group
:id (dm/str "group-" (name type))
:name (name type)}
(map token->dropdown-option items))))
(interpose [{:separator true
:id "separator"
:type :separator}])
(apply concat)
(vec)
(not-empty))))
(defn get-option
[options id]
(let [options (if (delay? options) @options options)]
(or (d/seek #(= id (get % :id)) options)
(nth options 0))))
(def ^:private schema::input-token
[:map
[:label {:optional true} [:maybe :string]]
@@ -58,21 +157,119 @@
(mf/defc input-token*
{::mf/forward-ref true
::mf/schema schema::input-token}
[{:keys [class label token-resolve-result] :rest props} ref]
[{:keys [class label token-resolve-result tokens empty-to-end type on-external-update-value] :rest props} ref]
(let [error (not (nil? (:errors token-resolve-result)))
id (mf/use-id)
input-ref (mf/use-ref)
is-open* (mf/use-state false)
is-open (deref is-open*)
filter-term* (mf/use-state "")
filter-term (deref filter-term*)
listbox-id (mf/use-id)
focused-id* (mf/use-state nil)
focused-id (deref focused-id*)
selected-id* (mf/use-state (fn []))
selected-id (deref selected-id*)
empty-to-end (d/nilv empty-to-end false)
internal-ref (mf/use-ref nil)
ref (or ref internal-ref)
nodes-ref (mf/use-ref nil)
open-dropdown-ref (mf/use-ref nil)
options-ref (mf/use-ref nil)
set-option-ref
(mf/use-fn
(fn [node]
(let [state (mf/ref-val nodes-ref)
state (d/nilv state #js {})
id (dom/get-data node "id")
state (obj/set! state id node)]
(mf/set-ref-val! nodes-ref state)
(fn []
(let [state (mf/ref-val nodes-ref)
state (d/nilv state #js {})
id (dom/get-data node "id")
state (obj/unset! state id)]
(mf/set-ref-val! nodes-ref state))))))
dropdown-options
(mf/with-memo [tokens filter-term type]
(delay
(let [tokens (if (delay? tokens) @tokens tokens)
allowed (get token-type->reference-types (keyword type) #{})
tokens (select-keys tokens allowed)
sorted-tokens (sort-groups-and-tokens tokens)
partial (extract-partial-brace-text filter-term)
options (if (seq partial)
(filter-token-groups-by-name sorted-tokens partial)
sorted-tokens)
no-sets? (nil? sorted-tokens)]
(generate-dropdown-options options no-sets?))))
update-input
(mf/use-fn
(mf/deps ref)
(fn [new-value]
(when-let [node (mf/ref-val ref)]
(dom/set-value! node new-value)
(reset! is-open* false))))
on-option-click
(mf/use-fn
(mf/deps options-ref update-input)
(fn [event]
(let [node (dom/get-current-target event)
id (dom/get-data node "id")
options (mf/ref-val options-ref)
options (if (delay? options) @options options)
option (get-option options id)
name (get option :name)
new-value (str "{" name "}")]
(on-external-update-value new-value)
(reset! is-open* false))))
open-dropdown
(mf/use-fn
(fn [_]
(swap! is-open* not)))
props (mf/spread-props props {:id id
:type "text"
:class (stl/css :input)
:variant "comfortable"
:hint-type (when error "error")
:ref (or ref input-ref)})]
:slot-end (when (some? tokens)
(mf/html [:> icon-button* {:variant "action"
:icon i/tokens
:class (stl/css :invisible-button)
:aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown")
:ref open-dropdown-ref
:on-click open-dropdown}]))
:ref ref})]
(mf/with-effect [dropdown-options]
(mf/set-ref-val! options-ref dropdown-options))
[:*
[:div {:class (dm/str class " " (stl/css-case :wrapper true
:input-error error))}
[:div {:class [class (stl/css-case :wrapper true
:input-error error)]}
(when label
[:> label* {:for id} label])
[:> input-field* props]]
(when token-resolve-result
[:> token-value-hint* {:result token-resolve-result}])]))
[:> token-value-hint* {:result token-resolve-result}])
(when ^boolean is-open
(let [options (if (delay? dropdown-options) @dropdown-options dropdown-options)]
[:> options-dropdown* {:on-click on-option-click
:id listbox-id
:options options
:selected selected-id
:focused focused-id
:align :left
:empty-to-end empty-to-end
:ref set-option-ref}]))]))

View File

@@ -68,8 +68,8 @@
(clj->js))))
(mf/defc token-update-create-modal
{::mf/wrap-props false}
[{:keys [x y position token token-type action selected-token-set-id] :as _args}]
{ ::mf/props :obj}
[{:keys [x y position token token-type action selected-token-set-id tokens] :as _args}]
(let [wrapper-style (use-viewport-position-style x y position (= token-type :color))
modal-size-large* (mf/use-state (= token-type :typography))
modal-size-large? (deref modal-size-large*)
@@ -94,6 +94,7 @@
:action action
:selected-token-set-id selected-token-set-id
:token-type token-type
:tokens tokens
:on-display-colorpicker update-modal-size}]]))
;; Modals ----------------------------------------------------------------------

View File

@@ -77,7 +77,7 @@
on-popover-open-click
(mf/use-fn
(mf/deps type title modal)
(mf/deps type title modal active-theme-tokens)
(fn [event]
(dom/stop-propagation event)
(st/emit! (dwtl/set-token-type-section-open type true)
@@ -86,6 +86,7 @@
{:x (:x pos)
:y (:y pos)
:position :right
:tokens active-theme-tokens
:fields (:fields modal)
:title title
:action "create"

View File

@@ -16,7 +16,7 @@
[app.main.data.workspace.tokens.application :as dwta]
[app.main.data.workspace.tokens.color :as dwtc]
[app.main.data.workspace.tokens.format :as dwtf]
[app.main.refs :as refs]
[app.main.ui.context :as ctx]
[app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i]
[app.main.ui.ds.foundations.utilities.token.token-status :refer [token-status-icon*]]
[app.main.ui.ds.utilities.swatch :refer [swatch*]]
@@ -27,7 +27,6 @@
[rumext.v2 :as mf]))
;; Translation dictionaries
(def ^:private attribute-dictionary
{:rotation "Rotation"
:opacity "Opacity"
@@ -77,8 +76,7 @@
:y :y})
;; Helper functions
(defn partially-applied-attr
(defn- partially-applied-attr
"Translates partially applied attributes based on the dictionary."
[app-token-keys is-applied {:keys [attributes all-attributes]}]
(let [filtered-keys (if all-attributes
@@ -87,7 +85,7 @@
(when is-applied
(str/join ", " (map attribute-dictionary filtered-keys)))))
(defn translate-and-format
(defn- translate-and-format
"Translates and formats grouped values by category."
[grouped-values]
(str/join "\n"
@@ -98,6 +96,18 @@
(str/join ", " (map attribute-dictionary values)) ".")))
grouped-values)))
(defn- token-exists?
"Returns true if any token in the grouped token map has a name matching `token-name`."
[tokens-by-type token-name]
(let [clean-name (-> token-name
(str/trim)
(str/replace #"^\{" "")
(str/replace #"\}$" "")
(str/lower))]
(some (fn [[_ tokens]]
(some #(= clean-name (:name %)) tokens))
tokens-by-type)))
(defn- generate-tooltip
"Generates a tooltip for a given token"
[is-viewer shape theme-token token half-applied no-valid-value ref-not-in-active-set]
@@ -142,14 +152,6 @@
;; Otherwise only show the base title
:else base-title)))
;; FIXME: the token thould already have precalculated references, so
;; we don't need to perform this regex operation on each rerender
(defn contains-reference-value?
"Extracts the value between `{}` in a string and checks if it's in the provided vector."
[text active-tokens]
(let [match (second (re-find #"\{([^}]+)\}" text))]
(contains? active-tokens match)))
(def ^:private
xf:map-id
(map :id))
@@ -176,7 +178,6 @@
{::mf/wrap [mf/memo]}
[{:keys [on-click token on-context-menu selected-shapes is-selected-inside-layout active-theme-tokens]}]
(let [{:keys [name value errors type]} token
has-selected? (pos? (count selected-shapes))
is-reference? (cft/is-reference? token)
contains-path? (str/includes? name ".")
@@ -203,14 +204,14 @@
(not half-applied?)
(not (attributes-match-selection? selected-shapes attributes {:selected-inside-layout? is-selected-inside-layout})))
;; FIXME: move to context or props
can-edit? (:can-edit (deref refs/permissions))
can-edit?
(mf/use-ctx ctx/can-edit?)
is-viewer? (not can-edit?)
ref-not-in-active-set
(and is-reference?
(not (contains-reference-value? value active-theme-tokens)))
(not (token-exists? @active-theme-tokens value)))
no-valid-value (seq errors)

View File

@@ -11,7 +11,6 @@
[app.common.data :as d]
[app.common.data.macros :as dm]
[app.common.files.helpers :as cfh]
[app.common.logging :as log]
[app.common.math :as mth]
[app.common.types.fills :as types.fills]
[app.common.types.fills.impl :as types.fills.impl]
@@ -316,14 +315,7 @@
(aset heap32 (+ offset 11) height)
(h/call wasm/internal-module "_store_image_from_texture")
true))))
(rx/catch (fn [cause]
(log/error :hint "Could not fetch image"
:image-id image-id
:thumbnail? thumbnail?
:url url
:cause cause)
(rx/empty))))}))
true)))))}))
(defn- get-fill-images
[leaf]
@@ -1109,28 +1101,13 @@
(set! (.-width canvas) (* dpr (.-clientWidth ^js canvas)))
(set! (.-height canvas) (* dpr (.-clientHeight ^js canvas))))
(defn- get-browser
[]
(when (exists? js/navigator)
(let [user-agent (.-userAgent js/navigator)]
(when user-agent
(cond
(re-find #"(?i)firefox" user-agent) :firefox
(re-find #"(?i)chrome" user-agent) :chrome
(re-find #"(?i)safari" user-agent) :safari
(re-find #"(?i)edge" user-agent) :edge
:else :unknown)))))
(defn init-canvas-context
[canvas]
(let [gl (unchecked-get wasm/internal-module "GL")
flags (debug-flags)
context-id (if (dbg/enabled? :wasm-gl-context-init-error) "fail" "webgl2")
context (.getContext ^js canvas context-id context-options)
context-init? (not (nil? context))
browser (get-browser)
browser (sr/translate-browser browser)]
context-init? (not (nil? context))]
(when-not (nil? context)
(let [handle (.registerContext ^js gl context #js {"majorVersion" 2})]
(.makeContextCurrent ^js gl handle)
@@ -1142,10 +1119,6 @@
(h/call wasm/internal-module "_init" (/ (.-width ^js canvas) dpr) (/ (.-height ^js canvas) dpr))
(h/call wasm/internal-module "_set_render_options" flags dpr))
(set! wasm/context-initialized? true))
(h/call wasm/internal-module "_set_browser" browser)
(h/call wasm/internal-module "_set_render_options" flags dpr)
(set-canvas-size canvas)
context-init?))

View File

@@ -264,13 +264,3 @@
"regular" (unchecked-get values "normal")
"italic" (unchecked-get values "italic")
default)))
(defn translate-browser
[browser]
(case browser
:firefox 0
:chrome 1
:safari 2
:edge 3
:unknown 4
4))

View File

@@ -98,18 +98,12 @@
styles (get-styles-from-attrs node txt/text-node-attrs txt/default-text-attrs)]
(dissoc styles :line-height)))
(defn normalize-spaces
"Add zero-width spaces after forward slashes to enable word breaking"
[text]
(when text
(.replace text (js/RegExp "/" "g") "/\u200B")))
(defn get-inline-children
[inline paragraph]
[(if (and (= "" (:text inline))
(= 1 (count (:children paragraph))))
(dom/create-element "br")
(dom/create-text (normalize-spaces (:text inline))))])
(dom/create-text (:text inline)))])
(defn create-random-key
[]

View File

@@ -43,7 +43,7 @@
(defn- request-data-for-thumbnail
[file-id revn]
(let [path "api/main/methods/get-file-data-for-thumbnail"
(let [path "api/rpc/command/get-file-data-for-thumbnail"
params {:file-id file-id
:revn revn
:strip-frames-with-thumbnails true}

View File

@@ -1763,6 +1763,9 @@ msgstr "Můžete pokračovat s účtem Penpot"
msgid "labels.copy-invitation-link"
msgstr "Kopírovat odkaz"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"

View File

@@ -1995,6 +1995,10 @@ msgstr "Farbe kopieren"
msgid "labels.copy-invitation-link"
msgstr "Link kopieren"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"
msgstr "Erstellen"

View File

@@ -1696,10 +1696,6 @@ msgstr "RGBA"
msgid "inspect.attributes.fill"
msgstr "Fill"
#: src/app/main/ui/inspect/attributes/fill.cljs:53
msgid "inspect.attributes.image.preview"
msgstr "Preview of the shape's fill image"
#: src/app/main/ui/inspect/attributes/common.cljs:78, src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:127
msgid "inspect.attributes.image.download"
msgstr "Download source image"
@@ -2139,8 +2135,8 @@ msgid "labels.copy-invitation-link"
msgstr "Copy link"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright-period"
msgstr "Kaleidos © 2019-present"
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"
@@ -5578,8 +5574,8 @@ msgstr "Add"
#: src/app/main/ui/workspace/libraries.cljs:107, src/app/main/ui/workspace/libraries.cljs:133
msgid "workspace.libraries.colors"
msgid_plural "workspace.libraries.colors"
msgstr[0] "1 color"
msgid_plural "workspace.libraries.colors"
msgstr[0] "1 color"
msgstr[1] "%s colors"
#: src/app/main/ui/workspace/color_palette.cljs:147
@@ -5618,8 +5614,8 @@ msgstr "Save color style"
#: src/app/main/ui/workspace/libraries.cljs:101, src/app/main/ui/workspace/libraries.cljs:125
msgid "workspace.libraries.components"
msgid_plural "workspace.libraries.components"
msgstr[0] "1 component"
msgid_plural "workspace.libraries.components"
msgstr[0] "1 component"
msgstr[1] "%s components"
#: src/app/main/ui/workspace/libraries.cljs:349
@@ -5644,8 +5640,8 @@ msgstr "File library"
#: src/app/main/ui/workspace/libraries.cljs:104, src/app/main/ui/workspace/libraries.cljs:129
msgid "workspace.libraries.graphics"
msgid_plural "workspace.libraries.graphics"
msgstr[0] "1 graphic"
msgid_plural "workspace.libraries.graphics"
msgstr[0] "1 graphic"
msgstr[1] "%s graphics"
#: src/app/main/ui/workspace/libraries.cljs:316
@@ -5704,8 +5700,8 @@ msgstr "Unlink all typographies"
#: src/app/main/ui/workspace/libraries.cljs:110, src/app/main/ui/workspace/libraries.cljs:137
msgid "workspace.libraries.typography"
msgid_plural "workspace.libraries.typography"
msgstr[0] "1 typography"
msgid_plural "workspace.libraries.typography"
msgstr[0] "1 typography"
msgstr[1] "%s typographies"
#: src/app/main/ui/workspace/libraries.cljs:354

View File

@@ -1694,10 +1694,6 @@ msgstr "RGBA"
msgid "inspect.attributes.fill"
msgstr "Relleno"
#: src/app/main/ui/inspect/attributes/fill.cljs:53
msgid "inspect.attributes.image.preview"
msgstr "Previsualización de la imagen de relleno"
#: src/app/main/ui/inspect/attributes/common.cljs:78, src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:127
msgid "inspect.attributes.image.download"
msgstr "Descargar imagen original"
@@ -2134,8 +2130,8 @@ msgid "labels.copy-invitation-link"
msgstr "Copiar enlace"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright-period"
msgstr "© Kaleidos, 2019-presente"
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"

View File

@@ -2022,6 +2022,10 @@ msgstr "Copier la couleur"
msgid "labels.copy-invitation-link"
msgstr "Copier le lien"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"
msgstr "Créer"

View File

@@ -2031,6 +2031,10 @@ msgstr "העתקת צבע"
msgid "labels.copy-invitation-link"
msgstr "העתקת קישור"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"
msgstr "יצירה"

View File

@@ -1932,6 +1932,10 @@ msgstr "रंग कॉपी करें"
msgid "labels.copy-invitation-link"
msgstr "लिंक कॉपी"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"
msgstr "निर्माण"

View File

@@ -1755,6 +1755,10 @@ msgstr "Možeš nastaviti s Penpot računom"
msgid "labels.copy-invitation-link"
msgstr "Kopiraj vezu"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"
msgstr "Kreiraj"

View File

@@ -1859,6 +1859,10 @@ msgstr "Salin warna"
msgid "labels.copy-invitation-link"
msgstr "Salin tautan"
#: src/app/main/ui/static.cljs:63
msgid "labels.copyright"
msgstr "Kaleidos @2024"
#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:205
msgid "labels.create"
msgstr "Buat"

Some files were not shown because too many files have changed in this diff Show More