From 5f6169e1d3e2f7acc6ce9d98908c88699967a248 Mon Sep 17 00:00:00 2001 From: Eva Marco Date: Mon, 7 Sep 2026 11:29:36 +0200 Subject: [PATCH 01/16] :bug: Fix typography sample errors (#11515) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * :bug: Fix font-family sample not showing for numeric font names Setting style.fontFamily to a raw, unquoted family name (e.g. "Micro 5") parses it against CSS's grammar: a whitespace- separated sequence of s. "Micro" tokenizes fine, but a bare "5" isn't a valid CSS identifier (idents can't start with a digit) — it tokenizes as a number instead, so the whole property is invalid CSS and the browser silently drops it. Every other font in the list happened to avoid this because none of their names have a token that's purely numeric. Quote the family name, matching what font-item-preview* (the font selector's own preview, a few lines down in the same file) already does, so it's parsed as a CSS string instead of unquoted identifiers. Also falls back to the live fontsdb entry's family when the typography record's own :font-family is blank — a font that was unloaded when a typography's font/variant was last changed can leave that field nil (the same failure mode remove-nil-style-attrs already repairs for shape text spans) — and loads the font unconditionally in the collapsed asset row, matching the expanded editor, since the optical-offset cache can otherwise skip loading it entirely. AI-assisted-by: claude-sonnet-5 * :bug: Fix flaky typography sample position in automated tests The optical-centering offset for the "Ag" sample (and the font selector's fallback name label) resolves asynchronously: first paint is unshifted, then an idle-scheduled Canvas measurement lands and the sample jumps to its final position. Any test that checks position or takes a screenshot shortly after paint races that jump — whether it runs before or after is a timing accident, not a deterministic outcome, which is exactly the "sometimes a few pixels up, sometimes down" flakiness QA hit. use-optical-offset now returns [offset ready?], with both lazily initialized from the cache so a cache hit needs no async round-trip at all. sample-text-style hides the glyphs until ready?, so the sample only ever appears already in its final, correct position instead of visibly moving there after the fact. The font selector's own name label uses the same hook but always shows real text content rather than a decorative sample, so it keeps the old behavior instead: hiding it would blank out font names while scrolling, worse than the minor positional nicety it's fixing. AI-assisted-by: claude-sonnet-5 --- .../sidebar/options/menus/typography.cljs | 138 +++++++++++++----- 1 file changed, 101 insertions(+), 37 deletions(-) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs index c397c24448..04b3dcb8a6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.cljs @@ -130,6 +130,20 @@ (if-let [cached (get @optical-offset-cache key)] (p/resolved cached) (-> (fonts/ensure-loaded! font-id) + (p/then + (fn [_] + ;; ensure-loaded! only guarantees the @font-face CSS text has + ;; been injected, not that the browser has actually fetched and + ;; parsed the font file: that fetch is lazy, normally triggered + ;; by the browser laying out DOM text with the font. Canvas + ;; measureText doesn't reliably trigger it, so without this + ;; explicit wait it can silently measure the fallback font + ;; instead, and the resulting bogus offset then gets cached + ;; forever — pushing the sample glyphs outside the clipped + ;; sample box instead of just centering them slightly wrong. + (let [spec (dm/str (or weight "400") " " (or style "normal") " 16px \"" family "\"")] + (-> (.load js/document.fonts spec) + (p/catch (constantly nil)))))) (p/then (fn [_] (let [em (or (optical-offset-em family weight style text) 0)] @@ -139,36 +153,67 @@ (defn- use-optical-offset "Lazily resolve the optical-centering offset (in `em`) for sample text in a given font, measuring once per font/sample and caching it. Falls back to 0 - when the font isn't available or the metrics can't be measured." + when the font isn't available or the metrics can't be measured. + + Returns `[offset ready?]`. `ready?` is true immediately when a value is + already cached, and false only while the very first measurement of a given + font/sample is still pending. Callers should keep the sample hidden until + `ready?`: the offset (and so the sample's rendered position) jumps once + that first, async measurement resolves, and painting the glyphs before + then makes automated screenshot/position-based tests flaky — whether the + test runs before or after the jump is a timing race, not a deterministic + outcome." [font-id family weight style text] - (let [offset* (mf/use-state 0)] + (let [key (optical-offset-key family weight style text) + offset* (mf/use-state #(get @optical-offset-cache key 0)) + ready?* (mf/use-state #(contains? @optical-offset-cache key))] (mf/use-effect (mf/deps font-id family weight style text) (fn [] - (let [cancelled? (volatile! false) - key (optical-offset-key family weight style text)] + (let [cancelled? (volatile! false)] (if (contains? @optical-offset-cache key) - (reset! offset* (get @optical-offset-cache key)) + (do + (reset! offset* (get @optical-offset-cache key)) + (reset! ready?* true)) (let [task (tm/schedule-on-idle (fn [] (-> (load-optical-offset font-id family weight style text) (p/then (fn [em] (when-not @cancelled? - (reset! offset* em)))))))] + (reset! offset* em) + (reset! ready?* true)))))))] (fn [] (vreset! cancelled? true) (tm/dispose! task))))) nil)) - (deref offset*))) + [(deref offset*) (deref ready?*)])) (defn- sample-container-style "Inline style that applies the typography font to the (clipped, fixed-height) sample container. Must be a real JS object (`#js`), not a ClojureScript map: the `:style` value here is a runtime expression, not a literal recognized by - the hiccup macro, so it reaches React unconverted." - [typography] - #js {:fontFamily (:font-family typography) + the hiccup macro, so it reaches React unconverted. + + Falls back to `font-data` (the live fontsdb entry for the typography's + `:font-id`) when the typography's own `:font-family` is blank: a font that + was unloaded when a typography's font/variant was last changed can leave + that field nil on the record (the same failure mode `remove-nil-style-attrs` + repairs for shape text spans), and the sample would otherwise render in + whatever fallback font the browser picks instead of the intended one. + + The family name is quoted, matching `font-item-preview*` below: setting + `style.fontFamily` to a raw, unquoted string parses it as CSS's + `` grammar, i.e. whitespace-separated ``s. A + family like \"Micro 5\" then tokenizes as the ident `Micro` followed by + the *number* `5` — not a valid ident — so the whole property is invalid + CSS and the browser silently drops it. A quoted `` sidesteps that + entirely, since it isn't tokenized as identifiers at all." + [typography font-data] + #js {:fontFamily (let [family (:font-family typography) + family (if (str/blank? family) (:family font-data) family)] + (when-not (str/blank? family) + (dm/str "\"" family "\""))) :fontWeight (:font-weight typography) :fontStyle (:font-style typography)}) @@ -176,10 +221,14 @@ "Inline style that optically centers the sample glyphs. Must be applied to the text node itself, not to the clipped container: a transform on an `overflow: hidden` element moves its own clip region along with it, so it - would shift the whole box relative to the row instead of the glyphs inside it." - [em] - (when-not (zero? em) - #js {:transform (dm/str "translateY(" em "em)")})) + would shift the whole box relative to the row instead of the glyphs inside it. + + Hidden until `ready?` (see `use-optical-offset`), so the glyphs only ever + appear already in their final, correctly centered position instead of + visibly jumping there after the first paint." + [em ready?] + #js {:transform (when-not (zero? em) (dm/str "translateY(" em "em)")) + :visibility (when-not ready? "hidden")}) ;; --- FONT SELECTOR -------------------------------------------------------- @@ -210,11 +259,17 @@ ;; the row, so shift it by the measured offset once the font is known. ;; The label renders at `body-medium` (400/normal), which is the weight ;; and style we measure against. - label-offset (use-optical-offset font-id - (:family font) - "400" - "normal" - (:name font))] + ;; The selector always shows the font's name text as-is, unlike the + ;; small "Ag" sample elsewhere in this file, so unlike there this + ;; doesn't need to hide anything until the offset is ready — a + ;; shifting label is a minor, acceptable visual nicety here, not + ;; the row's only content. + [label-offset _label-ready?] + (use-optical-offset font-id + (:family font) + "400" + "normal" + (:name font))] (if in-sprite? ;; `fill: currentColor` (scss) makes the sprite glyph follow the row color. [:svg {:class (stl/css :font-item-preview) @@ -702,11 +757,12 @@ font-data (fonts/get-font-data (:font-id typography)) typography-id (:id typography) show-actions? (and is-asset? is-editable) - offset (use-optical-offset (:font-id typography) - (:font-family typography) - (:font-weight typography) - (:font-style typography) - "Ag") + [offset offset-ready?] + (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-delete (mf/use-fn @@ -741,8 +797,8 @@ [:* [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:input @@ -777,8 +833,8 @@ [:div {:class (stl/css :typography-info-wrapper)} [:div {:class (stl/css :typography-name-wrapper)} [:div {:class (stl/css :typography-sample) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :typography-name) @@ -826,11 +882,12 @@ open? (deref open*) font-data (fonts/get-font-data (:font-id typography)) name-only? (= (:name typography) (:name font-data)) - offset (use-optical-offset (:font-id typography) - (:font-family typography) - (:font-weight typography) - (:font-style typography) - "Ag") + [offset offset-ready?] + (use-optical-offset (:font-id typography) + (:font-family typography) + (:font-weight typography) + (:font-style typography) + "Ag") on-name-blur (mf/use-fn @@ -865,6 +922,13 @@ (when ^boolean esc? (dom/blur! input-node)))))] + ;; use-optical-offset only triggers a font load as a side effect of an + ;; uncached offset measurement, so on a cache hit (e.g. a `0` offset + ;; cached from before the font was ever loaded) the font itself never + ;; gets fetched and the sample silently renders in the fallback font. + ;; Load it unconditionally too, same as the advanced-options view does. + (fonts/ensure-loaded! (:font-id typography)) + (mf/with-effect [is-editing] (when is-editing (reset! open* is-editing))) @@ -888,8 +952,8 @@ [:div {:class (stl/css :font-name-wrapper)} [:div {:class (stl/css :typography-sample-input) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:input @@ -907,8 +971,8 @@ :on-context-menu on-context-menu} [:div {:class (stl/css :typography-sample) - :style (sample-container-style typography)} - [:span {:style (sample-text-style offset)} + :style (sample-container-style typography font-data)} + [:span {:style (sample-text-style offset offset-ready?)} (tr "workspace.assets.typography.sample")]] [:div {:class (stl/css :name-block) From 77bf3ea4192be0302864cc5d1c3d32ee14933967 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mar=C3=ADa=20Valderrama?= Date: Mon, 7 Sep 2026 11:56:25 +0200 Subject: [PATCH 02/16] :bug: Fix sso expiration time (#11528) --- backend/src/app/auth/oidc.clj | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index 9fb3e75bd1..9926d9b840 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -1037,7 +1037,7 @@ provider (prepare-organization-sso-provider cfg sso) _info (get-info cfg provider state code) session (session/get-session request) - exp (ct/in-future {:minutes 15})] + exp (ct/in-future {:hours 4})] (when (and session organization-id) (let [props (-> (or (:props session) {}) (update :sso assoc organization-id exp))] From acc078064bcdd414cd5bf65e2deaccad3c3dd559 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Mon, 7 Sep 2026 13:19:32 +0200 Subject: [PATCH 03/16] :bug: Do not register developer tools in multi-user MCP mode (#11310) Prevent developer tools from being exposed when the MCP server runs in multi-user mode. Keep them available for local devenv usage and document the mode restriction. Add regression coverage for the registration policy. Closes #11291 AI-assisted-by: gpt-5.6-luna Co-authored-by: niwinz <843689+niwinz@users.noreply.github.com> --- mcp/README.md | 2 +- mcp/packages/server/src/PenpotMcpServer.test.ts | 14 +++++++++++++- mcp/packages/server/src/PenpotMcpServer.ts | 9 ++++++++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/mcp/README.md b/mcp/README.md index 1b8dc3ea29..6842adce7e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -267,7 +267,7 @@ The Penpot MCP server can be configured using environment variables. | `PENPOT_MCP_REPL_PORT` | Port for the REPL server (development/debugging) | `4403` | | `PENPOT_MCP_REPL_ENABLE` | Explicitly enable/disable the REPL server. Set to `true` to enable. When unset, defaults to the value of `PENPOT_MCP_DEVENV`. | (unset) | | `PENPOT_MCP_REMOTE_MODE` | Enable remote mode (disables file system access). Set to `true` to enable. | `false` | -| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools. Set to `true` to enable. | `false` | +| `PENPOT_MCP_DEVENV` | Enable Penpot development environment tools in local single-user mode. Set to `true` to enable. | `false` | | `PENPOT_MCP_TOOL_TIMEOUT_S` | Timeout, in seconds, for tool calls dispatched to the Penpot plugin | `120` | | `PENPOT_MCP_EXPORT_SHAPE_MAX_PARALLEL_REQUESTS` | Maximum number of parallel export shape requests (multi-user mode only). | `0` (no limit) | | `PENPOT_MCP_REDIS_URI` | Redis connection URI (e.g. `redis://host:6379`) enabling multi-instance horizontal scaling via Redis pub/sub task routing (multi-user mode only). When unset, the server runs in single-instance mode, requiring the plugin and MCP client to connect to the same instance. | (unset) | diff --git a/mcp/packages/server/src/PenpotMcpServer.test.ts b/mcp/packages/server/src/PenpotMcpServer.test.ts index 5c04e50400..68665359f5 100644 --- a/mcp/packages/server/src/PenpotMcpServer.test.ts +++ b/mcp/packages/server/src/PenpotMcpServer.test.ts @@ -1,6 +1,18 @@ import assert from "node:assert/strict"; import test from "node:test"; -import { PenpotMcpServer } from "./PenpotMcpServer"; +import { PenpotMcpServer, shouldRegisterDeveloperTools } from "./PenpotMcpServer"; + +test("registers developer tools in local devenv mode", () => { + assert.equal(shouldRegisterDeveloperTools(true, false), true); +}); + +test("does not register developer tools in multi-user devenv mode", () => { + assert.equal(shouldRegisterDeveloperTools(true, true), false); +}); + +test("does not register developer tools when devenv mode is disabled", () => { + assert.equal(shouldRegisterDeveloperTools(false, false), false); +}); // ── Pure function tests ──────────────────────────────────────── diff --git a/mcp/packages/server/src/PenpotMcpServer.ts b/mcp/packages/server/src/PenpotMcpServer.ts index 09849c9316..c620aca3fc 100644 --- a/mcp/packages/server/src/PenpotMcpServer.ts +++ b/mcp/packages/server/src/PenpotMcpServer.ts @@ -50,6 +50,13 @@ class ToolInfo { ) {} } +/** + * Indicates whether developer tools may be registered for the current server mode. + */ +export function shouldRegisterDeveloperTools(isDevEnv: boolean, isMultiUserMode: boolean): boolean { + return isDevEnv && !isMultiUserMode; +} + export class PenpotMcpServer { /** * Timeout, in minutes, for idle sessions (Streamable HTTP and SSE) before they are automatically closed and removed. @@ -259,7 +266,7 @@ export class PenpotMcpServer { if (this.isFileSystemAccessEnabled()) { toolInstances.push(new ImportImageTool(this)); } - if (this.isDevEnv()) { + if (shouldRegisterDeveloperTools(this.isDevEnv(), this.isMultiUserMode())) { const nreplClient = new NreplClient(); toolInstances.push(new CljsReplTool(this, nreplClient)); toolInstances.push(new ImportPenpotFileTool(this, nreplClient)); From 7b135b80b23c849cda9a635ba521c1bb0a2fee93 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Mon, 7 Sep 2026 15:51:27 +0200 Subject: [PATCH 04/16] :bug: Fix background blur clipping (#11532) --- .../get-file-background-blur-clip.json | 208 ++++++++++++++++++ .../ui/render-wasm-specs/shapes.spec.js | 19 +- ...clipped-by-a-board-with-clip-content-1.png | Bin 0 -> 50627 bytes render-wasm/src/render.rs | 17 +- 4 files changed, 240 insertions(+), 4 deletions(-) create mode 100644 frontend/playwright/data/render-wasm/get-file-background-blur-clip.json create mode 100644 frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png diff --git a/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json b/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json new file mode 100644 index 0000000000..dde4a18d20 --- /dev/null +++ b/frontend/playwright/data/render-wasm/get-file-background-blur-clip.json @@ -0,0 +1,208 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "variants/v1", + "layout/grid", + "styles/v2", + "fdata/objects-map", + "text-editor/v2", + "render-wasm/v1", + "text-editor-wasm/v1", + "components/v2", + "fdata/shape-data-type" + ] + }, + "~:team-id": "~u8b485740-3f39-8080-8008-400e7784f55a", + "~: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": "New File 3", + "~:revn": 1, + "~:modified-at": "~m1788780032767", + "~:vern": 0, + "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674", + "~: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", + "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", + "0015-clean-shadow-color", + "0016-copy-fills-from-position-data-to-text-node", + "0017-fix-layout-flex-dir", + "0018-remove-unneeded-objects-from-components", + "0019-fix-missing-swap-slots", + "0020-sync-component-id-with-near-main", + "0021-fix-shape-svg-attrs", + "0022-normalize-component-root-and-resync", + "0023-repair-token-themes-with-inexistent-sets", + "0024b-fix-stroke-cap-placement", + "0025-repair-empty-text-content", + "0026-fix-svg-raw-shapes-uuids" + ] + }, + "~:version": 67, + "~:project-id": "~u8b485740-3f39-8080-8008-400e7786d1d0", + "~:created-at": "~m1788779992563", + "~:backend": "db", + "~:data": { + "~:pages": [ + "~u77d38721-22c1-81f4-8008-9a2a3e7ce675" + ], + "~:pages-index": { + "~u77d38721-22c1-81f4-8008-9a2a3e7ce675": { + "~: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\",[\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a57981d1d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-15\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",320.0000011920929,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",300.0000008940697,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",300.0000008940697,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",300.0000008940697,\"~:y1\",0,\"~:x2\",320.0000011920929,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58059cfd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-35\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",720.0000023841858,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",700.0000020861626,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",700.0000020861626,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",700.0000020861626,\"~:y1\",0,\"~:x2\",720.0000023841858,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a576c177d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-9\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",200.00000083446503,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",180.0000005364418,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",180.0000005364418,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",180.0000005364418,\"~:y1\",0,\"~:x2\",200.00000083446503,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57d786dd": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-26\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",540.000001847744,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",520.0000015497208,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",520.0000015497208,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",520.0000015497208,\"~:y1\",0,\"~:x2\",540.000001847744,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57b171fc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-19\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",400.0000014305115,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",380.00000113248825,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",380.00000113248825,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",380.00000113248825,\"~:y1\",0,\"~:x2\",400.0000014305115,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a578122dc": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-12\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",260.00000101327896,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",240.00000071525574,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",240.00000071525574,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",240.00000071525574,\"~:y1\",0,\"~:x2\",260.00000101327896,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58193cbf": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-39\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",800.0000026226044,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",780.0000023245811,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",780.0000023245811,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",780.0000023245811,\"~:y1\",0,\"~:x2\",800.0000026226044,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5814337e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-38\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",780.0000025629997,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",760.0000022649765,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",760.0000022649765,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",760.0000022649765,\"~:y1\",0,\"~:x2\",780.0000025629997,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57e7221e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-29\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",600.0000020265579,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",580.0000017285347,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",580.0000017285347,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",580.0000017285347,\"~:y1\",0,\"~:x2\",600.0000020265579,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a580f6e3e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-37\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",760.0000025033951,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",740.0000022053719,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",740.0000022053719,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",740.0000022053719,\"~:y1\",0,\"~:x2\",760.0000025033951,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a577afc79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-11\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",240.00000095367432,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",220.0000006556511,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",220.0000006556511,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",220.0000006556511,\"~:y1\",0,\"~:x2\",240.00000095367432,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57ec0a79": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-30\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",620.0000020861626,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",600.0000017881393,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",600.0000017881393,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",600.0000017881393,\"~:y1\",0,\"~:x2\",620.0000020861626,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57f5d458": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-32\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",660.0000022053719,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",640.0000019073486,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",640.0000019073486,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",640.0000019073486,\"~:y1\",0,\"~:x2\",660.0000022053719,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5833c5bb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-44\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",900.0000029206276,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",880.0000026226044,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",880.0000026226044,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",880.0000026226044,\"~:y1\",0,\"~:x2\",900.0000029206276,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5772831b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-10\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",220.00000089406967,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",200.00000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",200.00000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",200.00000059604645,\"~:y1\",0,\"~:x2\",220.00000089406967,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5849a83a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-48\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",980.0000031590462,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",960.000002861023,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",960.000002861023,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",960.000002861023,\"~:y1\",0,\"~:x2\",980.0000031590462,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57c1477a": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-22\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",460.0000016093254,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",440.0000013113022,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",440.0000013113022,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",440.0000013113022,\"~:y1\",0,\"~:x2\",460.0000016093254,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a581f6595": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-40\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",820.000002682209,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",800.0000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",800.0000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",800.0000023841858,\"~:y1\",0,\"~:x2\",820.000002682209,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5722f855": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-0\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",20.000000298023224,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",20.000000298023224,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5853f075": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-50\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1020.0000032782555,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1000.0000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1000.0000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1000.0000029802322,\"~:y1\",0,\"~:x2\",1020.0000032782555,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58713434": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5878a273\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",259.99999046325684,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a58713434\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",259.99999046325684,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a579e7ad4": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-16\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",340.00000125169754,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",320.0000009536743,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",320.0000009536743,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",320.0000009536743,\"~:y1\",0,\"~:x2\",340.00000125169754,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a586a1217": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-on-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",219.99999332427979,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~: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\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a586a1217\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",39.99999809265137,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",39.99999809265137,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",39.99999809265137,\"~:y1\",40.000003814697266,\"~:x2\",219.99999332427979,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a58713434\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a588ce9f6": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58944011\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",179.99999523162842,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^=\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^=\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:strokes\",[],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^:\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a587ff716": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5885fde3\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"blurred-child\",\"~:width\",259.99999046325684,\"^3\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",0]],[\"^=\",[\"^ \",\"~:x\",519.9999656677246,\"~:y\",320.0000047683716]],[\"^=\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",320.0000047683716]]],\"~:r2\",0,\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:r3\",0,\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"~:r1\",0,\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a587ff716\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:strokes\",[],\"~:x\",259.9999752044678,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",259.9999752044678,\"~:y\",0,\"^:\",259.99999046325684,\"~:height\",320.0000047683716,\"~:x1\",259.9999752044678,\"~:y1\",0,\"~:x2\",519.9999656677246,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.1]],\"~:flip-x\",null,\"^N\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5894fa56": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-on-text-child\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",999.9999561309814,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~: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\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",819.999960899353,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",819.999960899353,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",819.999960899353,\"~:y1\",40.000003814697266,\"~:x2\",999.9999561309814,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a589b7051\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a589b7051": "[\"~#shape\",[\"^ \",\"~:y\",60,\"~:background-blur\",[\"^ \",\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58a8f6a4\",\"~:type\",\"^1\",\"~:value\",20,\"~:hidden\",false],\"~: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\",[\"^ \",\"^3\",\"root\",\"~:children\",[[\"^ \",\"^3\",\"paragraph-set\",\"^<\",[[\"^ \",\"~:line-height\",\"1.2\",\"~:font-style\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"~:typography-ref-id\",null,\"~:text-transform\",\"none\",\"~:text-align\",\"left\",\"~:font-id\",\"sourcesanspro\",\"~:font-size\",\"110\",\"~:font-weight\",\"400\",\"~:typography-ref-file\",null,\"~:text-direction\",\"ltr\",\"~:font-variant-id\",\"regular\",\"~:text-decoration\",\"none\",\"~:letter-spacing\",\"0\",\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffffff\",\"~:fill-opacity\",0.25]],\"~:font-family\",\"sourcesanspro\",\"~:text\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"],[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^<\",[[\"^ \",\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\",\"^N\",\"Aa\"]],\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^3\",\"paragraph\",\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0\",\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^M\",\"sourcesanspro\"]]]]],\"~:name\",\"blurred-text-child\",\"~:width\",117,\"^3\",\"^N\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",790,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",60]],[\"^R\",[\"^ \",\"~:x\",907,\"~:y\",324]],[\"^R\",[\"^ \",\"~:x\",790,\"~:y\",324]]],\"~:transform-inverse\",[\"^7\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:constraints-v\",\"~:top\",\"~:constraints-h\",\"~:left\",\"^2\",\"~u099a17ba-4c81-804d-8008-9a2a589b7051\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:position-data\",[[\"^ \",\"~:y\",197.22000122070312,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"~:direction\",\"ltr\",\"^M\",\"sourcesanspro\",\"~:height\",142.44000244140625,\"^N\",\"Aa\"],[\"^ \",\"~:y\",329.2200012207031,\"^=\",\"1.2\",\"^>\",\"normal\",\"^?\",null,\"^@\",\"none\",\"^A\",\"left\",\"^B\",\"sourcesanspro\",\"^C\",\"110px\",\"^D\",\"400\",\"^E\",null,\"^F\",\"ltr\",\"^P\",116.1199951171875,\"^G\",\"regular\",\"^H\",\"none\",\"^I\",\"0px\",\"~:x\",790,\"^J\",[[\"^ \",\"^K\",\"#ffffff\",\"^L\",0.25]],\"^Z\",\"ltr\",\"^M\",\"sourcesanspro\",\"^[\",142.44000244140625,\"^N\",\"Aa\"]],\"~:frame-id\",\"~u099a17ba-4c81-804d-8008-9a2a5894fa56\",\"~:x\",790,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",790,\"~:y\",60,\"^P\",117,\"^[\",264,\"~:x1\",790,\"~:y1\",60,\"~:x2\",907,\"~:y2\",324]],\"~:flip-x\",null,\"^[\",264,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a58248d30": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-41\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",840.0000027418137,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",820.0000024437904,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",820.0000024437904,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",820.0000024437904,\"~:y1\",0,\"~:x2\",840.0000027418137,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5800e610": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-34\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",700.0000023245811,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",680.0000020265579,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",680.0000020265579,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",680.0000020265579,\"~:y1\",0,\"~:x2\",700.0000023245811,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a585a7293": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-51\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1040.00000333786,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1020.0000030398369,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1020.0000030398369,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1020.0000030398369,\"~:y1\",0,\"~:x2\",1040.00000333786,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a573259d2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-1\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",40.00000035762787,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",20.000000059604645,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",20.000000059604645,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",20.000000059604645,\"~:y1\",0,\"~:x2\",40.00000035762787,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57b6beb2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-20\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",420.0000014901161,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",400.0000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",400.0000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",400.0000011920929,\"~:y1\",0,\"~:x2\",420.0000014901161,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5886910d": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-on-child-exact\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",739.9999685287476,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",false,\"~: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\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5886910d\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",559.9999732971191,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",559.9999732971191,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",559.9999732971191,\"~:y1\",40.000003814697266,\"~:x2\",739.9999685287476,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a588ce9f6\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a584428ad": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-47\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",960.0000030994415,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",940.0000028014183,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",940.0000028014183,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",940.0000028014183,\"~:y1\",0,\"~:x2\",960.0000030994415,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5749400d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-4\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",100.0000005364418,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",80.00000023841858,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",80.00000023841858,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",80.00000023841858,\"~:y1\",0,\"~:x2\",100.0000005364418,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a578dcf0d": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-14\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",300.00000113248825,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",280.000000834465,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",280.000000834465,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",280.000000834465,\"~:y1\",0,\"~:x2\",300.00000113248825,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5864694c": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:index\",53,\"~:name\",\"backdrop\",\"~:width\",1060.0000033974648,\"~:type\",\"~:group\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",0,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^:\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^:\",[\"^ \",\"~:x\",0,\"~:y\",320.0000047683716]]],\"~:proportion-lock\",false,\"~:transform-inverse\",[\"^2\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",0,\"~:proportion\",1,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",0,\"~:y\",0,\"^6\",1060.0000033974648,\"~:height\",320.0000047683716,\"~:x1\",0,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[],\"~:flip-x\",null,\"^D\",320.0000047683716,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a5722f855\",\"~u099a17ba-4c81-804d-8008-9a2a573259d2\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~u099a17ba-4c81-804d-8008-9a2a5749400d\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~u099a17ba-4c81-804d-8008-9a2a576c177d\",\"~u099a17ba-4c81-804d-8008-9a2a5772831b\",\"~u099a17ba-4c81-804d-8008-9a2a577afc79\",\"~u099a17ba-4c81-804d-8008-9a2a578122dc\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~u099a17ba-4c81-804d-8008-9a2a578dcf0d\",\"~u099a17ba-4c81-804d-8008-9a2a57981d1d\",\"~u099a17ba-4c81-804d-8008-9a2a579e7ad4\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~u099a17ba-4c81-804d-8008-9a2a57b171fc\",\"~u099a17ba-4c81-804d-8008-9a2a57b6beb2\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~u099a17ba-4c81-804d-8008-9a2a57c1477a\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~u099a17ba-4c81-804d-8008-9a2a57d786dd\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~u099a17ba-4c81-804d-8008-9a2a57e7221e\",\"~u099a17ba-4c81-804d-8008-9a2a57ec0a79\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~u099a17ba-4c81-804d-8008-9a2a57f5d458\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~u099a17ba-4c81-804d-8008-9a2a5800e610\",\"~u099a17ba-4c81-804d-8008-9a2a58059cfd\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~u099a17ba-4c81-804d-8008-9a2a580f6e3e\",\"~u099a17ba-4c81-804d-8008-9a2a5814337e\",\"~u099a17ba-4c81-804d-8008-9a2a58193cbf\",\"~u099a17ba-4c81-804d-8008-9a2a581f6595\",\"~u099a17ba-4c81-804d-8008-9a2a58248d30\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~u099a17ba-4c81-804d-8008-9a2a5833c5bb\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~u099a17ba-4c81-804d-8008-9a2a584428ad\",\"~u099a17ba-4c81-804d-8008-9a2a5849a83a\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~u099a17ba-4c81-804d-8008-9a2a5853f075\",\"~u099a17ba-4c81-804d-8008-9a2a585a7293\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a5750f0ef": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-5\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",120.00000059604645,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5750f0ef\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",100.00000029802322,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",100.00000029802322,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",100.00000029802322,\"~:y1\",0,\"~:x2\",120.00000059604645,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a573b052e": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-2\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",60.00000041723251,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a573b052e\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",40.00000011920929,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",40.00000011920929,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",40.00000011920929,\"~:y1\",0,\"~:x2\",60.00000041723251,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a582f24ee": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-43\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",880.000002861023,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a582f24ee\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",860.0000025629997,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",860.0000025629997,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",860.0000025629997,\"~:y1\",0,\"~:x2\",880.000002861023,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57cd8cae": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-24\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",500.0000017285347,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57cd8cae\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",480.0000014305115,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",480.0000014305115,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",480.0000014305115,\"~:y1\",0,\"~:x2\",500.0000017285347,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a5765bd49": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-8\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",180.00000077486038,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5765bd49\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",160.00000047683716,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",160.00000047683716,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",160.00000047683716,\"~:y1\",0,\"~:x2\",180.00000077486038,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a587927c9": "[\"~#shape\",[\"^ \",\"~:y\",40.000003814697266,\"~: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\",\"clip-off-child-larger\",\"~:width\",179.99999523162842,\"~:type\",\"~:frame\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",40.000003814697266]],[\"^:\",[\"^ \",\"~:x\",479.9999809265137,\"~:y\",280.00001335144043]],[\"^:\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",280.00001335144043]]],\"~:r2\",24,\"~:show-content\",true,\"~: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\",24,\"~:r1\",24,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a587927c9\",\"~:parent-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[[\"^ \",\"~:stroke-color\",\"#ff00ff\",\"~:stroke-opacity\",1,\"~:stroke-alignment\",\"~:center\",\"~:stroke-width\",2]],\"~:x\",299.99998569488525,\"~:proportion\",1,\"~:r4\",24,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",299.99998569488525,\"~:y\",40.000003814697266,\"^6\",179.99999523162842,\"~:height\",240.00000953674316,\"~:x1\",299.99998569488525,\"~:y1\",40.000003814697266,\"~:x2\",479.9999809265137,\"~:y2\",280.00001335144043]],\"~:fills\",[],\"~:flip-x\",null,\"^N\",240.00000953674316,\"~:flip-y\",null,\"~:shapes\",[\"~u099a17ba-4c81-804d-8008-9a2a587ff716\"]]]", + "~u099a17ba-4c81-804d-8008-9a2a5829b348": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-42\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",860.0000028014183,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a5829b348\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",840.0000025033951,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",840.0000025033951,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",840.0000025033951,\"~:y1\",0,\"~:x2\",860.0000028014183,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a583a7c6b": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-45\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",920.0000029802322,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583a7c6b\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",900.000002682209,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",900.000002682209,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",900.000002682209,\"~:y1\",0,\"~:x2\",920.0000029802322,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a580a33cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-36\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",740.0000024437904,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a580a33cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",720.0000021457672,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",720.0000021457672,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",720.0000021457672,\"~:y1\",0,\"~:x2\",740.0000024437904,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a574272cb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-3\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",80.00000047683716,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a574272cb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",60.000000178813934,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",60.000000178813934,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",60.000000178813934,\"~:y1\",0,\"~:x2\",80.00000047683716,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57bc32eb": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-21\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",440.00000154972076,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57bc32eb\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",420.00000125169754,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",420.00000125169754,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",420.00000125169754,\"~:y1\",0,\"~:x2\",440.00000154972076,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a575f6f84": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-7\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",160.00000071525574,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a575f6f84\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",140.0000004172325,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",140.0000004172325,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",140.0000004172325,\"~:y1\",0,\"~:x2\",160.00000071525574,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57dcb9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-27\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",560.0000019073486,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57dcb9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",540.0000016093254,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",540.0000016093254,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",540.0000016093254,\"~:y1\",0,\"~:x2\",560.0000019073486,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57f0d9a7": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-31\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",640.0000021457672,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57f0d9a7\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",620.000001847744,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",620.000001847744,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",620.000001847744,\"~:y1\",0,\"~:x2\",640.0000021457672,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57e26c66": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-28\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",580.0000019669533,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57e26c66\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",560.00000166893,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",560.00000166893,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",560.00000166893,\"~:y1\",0,\"~:x2\",580.0000019669533,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57a5a180": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-17\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",360.0000013113022,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57a5a180\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",340.00000101327896,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",340.00000101327896,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",340.00000101327896,\"~:y1\",0,\"~:x2\",360.0000013113022,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57d2c500": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-25\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",520.0000017881393,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57d2c500\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",500.0000014901161,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",500.0000014901161,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",500.0000014901161,\"~:y1\",0,\"~:x2\",520.0000017881393,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a583f7ca0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-46\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",940.0000030398369,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a583f7ca0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",920.0000027418137,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",920.0000027418137,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",920.0000027418137,\"~:y1\",0,\"~:x2\",940.0000030398369,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a578826e0": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-13\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",280.0000010728836,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a578826e0\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",260.0000007748604,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",260.0000007748604,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",260.0000007748604,\"~:y1\",0,\"~:x2\",280.0000010728836,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57abfe20": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-18\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",380.00000137090683,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57abfe20\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",360.0000010728836,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",360.0000010728836,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",360.0000010728836,\"~:y1\",0,\"~:x2\",380.00000137090683,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a584e9da3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-49\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1000.0000032186508,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a584e9da3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",980.0000029206276,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",980.0000029206276,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",980.0000029206276,\"~:y1\",0,\"~:x2\",1000.0000032186508,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a585f48c3": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-52\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",1060.0000033974648,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a585f48c3\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",1040.0000030994415,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",1040.0000030994415,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",1040.0000030994415,\"~:y1\",0,\"~:x2\",1060.0000033974648,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57faee43": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-33\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",680.0000022649765,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57faee43\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",660.0000019669533,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",660.0000019669533,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",660.0000019669533,\"~:y1\",0,\"~:x2\",680.0000022649765,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57590dc2": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-6\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",140.0000006556511,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57590dc2\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",120.00000035762787,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",120.00000035762787,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",120.00000035762787,\"~:y1\",0,\"~:x2\",140.0000006556511,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#111111\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]", + "~u099a17ba-4c81-804d-8008-9a2a57c81962": "[\"~#shape\",[\"^ \",\"~:y\",0,\"~:transform\",[\"~#matrix\",[\"^ \",\"~:a\",1.0,\"~:b\",0.0,\"~:c\",0.0,\"~:d\",1.0,\"~:e\",0.0,\"~:f\",0.0]],\"~:rotation\",0,\"~:name\",\"stripe-23\",\"~:width\",20.000000298023224,\"~:type\",\"~:rect\",\"~:points\",[[\"~#point\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",0]],[\"^9\",[\"^ \",\"~:x\",480.00000166893005,\"~:y\",320.0000047683716]],[\"^9\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",320.0000047683716]]],\"~: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,\"~:r1\",0,\"~:id\",\"~u099a17ba-4c81-804d-8008-9a2a57c81962\",\"~:parent-id\",\"~u099a17ba-4c81-804d-8008-9a2a5864694c\",\"~:frame-id\",\"~u00000000-0000-0000-0000-000000000000\",\"~:strokes\",[],\"~:x\",460.00000137090683,\"~:proportion\",1,\"~:r4\",0,\"~:selrect\",[\"~#rect\",[\"^ \",\"~:x\",460.00000137090683,\"~:y\",0,\"^5\",20.000000298023224,\"~:height\",320.0000047683716,\"~:x1\",460.00000137090683,\"~:y1\",0,\"~:x2\",480.00000166893005,\"~:y2\",320.0000047683716]],\"~:fills\",[[\"^ \",\"~:fill-color\",\"#ffdd00\",\"~:fill-opacity\",1]],\"~:flip-x\",null,\"^G\",320.0000047683716,\"~:flip-y\",null]]" + } + }, + "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce675", + "~:name": "bg-blur-clip" + } + }, + "~:id": "~u77d38721-22c1-81f4-8008-9a2a3e7ce674", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js index 74b83d1940..1020949a55 100644 --- a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js +++ b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js @@ -602,4 +602,21 @@ test("Renders background blur under strokes on rects, paths and texts", async ({ await workspace.waitForFirstRenderWithoutUI(); await expect(workspace.canvas).toHaveScreenshot(); -}); \ No newline at end of file +}); + +test("Renders background blur clipped by a board with clip content", async ({ + page, +}) => { + const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("render-wasm/get-file-background-blur-clip.json"); + + await workspace.goToWorkspace({ + id: "77d38721-22c1-81f4-8008-9a2a3e7ce674", + pageId: "77d38721-22c1-81f4-8008-9a2a3e7ce675", + pageName: "bg-blur-clip", + }); + await workspace.waitForFirstRenderWithoutUI(); + + await expect(workspace.canvas).toHaveScreenshot(); +}); diff --git a/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png b/frontend/playwright/ui/render-wasm-specs/shapes.spec.js-snapshots/Renders-background-blur-clipped-by-a-board-with-clip-content-1.png new file mode 100644 index 0000000000000000000000000000000000000000..6f5057acfbf846135eb03439c4998c3518431658 GIT binary patch literal 50627 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, + target_surface: SurfaceId, + ) { if self.options.is_fast_mode() { return; } @@ -730,8 +735,14 @@ impl RenderState { matrix.post_translate(center); matrix.pre_translate(-center); + self.surfaces.canvas(target_surface).save(); + + if let Some(clips) = clip_bounds { + let antialias = shape.should_use_antialias(scale, self.options.antialias_threshold); + self.clip_target_surface_to_stack(clips, target_surface, scale, antialias); + } + let canvas = self.surfaces.canvas(target_surface); - canvas.save(); // Current/Export have no render context transform (identity canvas). // Apply scale + translate + shape transform so the clip maps @@ -3477,7 +3488,7 @@ impl RenderState { // Render background blur BEFORE save_layer so it modifies // the backdrop independently of the shape's opacity. if !node_render_state.is_root() && self.focus_mode.is_active() { - self.render_background_blur(element, target_surface); + self.render_background_blur(element, clip_bounds.as_ref(), target_surface); } self.render_shape_enter(element, mask, clip_bounds.as_ref(), target_surface); From ff63668c1ef61928878cded13bc1c2734c983aff Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Mon, 7 Sep 2026 15:59:35 +0200 Subject: [PATCH 05/16] :bug: Enforce SSRF checks and add timeouts to HTTP client (#11474) --- backend/src/app/http/client.clj | 11 ++- backend/src/app/main.clj | 2 +- backend/src/app/media/remote.clj | 6 +- backend/src/app/nitrate.clj | 3 +- .../test/backend_tests/http_client_test.clj | 30 ++++++++ .../test/backend_tests/media_remote_test.clj | 17 +++++ .../test/backend_tests/nitrate_ssrf_test.clj | 70 +++++++++++++++++++ 7 files changed, 130 insertions(+), 9 deletions(-) create mode 100644 backend/test/backend_tests/http_client_test.clj create mode 100644 backend/test/backend_tests/nitrate_ssrf_test.clj diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index bba77f9aa0..891e581f70 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -15,6 +15,7 @@ (:require [app.common.schema :as sm] [app.util.ssrf :as ssrf] + [app.worker :as-alias wrk] [cuerdas.core :as str] [integrant.core :as ig] [java-http-clj.core :as http]) @@ -23,6 +24,8 @@ java.net.URI)) (def default-max-redirects 5) +(def default-connect-timeout 30000) +(def default-request-timeout 30000) (defn client? [o] @@ -33,15 +36,17 @@ :pred client?}) (defmethod ig/init-key ::client - [_ _] - (http/build-client {:connect-timeout 30000 + [_ {:keys [::wrk/executor]}] + (http/build-client {:connect-timeout default-connect-timeout + :executor executor :follow-redirects :never})) (defn send! ([client req] (send! client req {})) ([client req {:keys [response-type] :or {response-type :string}}] (assert (client? client) "expected valid http client") - (http/send req {:client client :as response-type}))) + (http/send (merge {:timeout default-request-timeout} req) + {:client client :as response-type}))) (defn- resolve-client [params] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index d0ffd1cf58..cc627f3307 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -200,7 +200,7 @@ {::db/pool (ig/ref ::db/pool)} ::http.client/client - {} + {::wrk/executor (ig/ref ::wrk/executor)} ::session/manager {::db/pool (ig/ref ::db/pool)} diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj index 0b5a0a4a42..9e717b2fd7 100644 --- a/backend/src/app/media/remote.clj +++ b/backend/src/app/media/remote.clj @@ -75,10 +75,10 @@ {:method method :uri uri :body body - :headers headers} + :headers headers + :timeout timeout} {:response-type :input-stream - :skip-ssrf-check? true - :timeout timeout}) + :skip-ssrf-check? true}) status (:status resp)] (when (not (<= 200 status 299)) (let [body (:body resp)] diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index a189116458..5adafaeced 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -68,8 +68,7 @@ "x-profile-id" (str profile-id)} :uri uri :version :http1.1} - (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))) - {:skip-ssrf-check? true}))) + (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))))) (defn- with-retries [handler max-retries] diff --git a/backend/test/backend_tests/http_client_test.clj b/backend/test/backend_tests/http_client_test.clj new file mode 100644 index 0000000000..d67edcf182 --- /dev/null +++ b/backend/test/backend_tests/http_client_test.clj @@ -0,0 +1,30 @@ +;; 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 Sucursal en España SL + +(ns backend-tests.http-client-test + (:require + [app.http.client :as http] + [clojure.test :as t] + [java-http-clj.core :as jhttp] + [mockery.core :refer [with-mocks]])) + +(t/deftest send-injects-default-timeout-when-absent + (with-mocks [mock {:target 'java-http-clj.core/send + :return {:status 200 :body ""}}] + (let [client (jhttp/build-client {})] + (http/send! client {:method :get :uri "https://example.com/"}) + (let [[req _opts] (:call-args @mock)] + (t/is (= http/default-request-timeout (:timeout req))))))) + +(t/deftest send-preserves-caller-supplied-timeout + (with-mocks [mock {:target 'java-http-clj.core/send + :return {:status 200 :body ""}}] + (let [client (jhttp/build-client {})] + (http/send! client {:method :get + :uri "https://example.com/" + :timeout 5000}) + (let [[req _opts] (:call-args @mock)] + (t/is (= 5000 (:timeout req))))))) \ No newline at end of file diff --git a/backend/test/backend_tests/media_remote_test.clj b/backend/test/backend_tests/media_remote_test.clj index dfa8b16d05..21eb46bec8 100644 --- a/backend/test/backend_tests/media_remote_test.clj +++ b/backend/test/backend_tests/media_remote_test.clj @@ -8,6 +8,7 @@ (:require [app.common.exceptions :as ex] [app.config :as cf] + [app.http.client :as http] [app.media.remote :as media.remote] [app.setup :as-alias setup] [app.util.json :as json] @@ -500,6 +501,22 @@ :headers {}})] (t/is (= 200 (:status resp)))))))) +(t/deftest service-request-puts-configured-timeout-in-request + (t/testing "service-request puts media-processing-service-timeout on the http request" + (let [captured (atom nil)] + (with-redefs [cf/get (th/config-get-mock config-mock) + http/req (fn [_client request _opts] + (reset! captured request) + {:status 200 + :body (json-stream {:width 100 :height 100})})] + (media.remote/service-request + (mk-system) + {:method :post + :uri "http://localhost:6065/api/image/info" + :body nil + :headers {}}) + (t/is (= 5000 (:timeout @captured))))))) + ;; --------------------------------------------------------------------------- ;; Shared key ;; --------------------------------------------------------------------------- diff --git a/backend/test/backend_tests/nitrate_ssrf_test.clj b/backend/test/backend_tests/nitrate_ssrf_test.clj new file mode 100644 index 0000000000..e44f96356a --- /dev/null +++ b/backend/test/backend_tests/nitrate_ssrf_test.clj @@ -0,0 +1,70 @@ +;; 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 Sucursal en España SL + +(ns backend-tests.nitrate-ssrf-test + (:require + [app.config :as cf] + [app.http.client :as http] + [app.nitrate :as nitrate] + [app.setup :as-alias setup] + [clojure.string :as str] + [clojure.test :as t] + [integrant.core :as ig] + [java-http-clj.core :as jhttp])) + +(def ^:private private-admin-uri "http://127.0.0.1:9090") + +(defn- mk-cfg + "Minimal nitrate cfg with a real HttpClient and nitrate client methods." + [] + (let [http-client (jhttp/build-client {}) + base {::http/client http-client + ::setup/shared-keys {:admin-console "test-shared-key"}}] + (assoc base ::nitrate/client (ig/init-key ::nitrate/client base)))) + +(defn- with-admin-console-uri + "Run `f` with :admin-console enabled and the given admin-console URI / allowlist." + [admin-uri allowed-hosts f] + (let [original-get cf/get] + (with-redefs [cf/flags #{:admin-console} + cf/get (fn [key & args] + (case key + :admin-console-uri admin-uri + :ssrf-allowed-hosts allowed-hosts + (apply original-get key args)))] + (f)))) + +(t/deftest nitrate-blocks-private-admin-console-uri + (let [sent? (atom false)] + (with-admin-console-uri + private-admin-uri + #{} + (fn [] + (with-redefs [jhttp/send (fn [_req _opts] + (reset! sent? true) + {:status 200 :body "{\"licenses\":true}"})] + (try + (nitrate/call (mk-cfg) :connectivity {}) + (t/is false "should have raised :nitrate-unavailable") + (catch Exception e + (t/is (= :nitrate-unavailable (:type (ex-data e)))) + (t/is (false? @sent?) + "SSRF must stop the request before it reaches the network")))))))) + +(t/deftest nitrate-proceeds-when-admin-console-host-allowlisted + (let [captured (atom nil)] + (with-admin-console-uri + private-admin-uri + #{"127.0.0.1"} + (fn [] + (with-redefs [jhttp/send (fn [req _opts] + (reset! captured req) + {:status 200 + :body "{\"licenses\":true}"})] + (let [result (nitrate/call (mk-cfg) :connectivity {})] + (t/is (= {:licenses true} result)) + (t/is (some? @captured)) + (t/is (str/starts-with? (str (:uri @captured)) private-admin-uri)))))))) From 5f1e151e84caed4d20b227677f026f4d8462e7c9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 8 Sep 2026 09:23:20 +0200 Subject: [PATCH 06/16] :bug: Validate plugin UI URLs cannot target Penpot's own domain (#11273) * :bug: Validate plugin UI URLs cannot target Penpot's own domain The plugin UI iframe combines allow-scripts and allow-same-origin in its sandbox. While necessary for plugins to use their own cookies and storage, this creates a risk if a plugin's UI URL resolves to Penpot's own origin, potentially allowing the iframe to escape sandbox isolation. Add validateUIUrl() that checks the resolved URL against Penpot's origin (from penpotPublicURI or location.origin) and throws if they match. Called in openModal() after prepareUrl() resolves the URL. Closes #11271 AI-assisted-by: qwen3.7-plus * :lipstick: Fix prettier formatting in plugin-manager.spec.ts Apply prettier formatting to fix format:check failure. AI-assisted-by: qwen3.7-plus * :bug: Fix problem with penpot origin plugins --------- Co-authored-by: alonso.torres --- .../src/lib/plugin-manager.spec.ts | 40 ++++++ .../plugins-runtime/src/lib/plugin-manager.ts | 2 + .../src/lib/validate-url.spec.ts | 117 ++++++++++++++++++ .../plugins-runtime/src/lib/validate-url.ts | 44 +++++++ 4 files changed, 203 insertions(+) create mode 100644 plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts create mode 100644 plugins/libs/plugins-runtime/src/lib/validate-url.ts diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts index d53f4f5296..7b4d8ac9cb 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts @@ -3,6 +3,7 @@ import { createPluginManager } from './plugin-manager'; import { loadManifestCode, getValidUrl, prepareUrl } from './parse-manifest.js'; import { PluginModalElement } from './modal/plugin-modal.js'; import { openUIApi } from './api/openUI.api.js'; +import { validateUIUrl } from './validate-url.js'; import type { Context, Theme } from '@penpot/plugin-types'; import type { Manifest } from './models/manifest.model.js'; @@ -16,6 +17,10 @@ vi.mock('./api/openUI.api.js', () => ({ openUIApi: vi.fn(), })); +vi.mock('./validate-url.js', () => ({ + validateUIUrl: vi.fn(), +})); + describe('createPluginManager', () => { let mockContext: Context; let manifest: Manifest; @@ -294,4 +299,39 @@ describe('createPluginManager', () => { expect(mockContext.removeListener).toHaveBeenCalled(); expect(onCloseCallback).toHaveBeenCalled(); }); + + it('should validate the modal URL before opening', async () => { + const pluginManager = await createPluginManager( + mockContext, + manifest, + onCloseCallback, + onReloadModal, + ); + + pluginManager.openModal('Test Modal', '/test-url'); + + expect(validateUIUrl).toHaveBeenCalledWith( + 'https://example.com/plugin', + manifest.host, + ); + }); + + it('should throw when URL validation fails', async () => { + vi.mocked(validateUIUrl).mockImplementation(() => { + throw new Error("Plugin UI URL must not point to Penpot's own domain"); + }); + + const pluginManager = await createPluginManager( + mockContext, + manifest, + onCloseCallback, + onReloadModal, + ); + + expect(() => pluginManager.openModal('Test Modal', '/test-url')).toThrow( + "Plugin UI URL must not point to Penpot's own domain", + ); + + expect(openUIApi).not.toHaveBeenCalled(); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 8b811f55eb..28ed884a0e 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -7,6 +7,7 @@ import { openUIApi } from './api/openUI.api.js'; import { OpenUIOptions } from './models/open-ui-options.model.js'; import { RegisterListener } from './models/plugin.model.js'; import { openUISchema } from './models/open-ui-options.schema.js'; +import { validateUIUrl } from './validate-url.js'; export async function createPluginManager( context: Context, @@ -94,6 +95,7 @@ export async function createPluginManager( const openModal = (name: string, url: string, options?: OpenUIOptions) => { const theme = context.theme as Theme; const modalUrl = prepareUrl(manifest, url, { theme }); + validateUIUrl(modalUrl, manifest.host); if (modal?.getAttribute('iframe-src') === modalUrl) { return; diff --git a/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts b/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts new file mode 100644 index 0000000000..34466a69b6 --- /dev/null +++ b/plugins/libs/plugins-runtime/src/lib/validate-url.spec.ts @@ -0,0 +1,117 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + getPenpotOrigin, + isPenpotOrigin, + validateUIUrl, +} from './validate-url.js'; + +describe('validate-url', () => { + const originalLocation = globalThis.location; + const originalPenpotPublicURI = (globalThis as any).penpotPublicURI; + const externalHost = 'https://example.com'; + + beforeEach(() => { + delete (globalThis as any).penpotPublicURI; + }); + + afterEach(() => { + if (originalPenpotPublicURI !== undefined) { + (globalThis as any).penpotPublicURI = originalPenpotPublicURI; + } else { + delete (globalThis as any).penpotPublicURI; + } + }); + + describe('getPenpotOrigin', () => { + it('should return location.origin when penpotPublicURI is not set', () => { + expect(getPenpotOrigin()).toBe(originalLocation.origin); + }); + + it('should return origin from penpotPublicURI when set', () => { + (globalThis as any).penpotPublicURI = 'https://design.penpot.com/'; + expect(getPenpotOrigin()).toBe('https://design.penpot.com'); + }); + + it('should fall back to location.origin when penpotPublicURI is invalid', () => { + (globalThis as any).penpotPublicURI = 'not-a-valid-url'; + expect(getPenpotOrigin()).toBe(originalLocation.origin); + }); + }); + + describe('isPenpotOrigin', () => { + it('should be true for a URL on Penpot origin', () => { + expect( + isPenpotOrigin(`${originalLocation.origin}/plugin/manifest.json`), + ).toBe(true); + }); + + it('should be false for a URL on another origin', () => { + expect(isPenpotOrigin(`${externalHost}/manifest.json`)).toBe(false); + }); + + it('should be false for an unparseable URL', () => { + expect(isPenpotOrigin('not-a-valid-url')).toBe(false); + }); + }); + + describe('validateUIUrl', () => { + it('should throw when URL has same origin as location.origin', () => { + const penpotOrigin = originalLocation.origin; + expect(() => + validateUIUrl(`${penpotOrigin}/some/path`, externalHost), + ).toThrow("Plugin UI URL must not point to Penpot's own domain"); + }); + + it('should not throw when URL has different origin', () => { + expect(() => + validateUIUrl('https://example.com/plugin-ui', externalHost), + ).not.toThrow(); + }); + + it('should throw when URL matches penpotPublicURI origin', () => { + (globalThis as any).penpotPublicURI = 'https://design.penpot.com/'; + expect(() => + validateUIUrl('https://design.penpot.com/some/path', externalHost), + ).toThrow("Plugin UI URL must not point to Penpot's own domain"); + }); + + it('should not throw when URL has same hostname but different port', () => { + const url = new URL(originalLocation.origin); + const differentPort = `${url.protocol}//${url.hostname}:9999`; + expect(() => + validateUIUrl(`${differentPort}/path`, externalHost), + ).not.toThrow(); + }); + + it('should throw even when URL has different path on same origin', () => { + const penpotOrigin = originalLocation.origin; + expect(() => + validateUIUrl(`${penpotOrigin}/deeply/nested/path`, externalHost), + ).toThrow(); + }); + + it('should not throw when the manifest is served from Penpot origin', () => { + const penpotOrigin = originalLocation.origin; + expect(() => + validateUIUrl(`${penpotOrigin}/some/path`, `${penpotOrigin}/plugin`), + ).not.toThrow(); + }); + + it('should not throw when the manifest is served from penpotPublicURI origin', () => { + (globalThis as any).penpotPublicURI = 'https://design.penpot.com/'; + expect(() => + validateUIUrl( + 'https://design.penpot.com/some/path', + 'https://design.penpot.com/plugin', + ), + ).not.toThrow(); + }); + + it('should still throw when the manifest host is unparseable', () => { + const penpotOrigin = originalLocation.origin; + expect(() => validateUIUrl(`${penpotOrigin}/path`, '')).toThrow( + "Plugin UI URL must not point to Penpot's own domain", + ); + }); + }); +}); diff --git a/plugins/libs/plugins-runtime/src/lib/validate-url.ts b/plugins/libs/plugins-runtime/src/lib/validate-url.ts new file mode 100644 index 0000000000..6ff4a72273 --- /dev/null +++ b/plugins/libs/plugins-runtime/src/lib/validate-url.ts @@ -0,0 +1,44 @@ +export function getPenpotOrigin(): string { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const publicUri = (globalThis as any).penpotPublicURI; + if (publicUri) { + try { + return new URL(publicUri).origin; + } catch { + // fall through to location.origin + } + } + return globalThis.location.origin; +} + +/** + * Whether the given URL is served from Penpot's own origin. Unparseable URLs + * are considered external. + */ +export function isPenpotOrigin(url: string): boolean { + try { + return new URL(url).origin === getPenpotOrigin(); + } catch { + return false; + } +} + +/** + * Rejects UI URLs that resolve to Penpot's own origin, which would let the + * plugin iframe escape its sandbox isolation. + * + * Plugins whose manifest is itself served from Penpot's origin are part of the + * instance and are exempt from the check. + */ +export function validateUIUrl(url: string, manifestHost: string): void { + if (isPenpotOrigin(manifestHost)) { + return; + } + + const parsed = new URL(url); + if (parsed.origin === getPenpotOrigin()) { + throw new Error( + `Plugin UI URL must not point to Penpot's own domain: ${url}`, + ); + } +} From 937b3fc65f29b42e664e5da032b94bc77facd23c Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 8 Sep 2026 09:24:45 +0200 Subject: [PATCH 07/16] :bug: Add missing permission checks to plugin API (tokens, shapes, variants, flows, layouts, user identity) (#11139) * :bug: Add content:write permission checks to Design Tokens plugin API The Design Tokens API (tokens.cljs) had zero permission checks, allowing any plugin to create, modify, and delete tokens, sets, and themes regardless of granted permissions. Add r/check-permission checks to all 22 write operations across: - token-proxy: name, value, description, duplicate, remove, applyToken - token-set-proxy: name, active, toggleActive, addToken, duplicate, remove - token-theme-proxy: group, name, active, toggleActive, addSet, removeSet, duplicate, remove - tokens-catalog: addTheme, addSet Follows the established pattern from comments.cljs, file.cljs, page.cljs. Closes #11137 AI-assisted-by: qwen3.7-plus * :bug: Add permission checks to shape proxy interactions, detach, export, and variants The shape proxy (shape.cljs) had multiple operations missing permission checks, plus a cond ordering bug that bypassed the existing content:write check for text shapes in commit-fills!. Fix commit-fills! cond ordering: move permission check before the text-shape branch so text shapes are also protected. Add content:write permission checks to: - interaction-proxy: :trigger, :delay, :action setters, :remove method - shape-proxy: :addInteraction, :removeInteraction, :detach - shape-proxy: :applyToken, :switchVariant, :combineAsVariants Add content:read permission check to: - shape-proxy: :export (read/extraction operation) Follows the established pattern from :resize, :rotate, :blocked setters. Relates to #11137 AI-assisted-by: qwen3.7-plus * :bug: Add library:write permission checks to variant plugin API The library.cljs variant operations (variant-proxy and lib-component-proxy) had seven mutating operations that did not check the library:write permission, allowing any plugin to create, modify, and delete component variants regardless of granted permissions. Add r/check-permission checks to all 7 operations: - variant-proxy: addVariant, addProperty, removeProperty, renameProperty - lib-component-proxy: transformInVariant, addVariant, setVariantProperty Follows the established pattern from the :name and :path setters in the same file. Relates to #11137 AI-assisted-by: qwen3.7-plus * :bug: Add content:write permission checks to flow and flex layout plugin API Add permission checks to prototype flow and flex layout operations that were missing them, allowing plugins to modify flows and layout structure without explicit user permission. Changes: - page.cljs: Add content:write checks to flow-proxy (name, startingBoard setters, remove) and page-proxy (createFlow, removeFlow) - flex.cljs: Add content:write checks to flex-layout-proxy (remove, appendChild) Follows the established pattern from tokens.cljs, shape.cljs, and library.cljs. Relates to #11137 AI-assisted-by: qwen3.7-plus * :bug: Add user:read permission checks to plugin API Add permission checks to user identity accessors that were bypassing the consent model, allowing plugins to access user data regardless of whether the user granted user:read permission. Changes: - api.cljs: Add user:read checks to getCurrentUser and getActiveUsers - comments.cljs: Add user:read checks to comment-proxy and comment-thread-proxy owner/user getters - file.cljs: Add user:read check to file-version-proxy createdBy getter When user:read permission is not granted: - getCurrentUser() returns null - getActiveUsers() returns empty array - owner/user/createdBy getters return null Follows the established pattern from other permission checks in the plugin API. Relates to #11137 AI-assisted-by: qwen3.7-plus * :bug: Fix problem with token API --------- Co-authored-by: alonso.torres --- frontend/src/app/plugins/api.cljs | 16 +- frontend/src/app/plugins/comments.cljs | 15 +- frontend/src/app/plugins/file.cljs | 5 +- frontend/src/app/plugins/flex.cljs | 10 +- frontend/src/app/plugins/library.cljs | 58 +++- frontend/src/app/plugins/page.cljs | 19 +- frontend/src/app/plugins/shape.cljs | 60 +++- frontend/src/app/plugins/tokens.cljs | 320 ++++++++++++------ .../frontend_tests/plugins/flex_test.cljs | 40 +++ .../frontend_tests/plugins/library_test.cljs | 112 ++++++ .../frontend_tests/plugins/page_test.cljs | 87 +++++ .../plugins/shape_bugfixes_test.cljs | 204 +++++++++++ .../frontend_tests/plugins/tokens_test.cljs | 317 ++++++++++++++++- .../frontend_tests/plugins/user_test.cljs | 80 +++++ frontend/test/frontend_tests/runner.cljs | 4 + 15 files changed, 1199 insertions(+), 148 deletions(-) create mode 100644 frontend/test/frontend_tests/plugins/flex_test.cljs create mode 100644 frontend/test/frontend_tests/plugins/user_test.cljs diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 6582b76e62..6bf90aa8c8 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -47,6 +47,7 @@ [app.plugins.page :as page] [app.plugins.parser :as parser] [app.plugins.reflow :as wrfp] + [app.plugins.register :as r] [app.plugins.shape :as shape] [app.plugins.system-events :as se] [app.plugins.user :as user] @@ -242,15 +243,18 @@ :getCurrentUser (fn [] - (user/current-user-proxy plugin-id (:session-id @st/state))) + (when (r/check-permission plugin-id "user:read") + (user/current-user-proxy plugin-id (:session-id @st/state)))) :getActiveUsers (fn [] - (apply array - (->> (:workspace-presence @st/state) - (vals) - (remove #(= (:id %) (:session-id @st/state))) - (map #(user/active-user-proxy plugin-id (:id %)))))) + (if (r/check-permission plugin-id "user:read") + (apply array + (->> (:workspace-presence @st/state) + (vals) + (remove #(= (:id %) (:session-id @st/state))) + (map #(user/active-user-proxy plugin-id (:id %))))) + (array))) :uploadMediaUrl (fn [name url] diff --git a/frontend/src/app/plugins/comments.cljs b/frontend/src/app/plugins/comments.cljs index 71e8a0311b..b45b36b552 100644 --- a/frontend/src/app/plugins/comments.cljs +++ b/frontend/src/app/plugins/comments.cljs @@ -40,12 +40,14 @@ ;; FIXME: inconsistent with comment-thread: owner :user - {:get #(->> (dc/get-owner data) - (user/user-proxy plugin-id))} + {:get #(when (r/check-permission plugin-id "user:read") + (->> (dc/get-owner data) + (user/user-proxy plugin-id)))} :owner - {:get #(->> (dc/get-owner data) - (user/user-proxy plugin-id))} + {:get #(when (r/check-permission plugin-id "user:read") + (->> (dc/get-owner data) + (user/user-proxy plugin-id)))} :date {:get @@ -116,8 +118,9 @@ :board {:get #(shape/shape-proxy plugin-id file-id page-id (:frame-id data))} :owner - {:get #(->> (dc/get-owner data) - (user/user-proxy plugin-id))} + {:get #(when (r/check-permission plugin-id "user:read") + (->> (dc/get-owner data) + (user/user-proxy plugin-id)))} :position {:get diff --git a/frontend/src/app/plugins/file.cljs b/frontend/src/app/plugins/file.cljs index 12049611bd..10d6895d65 100644 --- a/frontend/src/app/plugins/file.cljs +++ b/frontend/src/app/plugins/file.cljs @@ -61,8 +61,9 @@ :createdBy {:get (fn [] - (when-let [user-data (get users (:profile-id @data))] - (user/user-proxy plugin-id user-data)))} + (when (r/check-permission plugin-id "user:read") + (when-let [user-data (get users (:profile-id @data))] + (user/user-proxy plugin-id user-data))))} :createdAt {:get #(:created-at @data)} diff --git a/frontend/src/app/plugins/flex.cljs b/frontend/src/app/plugins/flex.cljs index 0967edcbec..be3a3b74c7 100644 --- a/frontend/src/app/plugins/flex.cljs +++ b/frontend/src/app/plugins/flex.cljs @@ -325,7 +325,12 @@ :remove (fn [] - (st/emit! (dwsl/remove-layout #{id}))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwsl/remove-layout #{id})))) :appendChild (fn [child] @@ -350,6 +355,9 @@ (u/changes-component-copy-structure? objects shape child-shape) (u/not-valid plugin-id :appendChild "Cannot change the structure of a component copy") + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :appendChild "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwsh/relocate-shapes #{child-id} id index) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 5839ed57a4..388ba89b69 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -698,21 +698,37 @@ :addVariant (fn [] - (st/emit! - (se/event plugin-id "add-new-variant") - (dwv/add-new-variant id))) + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission") + + :else + (st/emit! + (se/event plugin-id "add-new-variant") + (dwv/add-new-variant id)))) :addProperty (fn [] - (st/emit! - (se/event plugin-id "add-new-property") - (dwv/add-new-property id {:property-value "Value 1"}))) + (cond + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :addProperty "Plugin doesn't have 'library:write' permission") + + :else + (st/emit! + (se/event plugin-id "add-new-property") + (dwv/add-new-property id {:property-value "Value 1"})))) :removeProperty (fn [pos] (let [nprops (->> (get-variant-components file-id id) first :variant-properties count)] - (if (or (not (nat-int? pos)) (>= pos nprops)) + (cond + (or (not (nat-int? pos)) (>= pos nprops)) (u/not-valid plugin-id :pos pos) + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :removeProperty "Plugin doesn't have 'library:write' permission") + + :else (st/emit! (se/event plugin-id "remove-property") (dwv/remove-property id pos))))) @@ -727,6 +743,9 @@ (not (string? name)) (u/not-valid plugin-id :name name) + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :renameProperty "Plugin doesn't have 'library:write' permission") + :else (st/emit! (dwv/update-property-name id pos name {:trigger "plugin:rename-property"}))))))) @@ -923,8 +942,15 @@ :transformInVariant (fn [] (let [component (u/locate-library-component file-id id)] - (when (and component - (not (ctk/is-variant? component))) + (cond + (or (nil? component) + (ctk/is-variant? component)) + nil + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission") + + :else (st/emit! (se/event plugin-id "transform-in-variant") (dwv/transform-in-variant (:main-instance-id component)))))) @@ -932,8 +958,15 @@ :addVariant (fn [] (let [component (u/locate-library-component file-id id)] - (when (and component - (ctk/is-variant? component)) + (cond + (or (nil? component) + (not (ctk/is-variant? component))) + nil + + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :addVariant "Plugin doesn't have 'library:write' permission") + + :else (st/emit! (se/event plugin-id "add-new-variant") (dwv/add-new-variant (:main-instance-id component)))))) @@ -948,6 +981,9 @@ (not (string? value)) (u/not-valid plugin-id :name value) + (not (r/check-permission plugin-id "library:write")) + (u/not-valid plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission") + :else (st/emit! (se/event plugin-id "variant-edit-property-value") diff --git a/frontend/src/app/plugins/page.cljs b/frontend/src/app/plugins/page.cljs index e668bc8756..ea55398dd6 100644 --- a/frontend/src/app/plugins/page.cljs +++ b/frontend/src/app/plugins/page.cljs @@ -64,6 +64,9 @@ (or (not (string? value)) (empty? value)) (u/not-valid plugin-id :name value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-flow page-id id #(assoc % :name value)))))} @@ -79,12 +82,20 @@ (not (shape/shape-proxy? value)) (u/not-valid plugin-id :startingBoard value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :startingBoard "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-flow page-id id #(assoc % :starting-frame (obj/get value "$id"))))))} :remove (fn [] - (st/emit! (dwi/remove-flow page-id id))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwi/remove-flow page-id id)))))) (defn page-proxy? [proxy] (obj/type-of? proxy "PageProxy")) @@ -315,6 +326,9 @@ (not (shape/shape-proxy? frame)) (u/not-valid plugin-id :createFlow-frame frame) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :createFlow "Plugin doesn't have 'content:write' permission") + :else (let [flow-id (uuid/next)] (st/emit! @@ -328,6 +342,9 @@ (not (flow-proxy? flow)) (u/not-valid plugin-id :removeFlow-flow flow) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeFlow "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/remove-flow id (obj/get flow "$id")) diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 205f7d0d97..9031f021c0 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -103,6 +103,9 @@ (not (contains? ctsi/event-types value)) (u/not-valid plugin-id :trigger value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :trigger "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-interaction (u/locate-shape file-id page-id shape-id) @@ -119,6 +122,9 @@ (or (not (sm/valid-safe-int? value)) (neg? value)) (u/not-valid plugin-id :delay value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :delay "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-interaction (u/locate-shape file-id page-id shape-id) @@ -139,6 +145,9 @@ (not (sm/validate ctsi/schema:interaction interaction)) (u/not-valid plugin-id :action interaction) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :action "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/update-interaction (u/locate-shape file-id page-id shape-id) @@ -148,7 +157,12 @@ :remove (fn [] - (st/emit! (dwi/remove-interaction {:id shape-id} index))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwi/remove-interaction {:id shape-id} index)))))) (def lib-typography-proxy? nil) (def lib-component-proxy nil) @@ -200,15 +214,15 @@ (not (sm/validate [:vector types.fills/schema:fill] value)) (u/not-valid plugin-id :fills value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") + (not (u/page-active? (obj/get self "$page"))) (u/not-valid plugin-id :fills "Cannot modify a page that is not currently active") (cfh/text-shape? shape) (st/emit! (dwt/update-attrs id {:fills value})) - (not (r/check-permission plugin-id "content:write")) - (u/not-valid plugin-id :fills "Plugin doesn't have 'content:write' permission") - :else (st/emit! (dwsh/update-shapes [id] #(assoc % :fills value)))))) @@ -1475,6 +1489,9 @@ :detach (fn [] (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :detach "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) (u/not-valid plugin-id :detach "Cannot modify a page that is not currently active") @@ -1485,12 +1502,12 @@ (fn [component] (let [shape (u/locate-shape file-id page-id id)] (cond - (not (u/page-active? page-id)) - (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active") - (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :swapComponent "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :swapComponent "Cannot modify a page that is not currently active") + (not (obj/type-of? component "LibraryComponentProxy")) (u/not-valid plugin-id :swapComponent "Component not valid") @@ -1507,12 +1524,12 @@ (fn [] (let [shape (u/locate-shape file-id page-id id)] (cond - (not (u/page-active? page-id)) - (u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active") - (not (r/check-permission plugin-id "content:write")) (u/not-valid plugin-id :resetOverrides "Plugin doesn't have 'content:write' permission") + (not (u/page-active? page-id)) + (u/not-valid plugin-id :resetOverrides "Cannot modify a page that is not currently active") + (not (ctk/in-component-copy? shape)) (u/not-valid plugin-id :resetOverrides "The shape is not a component copy instance") @@ -1527,6 +1544,9 @@ (not (sm/validate ctse/schema:export value)) (u/not-valid plugin-id :export value) + (not (r/check-permission plugin-id "content:read")) + (u/not-valid plugin-id :export "Plugin doesn't have 'content:read' permission") + :else (if (and (contains? cf/flags :wasm-export) (contains? #{:jpeg :webp :png} (:type value :png))) @@ -1598,6 +1618,9 @@ (not (sm/validate ctsi/schema:interaction interaction)) (u/not-valid plugin-id :addInteraction interaction) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addInteraction "Plugin doesn't have 'content:write' permission") + :else (let [index (-> (u/locate-shape file-id page-id id) (:interactions []) count)] (st/emit! @@ -1611,6 +1634,9 @@ (not (interaction-proxy? interaction)) (u/not-valid plugin-id :removeInteraction interaction) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission") + :else (st/emit! (dwi/remove-interaction {:id id} (obj/get interaction "$index")) @@ -1691,8 +1717,14 @@ :fn (fn [token attrs] (let [token (u/locate-token file-id (obj/get token "$set-id") (obj/get token "$id")) kw-attrs (into #{} (map token-attr-plugin->token-attr attrs))] - (if (some #(not (token-attr? %)) kw-attrs) + (cond + (some #(not (token-attr? %)) kw-attrs) (u/not-valid plugin-id :applyToken attrs) + + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission") + + :else (st/emit! (-> (dwta/toggle-token {:token token :attrs kw-attrs @@ -1720,6 +1752,9 @@ (not (string? value)) (u/not-valid plugin-id :value value) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :switchVariant "Plugin doesn't have 'content:write' permission") + :else (let [shape (u/locate-shape file-id page-id id) component (u/locate-library-component file-id (:component-id shape))] @@ -1733,6 +1768,9 @@ (or (not (seq ids)) (not (every? uuid/parse* ids))) (u/not-valid plugin-id :ids ids) + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission") + :else (let [;; Keep the input order (head shape first): it determines ;; the order of the resulting variant components (see diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index 64bc69695f..31361117eb 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -17,6 +17,7 @@ [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] + [app.plugins.register :as r] [app.plugins.system-events :as se] [app.plugins.utils :as u] [app.util.object :as obj] @@ -85,16 +86,20 @@ (defn- apply-token-to-shapes [plugin-id file-id set-id id shape-ids attrs] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :applyToken "Plugin doesn't have 'content:write' permission") - (let [token (u/locate-token file-id set-id id)] - (if (some #(not (token-attr? %)) attrs) - (u/not-valid plugin-id :applyToSelected attrs) - (st/emit! - (-> (dwta/toggle-token {:token token - :attrs (into #{} (map token-attr-plugin->token-attr) attrs) - :shape-ids shape-ids - :expand-with-children false}) - (se/add-event plugin-id)))))) + :else + (let [token (u/locate-token file-id set-id id)] + (if (some #(not (token-attr? %)) attrs) + (u/not-valid plugin-id :applyToSelected attrs) + (st/emit! + (-> (dwta/toggle-token {:token token + :attrs (into #{} (map token-attr-plugin->token-attr) attrs) + :shape-ids shape-ids + :expand-with-children false}) + (se/add-event plugin-id))))))) (defn- typography-resolved-value->js "Converts a resolved typography composite (a Clojure map keyed by the @@ -204,8 +209,13 @@ (ctob/get-tokens set-id))) :set (fn [_ value] - (st/emit! (-> (dwtl/update-token set-id id {:name value}) - (se/add-event plugin-id))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (-> (dwtl/update-token set-id id {:name value}) + (se/add-event plugin-id)))))} :type {:this true @@ -230,11 +240,16 @@ base)) :set (fn [_ value] - (let [token (u/locate-token file-id set-id id) - value (cond-> value - (= :font-family (:type token)) - (ctob/convert-dtcg-font-family))] - (st/emit! (dwtl/update-token set-id id {:value value}))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :value "Plugin doesn't have 'content:write' permission") + + :else + (let [token (u/locate-token file-id set-id id) + value (cond-> value + (= :font-family (:type token)) + (ctob/convert-dtcg-font-family))] + (st/emit! (dwtl/update-token set-id id {:value value})))))} :resolvedValue {:this true @@ -265,28 +280,43 @@ :schema cfo/schema:token-description :set (fn [_ value] - (st/emit! (-> (dwtl/update-token set-id id {:description value}) - (se/add-event :plugin-id))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :description "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (-> (dwtl/update-token set-id id {:description value}) + (se/add-event plugin-id)))))} :duplicate (fn [] - ;; TODO: - ;; - add function duplicate-token in tokens-lib, that allows to specify the new id - ;; - use this function in dwtl/duplicate-token - ;; - return the new token proxy using the locally forced id - ;; - do the same with sets and themes - (let [token (u/locate-token file-id set-id id) - token' (ctob/make-token (-> (datafy token) - (dissoc :id - :modified-at)))] - (st/emit! (-> (dwtl/create-token set-id token') - (se/add-event plugin-id))) - (token-proxy plugin-id file-id set-id (:id token')))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission") + + :else + ;; TODO: + ;; - add function duplicate-token in tokens-lib, that allows to specify the new id + ;; - use this function in dwtl/duplicate-token + ;; - return the new token proxy using the locally forced id + ;; - do the same with sets and themes + (let [token (u/locate-token file-id set-id id) + token' (ctob/make-token (-> (datafy token) + (dissoc :id + :modified-at)))] + (st/emit! (-> (dwtl/create-token set-id token') + (se/add-event plugin-id))) + (token-proxy plugin-id file-id set-id (:id token'))))) :remove (fn [] - (st/emit! (-> (dwtl/delete-token set-id id) - (se/add-event plugin-id)))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (-> (dwtl/delete-token set-id id) + (se/add-event plugin-id))))) :applyToShapes {:enumerable false @@ -337,8 +367,13 @@ id) :set (fn [_ name] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/rename-token-set set name))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + :else + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/rename-token-set set name)))))} :active {:this true @@ -351,13 +386,23 @@ :schema ::sm/boolean :set (fn [_ value] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + + :else + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) value)))))} :toggleActive - (fn [_] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/toggle-token-set (ctob/get-name set))))) + (fn [] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + + :else + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/toggle-token-set (ctob/get-name set)))))) :tokens {:this true @@ -416,39 +461,54 @@ (sm/update-properties assoc :decode/json cfo/convert-dtcg-token))])) :decode/options {:key-fn identity} :fn (fn [attrs] - (let [tokens-lib (u/locate-tokens-lib file-id) - token (ctob/make-token attrs) - ;; Resolve against all tokens in the library (including those - ;; in inactive sets) so that references to structurally - ;; existing tokens resolve even if their set is not active. - ;; The target set's tokens take precedence over equally named - ;; tokens in other sets, and the new token takes precedence - ;; over all. - tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib) - (ctob/get-tokens tokens-lib id)) - (assoc (:name token) token)) - resolved-tokens (ts/resolve-tokens tokens-tree) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addToken "Plugin doesn't have 'content:write' permission") - {:keys [errors resolved-value] :as resolved-token} - (get resolved-tokens (:name token))] + :else + (let [tokens-lib (u/locate-tokens-lib file-id) + token (ctob/make-token attrs) + ;; Resolve against all tokens in the library (including those + ;; in inactive sets) so that references to structurally + ;; existing tokens resolve even if their set is not active. + ;; The target set's tokens take precedence over equally named + ;; tokens in other sets, and the new token takes precedence + ;; over all. + tokens-tree (-> (merge (ctob/get-all-tokens-map tokens-lib) + (ctob/get-tokens tokens-lib id)) + (assoc (:name token) token)) + resolved-tokens (ts/resolve-tokens tokens-tree) - (if resolved-value - (do (st/emit! (-> (dwtl/create-token id token) - (se/add-event plugin-id))) - (token-proxy plugin-id file-id id (:id token))) - (do (u/not-valid plugin-id :addToken (str errors)) - nil))))} + {:keys [errors resolved-value] :as resolved-token} + (get resolved-tokens (:name token))] + + (if resolved-value + (do (st/emit! (-> (dwtl/create-token id token) + (se/add-event plugin-id))) + (token-proxy plugin-id file-id id (:id token))) + (do (u/not-valid plugin-id :addToken (str errors)) + nil)))))} :duplicate (fn [] - (let [id-ref (atom nil)] - (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref})) - (when (some? @id-ref) - (token-set-proxy plugin-id file-id @id-ref)))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission") + + :else + (let [id-ref (atom nil)] + (st/emit! (dwtl/duplicate-token-set id {:id-ref id-ref})) + (when (some? @id-ref) + (token-set-proxy plugin-id file-id @id-ref))))) :remove (fn [] - (st/emit! (dwtl/delete-token-set id)))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/delete-token-set id))))))) (defn token-theme-proxy? [p] (obj/type-of? p "TokenThemeProxy")) @@ -501,8 +561,13 @@ (:id theme))) :set (fn [_ group] - (let [theme (u/locate-token-theme file-id id)] - (st/emit! (dwtl/update-token-theme id (assoc theme :group group)))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :group "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (u/locate-token-theme file-id id)] + (st/emit! (dwtl/update-token-theme id (assoc theme :group group))))))} :name {:this true @@ -517,9 +582,14 @@ (:group theme))) :set (fn [_ name] - (let [theme (u/locate-token-theme file-id id)] - (when name - (st/emit! (dwtl/update-token-theme id (assoc theme :name name))))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :name "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (u/locate-token-theme file-id id)] + (when name + (st/emit! (dwtl/update-token-theme id (assoc theme :name name)))))))} :active {:this true @@ -531,11 +601,21 @@ :schema ::sm/boolean :set (fn [_ value] - (st/emit! (dwtl/set-token-theme-active id value)))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :active "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/set-token-theme-active id value))))} :toggleActive - (fn [_] - (st/emit! (dwtl/toggle-token-theme-active id))) + (fn [] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :toggleActive "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/toggle-token-theme-active id)))) :activeSets {:this true @@ -554,32 +634,52 @@ {:enumerable false :schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]] :fn (fn [set-arg] - (let [set-name (token-set-name (resolve-token-set file-id set-arg)) - theme (u/locate-token-theme file-id id)] - (when (and set-name theme) - (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission") + + :else + (let [set-name (token-set-name (resolve-token-set file-id set-arg)) + theme (u/locate-token-theme file-id id)] + (when (and set-name theme) + (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name)))))))} :removeSet {:enumerable false :schema [:tuple [:or [:fn token-set-proxy?] ::sm/uuid]] :fn (fn [set-arg] - (let [set-name (token-set-name (resolve-token-set file-id set-arg)) - theme (u/locate-token-theme file-id id)] - (when (and set-name theme) - (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :removeSet "Plugin doesn't have 'content:write' permission") + + :else + (let [set-name (token-set-name (resolve-token-set file-id set-arg)) + theme (u/locate-token-theme file-id id)] + (when (and set-name theme) + (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name)))))))} :duplicate (fn [] - (let [theme (u/locate-token-theme file-id id) - theme' (ctob/make-token-theme (-> (datafy theme) - (dissoc :id - :modified-at)))] - (st/emit! (dwtl/create-token-theme theme')) - (token-theme-proxy plugin-id file-id (:id theme')))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :duplicate "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (u/locate-token-theme file-id id) + theme' (ctob/make-token-theme (-> (datafy theme) + (dissoc :id + :modified-at)))] + (st/emit! (dwtl/create-token-theme theme')) + (token-theme-proxy plugin-id file-id (:id theme'))))) :remove (fn [] - (st/emit! (dwtl/delete-token-theme id))))) + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :remove "Plugin doesn't have 'content:write' permission") + + :else + (st/emit! (dwtl/delete-token-theme id)))))) (defn tokens-catalog [plugin-id file-id] @@ -619,9 +719,14 @@ nil) (sm/dissoc-key :id))]) ;; We don't allow plugins to set the id :fn (fn [attrs] - (let [theme (ctob/make-token-theme attrs)] - (st/emit! (dwtl/create-token-theme theme)) - (token-theme-proxy plugin-id file-id (:id theme))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addTheme "Plugin doesn't have 'content:write' permission") + + :else + (let [theme (ctob/make-token-theme attrs)] + (st/emit! (dwtl/create-token-theme theme)) + (token-theme-proxy plugin-id file-id (:id theme)))))} :addSet {:enumerable false @@ -638,21 +743,26 @@ (sm/merge [:map [:active {:optional true} ::sm/boolean]]))] :fn (fn [attrs] - (let [active? (boolean (:active attrs)) - attrs (-> attrs - (dissoc :active) - (update :name ctob/normalize-set-name)) - set (ctob/make-token-set attrs)] - (st/emit! (dwtl/create-token-set set)) - ;; Newly created sets are inactive by default; activate it when - ;; requested. Enabling only adds the set name to the hidden theme, - ;; so it does not depend on the create event having propagated yet. - (when active? - (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true))) - ;; Pass the set name as `initial-name` so the proxy can resolve - ;; it immediately, before the async `st/emit!` above propagates - ;; the new set into `@st/state`. - (token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set))))} + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :addSet "Plugin doesn't have 'content:write' permission") + + :else + (let [active? (boolean (:active attrs)) + attrs (-> attrs + (dissoc :active) + (update :name ctob/normalize-set-name)) + set (ctob/make-token-set attrs)] + (st/emit! (dwtl/create-token-set set)) + ;; Newly created sets are inactive by default; activate it when + ;; requested. Enabling only adds the set name to the hidden theme, + ;; so it does not depend on the create event having propagated yet. + (when active? + (st/emit! (dwtl/set-enabled-token-set (ctob/get-name set) true))) + ;; Pass the set name as `initial-name` so the proxy can resolve + ;; it immediately, before the async `st/emit!` above propagates + ;; the new set into `@st/state`. + (token-set-proxy plugin-id file-id (ctob/get-id set) (ctob/get-name set)))))} :getThemeById {:enumerable false diff --git a/frontend/test/frontend_tests/plugins/flex_test.cljs b/frontend/test/frontend_tests/plugins/flex_test.cljs new file mode 100644 index 0000000000..8bfd3ceac0 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/flex_test.cljs @@ -0,0 +1,40 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.flex-test + (:require + [app.common.types.shape.layout :as ctl] + [app.common.uuid :as uuid] + [app.main.store :as st] + [app.plugins.flex :as flex] + [app.plugins.register :as r] + [app.plugins.shape :as shape] + [app.plugins.utils :as u] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-05) +;; --------------------------------------------------------------------------- + +(t/deftest flex-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (flex/flex-layout-proxy plugin-id file-id page-id id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +;; TODO: flex-append-child-checks-permission test requires more complex mocking +;; of u/locate-objects, u/locate-shape, ctl/reverse?, etc. The permission check +;; is in place at flex.cljs line 358. diff --git a/frontend/test/frontend_tests/plugins/library_test.cljs b/frontend/test/frontend_tests/plugins/library_test.cljs index 47d5869b1a..2e604e851e 100644 --- a/frontend/test/frontend_tests/plugins/library_test.cljs +++ b/frontend/test/frontend_tests/plugins/library_test.cljs @@ -6,8 +6,11 @@ (ns frontend-tests.plugins.library-test (:require + [app.common.types.component :as ctk] + [app.common.uuid :as uuid] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.variants :as dwv] [app.main.store :as st] [app.plugins.library :as library] [app.plugins.register :as r] @@ -93,3 +96,112 @@ (t/is (contains? (:color @captured) :image)) (t/is (not (contains? (:color @captured) :color))) (t/is (not (contains? (:color @captured) :gradient)))))) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-02) +;; --------------------------------------------------------------------------- + +(t/deftest variant-add-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.addVariant proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest variant-add-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.addProperty proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest variant-remove-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}]) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.removeProperty proxy 0) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest variant-rename-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + library/get-variant-components (constantly [{:variant-properties [{:name "color" :value "red"}]}]) + st/emit! mock/noop] + (let [proxy (library/variant-proxy plugin-id file-id id)] + (.renameProperty proxy 0 "newName") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :renameProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest component-transform-in-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :main-instance-id id}) + ctk/is-variant? (constantly false) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.transformInVariant proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :transformInVariant "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest component-add-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :main-instance-id id}) + ctk/is-variant? (constantly true) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.addVariant proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addVariant "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) + +(t/deftest component-set-variant-property-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + u/locate-library-component (constantly {:id id :variant-properties [{:name "color"}]}) + st/emit! mock/noop] + (let [proxy (library/lib-component-proxy plugin-id file-id id)] + (.setVariantProperty proxy 0 "red") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :setVariantProperty "Plugin doesn't have 'library:write' permission"] + (first @errors))))))) diff --git a/frontend/test/frontend_tests/plugins/page_test.cljs b/frontend/test/frontend_tests/plugins/page_test.cljs index d29149e846..4a3b983f15 100644 --- a/frontend/test/frontend_tests/plugins/page_test.cljs +++ b/frontend/test/frontend_tests/plugins/page_test.cljs @@ -9,12 +9,17 @@ [app.common.test-helpers.files :as cthf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as cths] + [app.common.uuid :as uuid] [app.main.data.workspace.pages :as dwpg] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.page :as page] + [app.plugins.register :as r] [app.plugins.shape :as shape] + [app.plugins.utils :as u] [app.util.object :as obj] [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock] [frontend-tests.helpers.state :as ths] [frontend-tests.helpers.wasm :as thw] [potok.v2.core :as ptk])) @@ -151,3 +156,85 @@ (done)))) (mock-page-initialized store page2-id)) 0)))) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-03) +;; --------------------------------------------------------------------------- + +(t/deftest flow-name-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)] + (set! (.-name proxy) "new-name") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest flow-starting-board-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop + shape/shape-proxy? (constantly true)] + (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)] + (set! (.-startingBoard proxy) #js {}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :startingBoard "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest flow-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop] + (let [proxy (page/flow-proxy plugin-id file-id page-id flow-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest create-flow-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop + shape/shape-proxy? (constantly true)] + (let [proxy (page/page-proxy plugin-id file-id page-id) + frame #js {"$id" (uuid/next)}] + (.createFlow proxy "flow-name" frame) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :createFlow "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest remove-flow-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + flow-id (uuid/next) + errors (atom [])] + (with-redefs [r/check-permission (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + st/emit! mock/noop + page/flow-proxy? (constantly true)] + (let [proxy (page/page-proxy plugin-id file-id page-id)] + (.removeFlow proxy (page/flow-proxy plugin-id file-id page-id flow-id)) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeFlow "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) diff --git a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs index c854e4942c..e877c4cc44 100644 --- a/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs +++ b/frontend/test/frontend_tests/plugins/shape_bugfixes_test.cljs @@ -11,10 +11,15 @@ [app.common.types.component :as ctk] [app.common.uuid :as uuid] [app.main.data.workspace :as dw] + [app.main.data.workspace.interactions :as dwi] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.texts :as dwt] + [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.variants :as dwv] [app.main.store :as st] [app.plugins.api :as api] [app.plugins.public-utils :as public-utils] + [app.plugins.register :as r] [app.plugins.shape :as shape] [app.plugins.utils :as u] [cljs.test :as t :include-macros true] @@ -204,3 +209,202 @@ (t/deftest group-empty-input-returns-nil (let [context (api/create-context plugin-id)] (t/is (nil? (.group context #js []))))) + +;; --------------------------------------------------------------------------- +;; Permission checks (T9-F-04) +;; --------------------------------------------------------------------------- + +(t/deftest commit-fills-text-shape-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/proxy->shape (constantly {:id shape-id :type :text}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (set! (.-fills proxy) #js [#js {:fillColor "#ff0000" :fillOpacity 1}]) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :fills "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-trigger-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (set! (.-trigger inter) "click") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :trigger "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-delay-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (set! (.-delay inter) 100) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :delay "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-action-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/proxy->interaction (constantly {:event-type :click :delay 0 :action-type :open-url :url "https://example.com"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (set! (.-action inter) #js {:type "open-url" :url "https://example.com"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :action "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest interaction-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (.remove inter) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest add-interaction-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-shape (constantly {:id shape-id}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.addInteraction proxy "click" #js {:type "open-url" :url "https://example.com"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addInteraction "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest remove-interaction-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id) + inter (shape/interaction-proxy plugin-id file-id page-id shape-id 0)] + (.removeInteraction proxy inter) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeInteraction "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest detach-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/page-active? (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.detach proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :detach "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest export-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false)] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.export proxy #js {:type "png" :scale 1}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :export "Plugin doesn't have 'content:read' permission"] + (first @errors))))))) + +(t/deftest apply-token-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color}) + shape/token-proxy? (constantly true) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id) + token #js {"$set-id" (str set-id) "$id" (str token-id)}] + (.applyToken proxy token #js []) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :applyToken "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest switch-variant-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-shape (constantly {:id shape-id :component-id shape-id}) + u/locate-library-component (constantly {:id (uuid/next)}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.switchVariant proxy 0 "value") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :switchVariant "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) + +(t/deftest combine-as-variants-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + page-id (uuid/next) + shape-id (uuid/next) + other-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-shape (fn [_file _page id] {:id id :component-id id}) + u/locate-library-component (constantly {:id (uuid/next)}) + ctk/is-variant? (constantly false) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (shape/shape-proxy plugin-id file-id page-id shape-id)] + (.combineAsVariants proxy #js [(str other-id)]) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :combineAsVariants "Plugin doesn't have 'content:write' permission"] + (first @errors))))))) diff --git a/frontend/test/frontend_tests/plugins/tokens_test.cljs b/frontend/test/frontend_tests/plugins/tokens_test.cljs index 5c6122f346..4707b34279 100644 --- a/frontend/test/frontend_tests/plugins/tokens_test.cljs +++ b/frontend/test/frontend_tests/plugins/tokens_test.cljs @@ -16,6 +16,7 @@ [app.main.data.workspace.tokens.library-edit :as dwtl] [app.main.store :as st] [app.plugins.api :as api] + [app.plugins.register :as r] [app.plugins.tokens :as ptok] [app.plugins.utils :as u] [cljs.test :as t :include-macros true] @@ -236,7 +237,8 @@ set-id (cthi/new-id! :set) dup-id (cthi/new-id! :dup) proxy (ptok/token-set-proxy "plugin-id" file-id set-id)] - (with-redefs [dwtl/duplicate-token-set + (with-redefs [r/check-permission (constantly true) + dwtl/duplicate-token-set (mock/stub (fn [id {:keys [id-ref]}] (t/is (= set-id id)) (reset! id-ref dup-id) @@ -253,7 +255,8 @@ set (ptok/token-set-proxy "plugin-id" file-id set-id "Primitives") theme (ptok/token-theme-proxy "plugin-id" file-id theme-id) captured (atom [])] - (with-redefs [u/locate-token-theme + (with-redefs [r/check-permission (constantly true) + u/locate-token-theme (fn [_file _theme] (ctob/make-token-theme :id theme-id :name "Theme" @@ -274,7 +277,8 @@ set-id (cthi/new-id! :set) token-id (cthi/new-id! :token) captured (atom nil)] - (with-redefs [u/locate-token (constantly {:id token-id + (with-redefs [r/check-permission (constantly true) + u/locate-token (constantly {:id token-id :name "font.primary" :type :font-family :value ["Inter"]}) @@ -347,7 +351,8 @@ theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light") emitted (atom []) invalid (atom [])] - (with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) + (with-redefs [r/check-permission (constantly true) + u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) u/not-valid (fn [_ code value] (swap! invalid conj [code value])) dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) @@ -367,7 +372,8 @@ theme (ctob/make-token-theme :id theme-id :group "mode" :name "Light") emitted (atom []) invalid (atom [])] - (with-redefs [u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) + (with-redefs [r/check-permission (constantly true) + u/locate-token-set (fn [_ id] (when (= id set-id) token-set)) u/locate-token-theme (fn [_ id] (when (= id theme-id) theme)) u/not-valid (fn [_ code value] (swap! invalid conj [code value])) dwtl/update-token-theme (fn [id theme] {:id id :theme theme}) @@ -400,3 +406,304 @@ (t/is (= 2 (count @errors))) (t/is (every? #(instance? js/Error %) @errors)))))) +;; ═══════════════════════════════════════════════════════════════ +;; Permission check tests (T9-F-01) +;; ═══════════════════════════════════════════════════════════════ + +;; Note: token-proxy-name-setter-checks-permission test removed because +;; schema validation runs before the permission check, making it impossible +;; to test the permission check directly for setters with schemas. + +(t/deftest token-proxy-value-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color}) + u/locate-tokens-lib (constantly nil) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (set! (.-value proxy) "#ff0000") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :value "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-proxy-description-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (set! (.-description proxy) "A description") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :description "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-proxy-duplicate-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token (constantly {:id token-id :name "test" :type :color :value "#000"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (.duplicate proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-proxy-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + token-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-proxy plugin-id file-id set-id token-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-name-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (set! (.-name proxy) "new-core") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-active-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (set! (.-active proxy) true) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-toggle-active-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)] + (.toggleActive proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-add-token-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + tokens-lib (-> (ctob/make-tokens-lib) + (ctob/add-set (ctob/make-token-set :id set-id :name "core"))) + errors (atom [])] + (with-redefs [u/locate-token-set (constantly {:id set-id :name "core"}) + u/locate-tokens-lib (constantly tokens-lib) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (t/is (fn? (.-addToken proxy))) + (.addToken proxy #js {"type" "color" "name" "color.test" "value" "#FF0000"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addToken "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-duplicate-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)] + (.duplicate proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-set-proxy-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-set-proxy plugin-id file-id set-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-group-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"}) + u/locate-tokens-lib (constantly nil) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (set! (.-group proxy) "new-group") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :group "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-name-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"}) + u/locate-tokens-lib (constantly nil) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (set! (.-name proxy) "Dark") + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :name "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-active-setter-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (set! (.-active proxy) true) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :active "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-toggle-active-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (.toggleActive proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :toggleActive "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-add-set-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{}}) + u/locate-token-set (constantly {:id set-id :name "core"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id) + set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (.addSet proxy set-proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-remove-set-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + set-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :sets #{"core"}}) + u/locate-token-set (constantly {:id set-id :name "core"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id) + set-proxy (ptok/token-set-proxy plugin-id file-id set-id "core")] + (.removeSet proxy set-proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :removeSet "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-duplicate-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-token-theme (constantly {:id theme-id :name "Light" :group "mode"}) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (.duplicate proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :duplicate "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest token-theme-proxy-remove-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + theme-id (uuid/next) + errors (atom [])] + (with-redefs [u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [proxy (ptok/token-theme-proxy plugin-id file-id theme-id)] + (.remove proxy) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :remove "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest tokens-catalog-add-theme-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [catalog (ptok/tokens-catalog plugin-id file-id)] + (.addTheme catalog #js {"name" "NewTheme" "group" "mode"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addTheme "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + +(t/deftest tokens-catalog-add-set-checks-permission + (let [plugin-id "test-plugin" + file-id (uuid/next) + errors (atom [])] + (with-redefs [u/locate-tokens-lib (constantly (ctob/make-tokens-lib)) + u/not-valid (mock/stub (fn [pid prop msg] (swap! errors conj [pid prop msg]))) + r/check-permission (constantly false) + st/emit! mock/noop] + (let [catalog (ptok/tokens-catalog plugin-id file-id)] + (.addSet catalog #js {"name" "NewSet"}) + (t/is (= 1 (count @errors))) + (t/is (= [plugin-id :addSet "Plugin doesn't have 'content:write' permission"] (first @errors))))))) + diff --git a/frontend/test/frontend_tests/plugins/user_test.cljs b/frontend/test/frontend_tests/plugins/user_test.cljs new file mode 100644 index 0000000000..99c83081bd --- /dev/null +++ b/frontend/test/frontend_tests/plugins/user_test.cljs @@ -0,0 +1,80 @@ +;; 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 Sucursal en España SL + +(ns frontend-tests.plugins.user-test + (:require + [app.main.data.comments :as dc] + [app.main.store :as st] + [app.plugins.api :as api] + [app.plugins.comments :as comments] + [app.plugins.file :as file] + [app.plugins.register :as r] + [cljs.test :as t :include-macros true] + [frontend-tests.helpers.mock :as mock])) + +(def ^:private plugin-id "00000000-0000-0000-0000-000000000000") + +(t/deftest comment-thread-owner-returns-nil-without-user-read + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + thread (comments/comment-thread-proxy + plugin-id + file-id + page-id + {:id thread-id :owner-id owner-id})] + (with-redefs [r/check-permission (constantly false) + dc/get-owner (constantly {:id owner-id :fullname "Owner"})] + (t/is (nil? (.-owner thread))) + (t/is (nil? (.-user thread)))))) + +(t/deftest comment-reply-owner-returns-nil-without-user-read + (let [owner-id (random-uuid) + file-id (random-uuid) + page-id (random-uuid) + thread-id (random-uuid) + reply-id (random-uuid) + reply (comments/comment-proxy + plugin-id + file-id + page-id + thread-id + {:id reply-id :owner-id owner-id})] + (with-redefs [r/check-permission (constantly false) + dc/get-owner (constantly {:id owner-id :fullname "Owner"})] + (t/is (nil? (.-owner reply))) + (t/is (nil? (.-user reply)))))) + +(t/deftest file-version-created-by-returns-nil-without-user-read + (let [file-id (random-uuid) + version-id (random-uuid) + profile-id (random-uuid) + version (file/file-version-proxy + plugin-id + file-id + {profile-id {:id profile-id :fullname "User"}} + {:id version-id + :label "Version" + :created-at (js/Date.) + :profile-id profile-id})] + (with-redefs [r/check-permission (constantly false)] + (t/is (nil? (.-createdBy version)))))) + +(t/deftest get-current-user-returns-nil-without-user-read + (let [ctx (api/create-context plugin-id)] + (with-redefs [r/check-permission (constantly false) + st/state (atom {:session-id (random-uuid) + :profile {:id (random-uuid) :fullname "User"}})] + (t/is (nil? (.getCurrentUser ctx)))))) + +(t/deftest get-active-users-returns-empty-without-user-read + (let [ctx (api/create-context plugin-id)] + (with-redefs [r/check-permission (constantly false) + st/state (atom {:session-id (random-uuid) + :profile {:id (random-uuid)} + :workspace-presence {(random-uuid) {:id (random-uuid)}}})] + (t/is (zero? (.-length (.getActiveUsers ctx))))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 3430ceb41a..5927bfabfd 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -44,6 +44,7 @@ [frontend-tests.plugins.comments-test] [frontend-tests.plugins.context-shapes-test] [frontend-tests.plugins.file-test] + [frontend-tests.plugins.flex-test] [frontend-tests.plugins.format-test] [frontend-tests.plugins.grid-test] [frontend-tests.plugins.interactions-test] @@ -55,6 +56,7 @@ [frontend-tests.plugins.shape-bugfixes-test] [frontend-tests.plugins.text-test] [frontend-tests.plugins.tokens-test] + [frontend-tests.plugins.user-test] [frontend-tests.plugins.utils-test] [frontend-tests.plugins.value-objects-test] [frontend-tests.render-dimensions-test] @@ -143,6 +145,7 @@ 'frontend-tests.plugins.comments-test 'frontend-tests.plugins.context-shapes-test 'frontend-tests.plugins.file-test + 'frontend-tests.plugins.flex-test 'frontend-tests.plugins.format-test 'frontend-tests.plugins.grid-test 'frontend-tests.plugins.interactions-test @@ -154,6 +157,7 @@ 'frontend-tests.plugins.shape-bugfixes-test 'frontend-tests.plugins.text-test 'frontend-tests.plugins.tokens-test + 'frontend-tests.plugins.user-test 'frontend-tests.plugins.utils-test 'frontend-tests.plugins.value-objects-test 'frontend-tests.render-wasm.process-objects-test From af5b76793350391dabc829aa259d78112210ecb9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 8 Sep 2026 10:29:15 +0200 Subject: [PATCH 08/16] :bug: Fix microinteractions on text shape selrects for autowidth/autoheight (#11068) (#11545) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Belén Albeza --- .../text-editor/get-file-fixed-size-text.json | 349 ++++++++++++++++++ .../ui/specs/text-editor-v3.spec.js | 111 +++++- .../main/ui/workspace/viewport/selection.cljs | 23 +- 3 files changed, 467 insertions(+), 16 deletions(-) create mode 100644 frontend/playwright/data/text-editor/get-file-fixed-size-text.json diff --git a/frontend/playwright/data/text-editor/get-file-fixed-size-text.json b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json new file mode 100644 index 0000000000..5e88e658a2 --- /dev/null +++ b/frontend/playwright/data/text-editor/get-file-fixed-size-text.json @@ -0,0 +1,349 @@ +{ + "~:features": { + "~#set": [ + "fdata/path-data", + "plugins/runtime", + "design-tokens/v1", + "layout/grid", + "styles/v2", + "fdata/pointer-map", + "fdata/objects-map", + "components/v2", + "fdata/shape-data-type", + "text-editor/v2" + ] + }, + "~:team-id": "~u9e6e22b2-db76-81d6-8006-75d7cdbb8bad", + "~: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": "Fixed size text", + "~:revn": 3, + "~:modified-at": "~m1753957736516", + "~:vern": 0, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~: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", + "0007-clear-invalid-strokes-and-fills-v2", + "0008-fix-library-colors-v4", + "0009-clean-library-colors", + "0009-add-partial-text-touched-flags" + ] + }, + "~:version": 67, + "~:project-id": "~u9e6e22b2-db76-81d6-8006-75d7cdc30669", + "~:created-at": "~m1753957644225", + "~:data": { + "~:pages": [ + "~u238a17e0-75ff-8075-8006-934586ea2231" + ], + "~:pages-index": { + "~u238a17e0-75ff-8075-8006-934586ea2231": { + "~:objects": { + "~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 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.0 + } + }, + { + "~#point": { + "~:x": 0.01, + "~:y": 0.01 + } + }, + { + "~#point": { + "~:x": 0.0, + "~:y": 0.01 + } + } + ], + "~:r2": 0, + "~:proportion-lock": false, + "~:transform-inverse": { + "~#matrix": { + "~: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, + "~:width": 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, + "~:height": 0.01, + "~:flip-y": null, + "~:shapes": [ + "~ucc6f0580-449c-8019-8006-9345db077fa0" + ] + } + }, + "~ucc6f0580-449c-8019-8006-9345db077fa0": { + "~#shape": { + "~:y": 150, + "~: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": "1s4am1jl24s", + "~:children": [ + { + "~:type": "paragraph-set", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:children": [ + { + "~:line-height": "1.2", + "~:font-style": "normal", + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:font-id": "sourcesanspro", + "~:key": "13p0zwl2yhc", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro", + "~:text": "Lorem ipsum" + } + ], + "~:typography-ref-id": null, + "~:text-transform": "none", + "~:text-align": "left", + "~:font-id": "sourcesanspro", + "~:key": "20hf3kmyoub", + "~:font-size": "14", + "~:font-weight": "400", + "~:typography-ref-file": null, + "~:text-direction": "ltr", + "~:type": "paragraph", + "~:font-variant-id": "regular", + "~:text-decoration": "none", + "~:letter-spacing": "0", + "~:fills": [ + { + "~:fill-color": "#000000", + "~:fill-opacity": 1 + } + ], + "~:font-family": "sourcesanspro" + } + ] + } + ], + "~:vertical-align": "top" + }, + "~:hide-in-viewer": false, + "~:name": "Fixed text", + "~:width": 300, + "~:type": "~:text", + "~:points": [ + { + "~#point": { + "~:x": 200, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 150 + } + }, + { + "~#point": { + "~:x": 500, + "~:y": 350 + } + }, + { + "~#point": { + "~:x": 200, + "~:y": 350 + } + } + ], + "~:transform-inverse": { + "~#matrix": { + "~:a": 1.0, + "~:b": 0.0, + "~:c": 0.0, + "~:d": 1.0, + "~:e": 0.0, + "~:f": 0.0 + } + }, + "~:id": "~ucc6f0580-449c-8019-8006-9345db077fa0", + "~:parent-id": "~u00000000-0000-0000-0000-000000000000", + "~:frame-id": "~u00000000-0000-0000-0000-000000000000", + "~:x": 200, + "~:selrect": { + "~#rect": { + "~:x": 200, + "~:y": 150, + "~:width": 300, + "~:height": 200, + "~:x1": 200, + "~:y1": 150, + "~:x2": 500, + "~:y2": 350 + } + }, + "~:flip-x": null, + "~:height": 200, + "~:flip-y": null + } + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2231", + "~:name": "Page 1" + } + }, + "~:id": "~u238a17e0-75ff-8075-8006-934586ea2230", + "~:options": { + "~:components-v2": true, + "~:base-font-size": "16px" + } + } +} \ No newline at end of file diff --git a/frontend/playwright/ui/specs/text-editor-v3.spec.js b/frontend/playwright/ui/specs/text-editor-v3.spec.js index 53b439ab19..e266224d8c 100644 --- a/frontend/playwright/ui/specs/text-editor-v3.spec.js +++ b/frontend/playwright/ui/specs/text-editor-v3.spec.js @@ -9,7 +9,9 @@ const FILE = { test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); // WASM_FLAGS already enables render-wasm; add the WASM text editor on top. - await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-text-editor-wasm"]); + await WasmWorkspacePage.mockConfigFlags(page, [ + "enable-feature-text-editor-wasm", + ]); }); async function openEditorAndSelectAll(workspace) { @@ -22,12 +24,12 @@ async function openEditorAndSelectAll(workspace) { } test.describe("BUG 10502 - Mixed families and variants", () => { - test("Multiple variants of the same font family", async ({ - page, - }) => { + test("Multiple variants of the same font family", async ({ page }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-variants.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-variants.json", + ); await workspace.goToWorkspace(FILE); await workspace.waitForFirstRender(); @@ -47,10 +49,14 @@ test.describe("BUG 10502 - Mixed families and variants", () => { await expect(fontVariant).toHaveText("--"); }); - test("Mixed font families appear as such in the dropdown", async ({ page }) => { + test("Mixed font families appear as such in the dropdown", async ({ + page, + }) => { const workspace = new WasmWorkspacePage(page, { textEditor: true }); await workspace.setupEmptyFile(); - await workspace.mockGetFile("text-editor/get-file-10502-mixed-families.json"); + await workspace.mockGetFile( + "text-editor/get-file-10502-mixed-families.json", + ); // Serve a stand-in TTF for Sora so the render doesn't wait on a real fetch. // Glyphs are irrelevant here: the assertion only inspects the sidebar. await workspace.mockGoogleFont("sora", "render-wasm/assets/ebgaramond.ttf"); @@ -147,9 +153,94 @@ test("BUG 10531 - Entering the editor auto-selects the whole text", async ({ await workspace.copy("keyboard"); // Assert the text was copied correctly - const copiedText = await page.evaluate(() => - navigator.clipboard.readText(), - ); + const copiedText = await page.evaluate(() => navigator.clipboard.readText()); expect(copiedText).toBe("Lorem ipsum"); }); +test.describe("BUG 10934 - Double-clicking a text side handle sets auto-size", () => { + // Sets up the workspace and loads a text shape whose size is larger than its text + async function setupFixedSizeText(page) { + const workspace = new WasmWorkspacePage(page, { textEditor: true }); + // Enable token inputs so they use the new component with accessible DOM + await workspace.mockConfigFlags(["enable-feature-token-input"]); + await workspace.setupEmptyFile(); + await workspace.mockGetFile("text-editor/get-file-fixed-size-text.json"); + await workspace.goToWorkspace(); + await workspace.waitForFirstRender(); + + // Select the text and zoom to fit, so it is fully visible in the viewport + await workspace.clickLeafLayer("Fixed text"); + await page.keyboard.press("Shift+1"); + await workspace.waitForIdle(); + + return workspace; + } + + async function doubleClickSideHandle(workspace, position) { + const handle = workspace.viewport.getByTestId( + `resize-side-handler-${position}`, + ); + await handle.waitFor(); + const box = await handle.boundingBox(); + await workspace.page.mouse.dblclick( + box.x + box.width / 2, + box.y + box.height / 2, + ); + } + + function measureInput(workspace, name) { + return workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name, exact: true }); + } + + test("Double-clicking the right handle switches to auto-width", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const widthInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Width", exact: true }); + const initialWidth = Number(await widthInput.inputValue()); + + await doubleClickSideHandle(workspace, "right"); + + // Assert auto-width is selected and that the width has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto width", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await widthInput.inputValue())) + .toBeLessThan(initialWidth); + }); + + test("Double-clicking the bottom handle switches to auto-height", async ({ + page, + }) => { + const workspace = await setupFixedSizeText(page); + + const heightInput = workspace.rightSidebar + .getByRole("region", { name: "shape-measures-section" }) + .getByRole("textbox", { name: "Height", exact: true }); + const initialHeight = Number(await heightInput.inputValue()); + + await doubleClickSideHandle(workspace, "bottom"); + + // Assert auto-height is selected and that the height has shrunk. The resize + // is debounced, so poll the value (auto-retrying) rather than reading once. + await expect( + workspace.rightSidebar.getByRole("button", { + name: "Auto height", + pressed: true, + }), + ).toBeVisible(); + await expect + .poll(async () => Number(await heightInput.inputValue())) + .toBeLessThan(initialHeight); + }); +}); diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 9e497ef8d0..4315ff2d0b 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -18,6 +18,8 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.wasm-text :as dwwt] + [app.main.features :as features] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.context :as ctx] @@ -26,6 +28,7 @@ [app.util.debug :as dbg] [app.util.dom :as dom] [app.util.object :as obj] + [potok.v2.core :as ptk] [rumext.v2 :as mf])) (def rotation-handler-size 20) @@ -295,13 +298,20 @@ on-double-click (mf/use-fn (mf/deps shape-id position shape-type) - (fn [_event] + (fn [event] (when (= shape-type :text) - (cond - (= position :right) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-width))) - (= position :bottom) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type :auto-height)))))))] + ;; Prevent the viewport double-click handler from entering text editor + (dom/stop-propagation event) + (let [grow-type (case position + :right :auto-width + :bottom :auto-height + nil)] + (when (some? grow-type) + (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) + ;; The WASM renderer needs an explicit reflow after the grow-type change + (when (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-all [shape-id]) + (ptk/data-event :layout/update {:ids [shape-id]}))))))))] [:g.resize-handler (when ^boolean show-handler @@ -321,6 +331,7 @@ :height height :class cursor :data-position (name position) + :data-testid (dm/str "resize-side-handler-" (name position)) :transform transform-str :on-pointer-down on-resize :on-double-click on-double-click From 18e641d79a91882e112ebb8556be638e8ad214bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Elena=20Torr=C3=B3?= Date: Tue, 8 Sep 2026 13:32:37 +0200 Subject: [PATCH 09/16] :bug: Fix selrect auto-width on selrect click and selrect resize (#11541) * :bug: Fix text edge double click needing two undos * :bug: Fix text font change needing two undos --- .../src/app/main/data/workspace/texts.cljs | 3 +- .../app/main/data/workspace/wasm_text.cljs | 34 +++++++++++-------- .../main/ui/workspace/viewport/selection.cljs | 14 +++++--- 3 files changed, 31 insertions(+), 20 deletions(-) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index dd8741218d..d95a059a31 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -123,7 +123,8 @@ (defn- await-font-faces "Waits for missing WASM faces, then resizes the affected texts." [stream face-keys ids] - (let [resize-stream (->> (rx/from ids) (rx/map dwwt/resize-wasm-text))] + (let [resize-opts {:stack-undo? true :undo-transation? false} + resize-stream (->> (rx/from ids) (rx/map #(dwwt/resize-wasm-text % resize-opts)))] (if (empty? face-keys) resize-stream (->> (rx/merge wasm.fonts/font-stored-stream diff --git a/frontend/src/app/main/data/workspace/wasm_text.cljs b/frontend/src/app/main/data/workspace/wasm_text.cljs index bb98793dc3..4627a0b01e 100644 --- a/frontend/src/app/main/data/workspace/wasm_text.cljs +++ b/frontend/src/app/main/data/workspace/wasm_text.cljs @@ -79,20 +79,26 @@ (defn resize-wasm-text "Resize a single text shape (auto-width/auto-height) by id. - No-op if the id is not a text shape or is :fixed." - [id] - (ptk/reify ::resize-wasm-text - ptk/WatchEvent - (watch [_ state _] - (let [objects (dsh/lookup-page-objects state) - shape (get objects id) - resize-stream - (if (and (some? shape) - (cfh/text-shape? shape) - (not= :fixed (:grow-type shape))) - (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape))) - (rx/empty))] - (wrf/with-pending :text-resize [id] resize-stream))))) + No-op if the id is not a text shape or is :fixed. + `opts` are forwarded to `apply-wasm-modifiers`, so a caller whose undo + transaction is already closed when the resize lands can still get the + geometry into the right undo entry." + ([id] + (resize-wasm-text id nil)) + ([id opts] + (ptk/reify ::resize-wasm-text + ptk/WatchEvent + (watch [_ state _] + (let [objects (dsh/lookup-page-objects state) + shape (get objects id) + apply-opts (or opts {}) + resize-stream + (if (and (some? shape) + (cfh/text-shape? shape) + (not= :fixed (:grow-type shape))) + (rx/of (dwm/apply-wasm-modifiers (resize-wasm-text-modifiers shape) apply-opts)) + (rx/empty))] + (wrf/with-pending :text-resize [id] resize-stream)))))) (defn resize-wasm-text-debounce-commit ([] diff --git a/frontend/src/app/main/ui/workspace/viewport/selection.cljs b/frontend/src/app/main/ui/workspace/viewport/selection.cljs index 4315ff2d0b..e271574081 100644 --- a/frontend/src/app/main/ui/workspace/viewport/selection.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/selection.cljs @@ -18,6 +18,7 @@ [app.main.data.helpers :as dsh] [app.main.data.workspace :as dw] [app.main.data.workspace.shapes :as dwsh] + [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.wasm-text :as dwwt] [app.main.features :as features] [app.main.refs :as refs] @@ -307,11 +308,14 @@ :bottom :auto-height nil)] (when (some? grow-type) - (st/emit! (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) - ;; The WASM renderer needs an explicit reflow after the grow-type change - (when (features/active-feature? @st/state "render-wasm/v1") - (st/emit! (dwwt/resize-wasm-text-all [shape-id]) - (ptk/data-event :layout/update {:ids [shape-id]}))))))))] + (let [uid (js/Symbol)] + (st/emit! (dwu/start-undo-transaction uid) + (dwsh/update-shapes [shape-id] #(assoc % :grow-type grow-type))) + ;; The WASM renderer needs an explicit reflow after the grow-type change + (if (features/active-feature? @st/state "render-wasm/v1") + (st/emit! (dwwt/resize-wasm-text-all [shape-id] {:undo-id uid}) + (ptk/data-event :layout/update {:ids [shape-id]})) + (st/emit! (dwu/commit-undo-transaction uid)))))))))] [:g.resize-handler (when ^boolean show-handler From fb6ece7a7eda9bd3f034aa54f28be3f29f067900 Mon Sep 17 00:00:00 2001 From: Pablo Alba Date: Tue, 8 Sep 2026 13:38:00 +0200 Subject: [PATCH 10/16] Revert ":bug: Enforce SSRF checks and add timeouts to HTTP client (#11474)" (#11556) This reverts commit ff63668c1ef61928878cded13bc1c2734c983aff. --- backend/src/app/http/client.clj | 11 +-- backend/src/app/main.clj | 2 +- backend/src/app/media/remote.clj | 6 +- backend/src/app/nitrate.clj | 3 +- .../test/backend_tests/http_client_test.clj | 30 -------- .../test/backend_tests/media_remote_test.clj | 17 ----- .../test/backend_tests/nitrate_ssrf_test.clj | 70 ------------------- 7 files changed, 9 insertions(+), 130 deletions(-) delete mode 100644 backend/test/backend_tests/http_client_test.clj delete mode 100644 backend/test/backend_tests/nitrate_ssrf_test.clj diff --git a/backend/src/app/http/client.clj b/backend/src/app/http/client.clj index 891e581f70..bba77f9aa0 100644 --- a/backend/src/app/http/client.clj +++ b/backend/src/app/http/client.clj @@ -15,7 +15,6 @@ (:require [app.common.schema :as sm] [app.util.ssrf :as ssrf] - [app.worker :as-alias wrk] [cuerdas.core :as str] [integrant.core :as ig] [java-http-clj.core :as http]) @@ -24,8 +23,6 @@ java.net.URI)) (def default-max-redirects 5) -(def default-connect-timeout 30000) -(def default-request-timeout 30000) (defn client? [o] @@ -36,17 +33,15 @@ :pred client?}) (defmethod ig/init-key ::client - [_ {:keys [::wrk/executor]}] - (http/build-client {:connect-timeout default-connect-timeout - :executor executor + [_ _] + (http/build-client {:connect-timeout 30000 :follow-redirects :never})) (defn send! ([client req] (send! client req {})) ([client req {:keys [response-type] :or {response-type :string}}] (assert (client? client) "expected valid http client") - (http/send (merge {:timeout default-request-timeout} req) - {:client client :as response-type}))) + (http/send req {:client client :as response-type}))) (defn- resolve-client [params] diff --git a/backend/src/app/main.clj b/backend/src/app/main.clj index cc627f3307..d0ffd1cf58 100644 --- a/backend/src/app/main.clj +++ b/backend/src/app/main.clj @@ -200,7 +200,7 @@ {::db/pool (ig/ref ::db/pool)} ::http.client/client - {::wrk/executor (ig/ref ::wrk/executor)} + {} ::session/manager {::db/pool (ig/ref ::db/pool)} diff --git a/backend/src/app/media/remote.clj b/backend/src/app/media/remote.clj index 9e717b2fd7..0b5a0a4a42 100644 --- a/backend/src/app/media/remote.clj +++ b/backend/src/app/media/remote.clj @@ -75,10 +75,10 @@ {:method method :uri uri :body body - :headers headers - :timeout timeout} + :headers headers} {:response-type :input-stream - :skip-ssrf-check? true}) + :skip-ssrf-check? true + :timeout timeout}) status (:status resp)] (when (not (<= 200 status 299)) (let [body (:body resp)] diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index 5adafaeced..a189116458 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -68,7 +68,8 @@ "x-profile-id" (str profile-id)} :uri uri :version :http1.1} - (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key)))))) + (= method :post) (assoc :body (json/encode request-params :key-fn json/write-camel-key))) + {:skip-ssrf-check? true}))) (defn- with-retries [handler max-retries] diff --git a/backend/test/backend_tests/http_client_test.clj b/backend/test/backend_tests/http_client_test.clj deleted file mode 100644 index d67edcf182..0000000000 --- a/backend/test/backend_tests/http_client_test.clj +++ /dev/null @@ -1,30 +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 Sucursal en España SL - -(ns backend-tests.http-client-test - (:require - [app.http.client :as http] - [clojure.test :as t] - [java-http-clj.core :as jhttp] - [mockery.core :refer [with-mocks]])) - -(t/deftest send-injects-default-timeout-when-absent - (with-mocks [mock {:target 'java-http-clj.core/send - :return {:status 200 :body ""}}] - (let [client (jhttp/build-client {})] - (http/send! client {:method :get :uri "https://example.com/"}) - (let [[req _opts] (:call-args @mock)] - (t/is (= http/default-request-timeout (:timeout req))))))) - -(t/deftest send-preserves-caller-supplied-timeout - (with-mocks [mock {:target 'java-http-clj.core/send - :return {:status 200 :body ""}}] - (let [client (jhttp/build-client {})] - (http/send! client {:method :get - :uri "https://example.com/" - :timeout 5000}) - (let [[req _opts] (:call-args @mock)] - (t/is (= 5000 (:timeout req))))))) \ No newline at end of file diff --git a/backend/test/backend_tests/media_remote_test.clj b/backend/test/backend_tests/media_remote_test.clj index 21eb46bec8..dfa8b16d05 100644 --- a/backend/test/backend_tests/media_remote_test.clj +++ b/backend/test/backend_tests/media_remote_test.clj @@ -8,7 +8,6 @@ (:require [app.common.exceptions :as ex] [app.config :as cf] - [app.http.client :as http] [app.media.remote :as media.remote] [app.setup :as-alias setup] [app.util.json :as json] @@ -501,22 +500,6 @@ :headers {}})] (t/is (= 200 (:status resp)))))))) -(t/deftest service-request-puts-configured-timeout-in-request - (t/testing "service-request puts media-processing-service-timeout on the http request" - (let [captured (atom nil)] - (with-redefs [cf/get (th/config-get-mock config-mock) - http/req (fn [_client request _opts] - (reset! captured request) - {:status 200 - :body (json-stream {:width 100 :height 100})})] - (media.remote/service-request - (mk-system) - {:method :post - :uri "http://localhost:6065/api/image/info" - :body nil - :headers {}}) - (t/is (= 5000 (:timeout @captured))))))) - ;; --------------------------------------------------------------------------- ;; Shared key ;; --------------------------------------------------------------------------- diff --git a/backend/test/backend_tests/nitrate_ssrf_test.clj b/backend/test/backend_tests/nitrate_ssrf_test.clj deleted file mode 100644 index e44f96356a..0000000000 --- a/backend/test/backend_tests/nitrate_ssrf_test.clj +++ /dev/null @@ -1,70 +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 Sucursal en España SL - -(ns backend-tests.nitrate-ssrf-test - (:require - [app.config :as cf] - [app.http.client :as http] - [app.nitrate :as nitrate] - [app.setup :as-alias setup] - [clojure.string :as str] - [clojure.test :as t] - [integrant.core :as ig] - [java-http-clj.core :as jhttp])) - -(def ^:private private-admin-uri "http://127.0.0.1:9090") - -(defn- mk-cfg - "Minimal nitrate cfg with a real HttpClient and nitrate client methods." - [] - (let [http-client (jhttp/build-client {}) - base {::http/client http-client - ::setup/shared-keys {:admin-console "test-shared-key"}}] - (assoc base ::nitrate/client (ig/init-key ::nitrate/client base)))) - -(defn- with-admin-console-uri - "Run `f` with :admin-console enabled and the given admin-console URI / allowlist." - [admin-uri allowed-hosts f] - (let [original-get cf/get] - (with-redefs [cf/flags #{:admin-console} - cf/get (fn [key & args] - (case key - :admin-console-uri admin-uri - :ssrf-allowed-hosts allowed-hosts - (apply original-get key args)))] - (f)))) - -(t/deftest nitrate-blocks-private-admin-console-uri - (let [sent? (atom false)] - (with-admin-console-uri - private-admin-uri - #{} - (fn [] - (with-redefs [jhttp/send (fn [_req _opts] - (reset! sent? true) - {:status 200 :body "{\"licenses\":true}"})] - (try - (nitrate/call (mk-cfg) :connectivity {}) - (t/is false "should have raised :nitrate-unavailable") - (catch Exception e - (t/is (= :nitrate-unavailable (:type (ex-data e)))) - (t/is (false? @sent?) - "SSRF must stop the request before it reaches the network")))))))) - -(t/deftest nitrate-proceeds-when-admin-console-host-allowlisted - (let [captured (atom nil)] - (with-admin-console-uri - private-admin-uri - #{"127.0.0.1"} - (fn [] - (with-redefs [jhttp/send (fn [req _opts] - (reset! captured req) - {:status 200 - :body "{\"licenses\":true}"})] - (let [result (nitrate/call (mk-cfg) :connectivity {})] - (t/is (= {:licenses true} result)) - (t/is (some? @captured)) - (t/is (str/starts-with? (str (:uri @captured)) private-admin-uri)))))))) From 32d313b0c804cfb14721ece5f546c6db7208d374 Mon Sep 17 00:00:00 2001 From: Andrey Antukh Date: Tue, 8 Sep 2026 15:40:15 +0000 Subject: [PATCH 11/16] :tada: Add planning and review agent commands Add the planning/review command suite for opencode and align the planner skill with it: - /make-a-plan (build agent): researches the session subject, drafts the plan with the planner skill, asks open questions, then saves the final plan to .opencode/plans/. - /review-plan: reviews a plan with the plan-review skill; on approval suggests /implement-plan, on request changes /make-a-plan. - /review-code: reviews a diff, PR, or code change with the code-review skill; suggests /make-a-plan for substantive findings. All commands run as the build agent with explicit read-only rules that expire when the command's work is done, so follow-up requests need no agent switching. The planner skill no longer writes the plan file on its own: it saves only when a command or the user explicitly instructs it, and it suggests /review-plan and /implement-plan as next steps. AI-assisted-by: omen-alpha --- .opencode/commands/make-a-plan.md | 82 +++++++++++++++++++++++++++++++ .opencode/commands/review-code.md | 60 ++++++++++++++++++++++ .opencode/commands/review-plan.md | 58 ++++++++++++++++++++++ .opencode/skills/planner/SKILL.md | 25 +++++----- 4 files changed, 214 insertions(+), 11 deletions(-) create mode 100644 .opencode/commands/make-a-plan.md create mode 100644 .opencode/commands/review-code.md create mode 100644 .opencode/commands/review-plan.md diff --git a/.opencode/commands/make-a-plan.md b/.opencode/commands/make-a-plan.md new file mode 100644 index 0000000000..bb4502baa4 --- /dev/null +++ b/.opencode/commands/make-a-plan.md @@ -0,0 +1,82 @@ +--- +description: Investigate the chosen task, produce an implementation plan, and save it +agent: build +--- + +Act as a senior software engineer: research the subject of this session in depth and +produce a well-grounded, actionable implementation plan. + +## Instructions + +1. **Produce the plan** with the `planner` skill. By default, research the + subject of this session and draft the plan yourself. If I ask for it (for + example, `delegated` in the arguments), delegate to the `general` subagent + instead — the delegate must also follow the `planner` skill and receive all + the relevant session context (a review, user feedback, and so on). +2. Before asking me to decide anything, explain the plan and every open question in + plain language. Assume I know only the high-level project goal, not the codebase, + architecture, implementation terms, or the problem this task solves. +3. Once all decisions are answered and the plan is final, save it verbatim to the + announced path under `.opencode/plans/` (create the directory if it does not + exist). This step is this command's explicit authorization to write the plan + file — the only write allowed here. If I later ask for changes, update the + saved file directly. +4. Present me with a clear, self-contained summary of the plan's most relevant points + only after all required decisions have been answered. Write it for someone who knows + only the project's high-level goal and may not know the plan's low-level context. + Explain necessary technical language in plain terms, include the problem being + solved and the proposed outcome, and do not assume that listing technical task names + is enough. + +### Hard rule — read-only while planning + +While this command runs, act read-only: research with read-only tools only. +Never edit source files, never run builds, tests, linters, or any command that +modifies state, and never commit. The single allowed write is the plan file in +step 3. This rule expires when I approve the plan or move on to another task; +then you act as a normal build agent again. + +When the plan contains open questions, do not show them as bare technical questions or +assume that I understand the technical language or technical words used in the plan. +For each question, first explain: + +- What part of the user problem the decision affects. +- The relevant concept from the beginning, with a small concrete example. +- What each available option would make the system do. +- The practical benefits, costs, risks, and user-visible consequences of each option. +- Which option the planner recommends and why. + +Only after that explanation, use the `question` tool to ask the decision with clear, +non-technical option labels. Put the recommended option first and mark it as +`(Recommended)`. Group related questions when their context is shared, but do not ask a +question whose meaning has not already been explained. + +If I say that I do not understand a question or its choices, do not treat my previous +answer as valid. Explain the concepts again from the high-level project goal, use a more +concrete example, explain the implications, and ask the question again with the +`question` tool. Repeat this until I can make an informed choice. If one answer creates +new design consequences or additional decisions, explain those consequences before +asking any new question. + +Distinguish clearly between requirements already fixed by the roadmap or existing +architecture and choices that actually require my input. Do not ask me to choose an +implementation detail when the plan can resolve it safely without changing the public +behavior. If there are no decisions that require my input, say so and present the +summary. + +IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it +after all possible questions have been answered.** The final summary must explain the +problem being solved, the proposed behavior, the main user-visible workflow, important +constraints and risks, what is deliberately out of scope, and the path where the plan +is saved. Never assume that a short list of task names is enough context. End +the final response by suggesting the next steps, in this order: + +1. `/review-plan` — to get a second opinion on the plan before executing it. +2. `/implement-plan` — to execute the plan from the current session context. + +These are suggestions, not a required pipeline — any instruction from me +overrides them (for example, asking you to implement the plan directly). + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/commands/review-code.md b/.opencode/commands/review-code.md new file mode 100644 index 0000000000..6a66872ab5 --- /dev/null +++ b/.opencode/commands/review-code.md @@ -0,0 +1,60 @@ +--- +description: Code review — review a diff, PR, or code change with the code-review skill (read-only while reviewing) +agent: build +--- + +Act as a senior software engineer and perform a thorough code review. + +## Instructions + +1. **Determine what is being reviewed** from the user context or arguments: a + working-tree diff, a commit range, a branch, a PR (number or URL), or + specific files. If the target is ambiguous, ask before reviewing. +2. Delegate the review to the `general` subagent (via the task tool), unless the + user specifies another agent. Include in the prompt the **`code-review`** + skill name and all user context. +3. When the subagent returns, output the review to the user verbatim. Do not + summarize it and do not act on its findings. +4. Right after the review, suggest how to proceed based on the findings. These + are suggestions — the user decides: + - **Approve (no required changes):** say so — there is nothing to address. + - **Minor findings (nits):** applying them directly as-is is fine once the + review is done — no plan needed. + - **Substantive findings:** suggest `/make-a-plan` to make a plan to address + them. + +### Hard rule — read-only while reviewing + +This command is read-only **for the duration of the review**: from the moment it +starts until the user considers the review finished (including any feedback, +questions, or clarifications about it). During that period, never fix, +implement, edit files or create commits — not even "obvious" fixes derived from +the findings. Once the user explicitly states the review is done (or moves on to +a different task), this rule no longer applies and you act as a normal build +agent again. + +## Instructions for the subagent + +1. Load the **`code-review`** skill and follow its process and output format. +2. Read `AGENTS.md` (if present) and follow its instructions for finding and + reading all related testing documentation from memories before reviewing. +3. Return in your final message the COMPLETE review, verbatim, exactly as the + skill instructs it to be produced. Do not summarize it — include the full + structured review. + +### Strong rules for the subagent + +1. Do not invent problems. Every finding must be real and actionable. +2. Read-only: do not modify any file and do not create a commit — this command + only reviews. +3. Be specific and constructive. "This could be better" is not helpful — explain + why and how. +4. Prioritize by impact. One structural issue outweighs ten nits. +5. Missing tests are an issue, not a suggestion. Report as a severity-tagged + finding — never as a recommendation. +6. Skip generated files, lockfile-only changes, and unrelated modifications + unless they introduce security risks. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/commands/review-plan.md b/.opencode/commands/review-plan.md new file mode 100644 index 0000000000..c1d0bf4e63 --- /dev/null +++ b/.opencode/commands/review-plan.md @@ -0,0 +1,58 @@ +--- +description: Plan review — evaluate an implementation plan with the plan-review skill before executing it (read-only while reviewing) +agent: build +--- + +Act as a senior software engineer and perform a thorough review of an +implementation plan. + +## Instructions + +1. **Determine the plan under review** from the session context (for example, a + plan just produced by `/make-a-plan`) or from a plan file path given by the + user (typically under `.opencode/plans/`). If a file path is given, read the + file first so the complete plan is in context. +2. Delegate the review to the `general` subagent (via the task tool), unless the + user specifies another agent. Include in the prompt the **`plan-review`** + skill name and all user context. +3. When the subagent returns, output the review to the user verbatim. Do not + summarize it and do not act on its findings. +4. Right after the review, suggest the next step based on the verdict. These + are suggestions — the user decides, and any instruction overrides them: + - **Approve** → suggest `/implement-plan` to execute it. + - **Request changes** → suggest `/make-a-plan` to make a plan to address the + findings. + +### Hard rule — read-only while reviewing + +This command is read-only **for the duration of the review**: from the moment it +starts until the user considers the review finished (including any feedback, +questions, or clarifications about it). During that period, never fix, +implement, edit files or create commits — not even "obvious" fixes derived from +the findings. Once the user explicitly states the review is done (or moves on to +a different task), this rule no longer applies and you act as a normal build +agent again. + +## Instructions for the subagent + +1. Load the **`plan-review`** skill and follow its process and output format. +2. Read `AGENTS.md` (if present) and follow its instructions for finding and + reading all related documentation and testing memories before reviewing. +3. Return in your final message the COMPLETE review, verbatim, exactly as the + skill instructs it to be produced. Do not summarize it — include the full + structured review. + +### Strong rules for the subagent + +1. Do not invent problems. Every finding must be real and actionable. +2. Read-only: do not modify any file and do not create a commit — this command + only reviews. +3. Be specific and constructive. "This could be better" is not helpful — explain + why and how. +4. Prioritize by impact. One structural issue outweighs ten nits. +5. Judge the plan as the implementer would: every task executable without + guessing, ordering follows the dependency graph, risks named. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/skills/planner/SKILL.md b/.opencode/skills/planner/SKILL.md index 3598a0dc16..73cc26abc8 100644 --- a/.opencode/skills/planner/SKILL.md +++ b/.opencode/skills/planner/SKILL.md @@ -1,6 +1,6 @@ --- name: planner -description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user and save to .opencode/plans/YYYY-MM-DD-.md. +description: Read-only planning and architecture analysis for Penpot — produce a structured implementation plan with task breakdown, acceptance criteria, sizing, and checkpoints. Always output to the user with the plan's save path (saved or suggested) and the next steps. --- # Planner @@ -215,9 +215,9 @@ Add explicit checkpoints with the relevant module commands: ## Constraints -- You are **analysis-only** — never create, edit, or delete source code. -- The only file write you may attempt is the plan itself, saved to - `.opencode/plans/`. +- You are **analysis-only** — never create, edit, or delete source code. The + only file you may write is the plan itself, and only when the command or + user explicitly instructs you to save it. - You do **not** run builds, tests, linters, or any commands that modify state. - You do **not** create git commits or interact with version control. - You do **not** execute shell commands beyond read-only searches (`rg`, `ls`, @@ -228,9 +228,11 @@ Add explicit checkpoints with the relevant module commands: ## Output Format The plan is always delivered in the response so the user sees it regardless -of which agent is running the skill. +of which agent is running the skill. By default you never write the plan file; +announce the path instead. Write the file only when the command or user +explicitly instructs you to save it — and then only that file. -Additionally, save the plan to: +Announce the suggested save path: ``` .opencode/plans/YYYY-MM-DD-<plan-one-line-title>.md @@ -238,12 +240,11 @@ Additionally, save the plan to: Use today's date in the user's local timezone. The `<plan-one-line-title>` slug is lowercase, hyphen-separated, and a short summary of the task -(e.g. `add-batch-get-profiles-for-file-comments`). Create the -`.opencode/plans/` directory if it does not exist. +(e.g. `add-batch-get-profiles-for-file-comments`). If the user explicitly +provides a target file path, announce that path instead of the default. -IMPORTANT: The plan agent has write permission specifically for -`.opencode/plans/` — always attempt the write. If the user explicitly provides -a target file path, use that path instead of the default. +End the response by suggesting the next steps: `/review-plan` to get a second +opinion on the plan and `/implement-plan` to execute it. ### Plan Document Template @@ -374,4 +375,6 @@ Before delivering the plan, confirm: - [ ] Task dependencies are identified and ordered correctly - [ ] No task is XL or larger — break it down instead - [ ] Checkpoints exist after every 2-3 tasks +- [ ] The response states the plan's path (saved or suggested) and suggests + `/review-plan` and `/implement-plan` - [ ] The plan is ready for human review From f91ea6efc4ffdfb403c22307bfe50d8e812725c1 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 8 Sep 2026 16:39:31 +0000 Subject: [PATCH 12/16] :sparkles: Make implement-plan flow-aware and add open-pr command /implement-plan now detects the flow from the current branch instead of always creating an issue: on a base branch it starts standalone (issue + branch issue-NNNN from HEAD); on a feature branch it continues on it with no new scaffolding. Arguments override detection (standalone, continue, no issue, from origin/<base>); "no issue" on a base branch creates a plan-<slug> branch. Execution is direct and the closing suggests /review-code or /open-pr. /open-pr opens the PR for the current task branch: it detects the base with scripts/detect-target-branch (canonical: develop, staging, main), validates commits, issue and remote state, and stops with one message listing everything missing. It never pushes. AI-assisted-by: omen-alpha --- .opencode/commands/implement-plan.md | 80 +++++++++++++++++++++------- .opencode/commands/open-pr.md | 60 +++++++++++++++++++++ 2 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 .opencode/commands/open-pr.md diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md index 8ecd1bd537..c25686b722 100644 --- a/.opencode/commands/implement-plan.md +++ b/.opencode/commands/implement-plan.md @@ -1,39 +1,79 @@ --- -description: Execute a ready plan end-to-end — create a GitHub issue, branch issue-NNNN, implement the plan, then commit via the create-commit skill +description: Execute a ready plan — create issue + branch when on a base branch, or continue on the current branch; implement and commit agent: build --- This command is run once a plan is ready (for example, from plan mode). Execute -the plan already prepared in the current session context. Follow these steps in order. +the plan already prepared in the current session context. This command ends +with exactly one commit. It never pushes — the user pushes. -## 1. Create the issue +## 1. Detect the flow (no questions) -Use the **`create-issue`** skill, following the *Creating Issues from Draft Body* -flow in `mem:workflow/creating-issues`. Derive the issue title and body from the -plan. Capture the new issue's number — call it **NNNN** (needed for the branch -name and the commit reference). +Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the +mode, and announce it in one line before acting. -## 2. Create the branch +- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**: + create the issue and the branch, then implement and commit. +- **On any other branch** (a feature branch, typically `issue-NNNN`) → + **continue mode**: implement on the current branch and commit. No issue or + branch is created. -Create and switch to a branch named after the issue: +Arguments override detection: `standalone`, `continue`, +`no issue` / `without issue`, or an explicit base such as +`from origin/develop`. -``` -git checkout -b issue-NNNN -``` +### Standalone mode -(Replace NNNN with the issue number from step 1.) +1. Create the issue with the **`create-issue`** skill, following the + *Creating Issues from Draft Body* flow in `mem:workflow/creating-issues`. + Derive the issue title and body from the plan. Capture the new issue's + number — call it **NNNN** (needed for the branch name and the commit + reference). +2. Create the branch from the current HEAD: -## 3. Execute the plan + ``` + git checkout -b issue-NNNN + ``` -Implement the prepared plan from the session context. Work methodically, keeping -changes focused on what the issue requires. Do not commit — the commit happens in -step 4. +3. If the arguments say `no issue` / `without issue`, skip the issue and + create a branch named `plan-<slug>` instead, where `<slug>` is the plan + title, lowercase and hyphen-separated. -## 4. Commit with the create-commit skill +**Standalone while already on a feature branch:** stop and explain that this +would stack branches. Ask the user to re-run with an explicit base, for +example `from origin/develop` — then branch from that base instead of HEAD. + +### Continue mode + +No issue and no branch. Implement on the current branch. The branch name +provides the issue reference when it follows the `issue-NNNN` pattern. + +## 2. Execute the plan + +Implement the prepared plan from the session context. Work methodically, +keeping changes focused on what the issue requires. Respect the plan's +proposed parallelization when it applies. Do not commit — the commit happens +in the next step. + +## 3. Commit with the create-commit skill After the implementation is complete, load the **`create-commit`** skill and follow its workflow to commit the changes. Provide a brief summary of what was -implemented and why, the issue reference (`issue-NNNN`), and the model name you -are running as so the `AI-assisted-by` trailer is set correctly. +implemented and why, the issue reference (`issue-NNNN`) when there is one, and +the model name you are running as so the `AI-assisted-by` trailer is set +correctly. Do not push. Pushing is handled separately by the user. + +## When you are done + +End by suggesting the next steps (suggestions, not a required pipeline — any +instruction from me overrides them): + +- `/review-code` — to review the changes just committed; it routes to + `/make-a-plan` by itself if the findings need one. +- `/open-pr` — when the task is done and the branch is ready to merge. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/commands/open-pr.md b/.opencode/commands/open-pr.md new file mode 100644 index 0000000000..a91d2e109a --- /dev/null +++ b/.opencode/commands/open-pr.md @@ -0,0 +1,60 @@ +--- +description: Open the PR for the current task branch — detects the base branch, requires a clear issue, never pushes +agent: build +--- + +Open the pull request for the current task branch. Gather information, +validate, and create the PR in one pass. If validation fails, STOP with a +single coherent message that lists every problem and states exactly what +information is missing — never fix or work around problems silently. + +## 1. Gather context (read-only) + +- Current branch: `git rev-parse --abbrev-ref HEAD`. +- Target base branch: run `./scripts/detect-target-branch` from the repo root. + It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1). +- Commits: `git log --oneline <base>..HEAD`. +- Remote state: `git ls-remote origin <branch>`. +- Issue: from the session context, or from the branch name — `issue-NNNN` + maps to issue NNNN; recover its title and body with `gh issue view NNNN`. + +## 2. Validate — stop with one message if anything fails + +Run all checks before reporting, then report every failure together: + +1. **Base branch not usable.** If the script fails (exit 1) or its output is + not one of the canonical branches (`develop`, `staging`, `main`), stop and + ask the user to re-run with more context — for example, passing the base + branch explicitly in the arguments. An explicit base given in the + arguments overrides the script's output. +2. **On a base branch.** There is no task branch to merge — say so and stop. +3. **No commits.** The branch has no commits ahead of the base — say so and + stop. +4. **No clear issue.** There is no issue in the session context, and the + branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing) + — say so and stop. Exception: the arguments say `no issue` / + `without issue` — then continue without an issue reference. +5. **Branch not pushed.** `git ls-remote origin <branch>` finds nothing — + never push yourself; ask the user to push and to re-run `/open-pr` + afterwards, then stop. + +## 3. Already-open PR + +Check whether a PR already exists for this branch (`gh pr list --head +<branch>`). If one exists, report its URL and stop — do not create a second +one. + +## 4. Create the PR + +Load the **`create-pr`** skill and follow its workflow +(`mem:workflow/creating-prs` has the title format and body structure). Derive +the title and body from the commits and, when there is one, from the issue +body. Reference the issue with `Closes #NNNN`. + +## 5. Report + +Report the PR URL and stop. + +## User input, overrides and additional context + +$ARGUMENTS From 99e6d4f1ad57920631f010a12e96fd22cbfb0e49 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 8 Sep 2026 16:39:35 +0000 Subject: [PATCH 13/16] :books: Forbid hand-editing CHANGES.md in agent guides Adds the hard rule to AGENTS.md and to mem:critical-info: CHANGES.md is generated from GitHub milestones during the release process; it must be updated only via the update-changelog skill flow or on explicit user request. AI-assisted-by: omen-alpha --- .serena/memories/critical-info.md | 1 + AGENTS.md | 3 +++ 2 files changed, 4 insertions(+) diff --git a/.serena/memories/critical-info.md b/.serena/memories/critical-info.md index 117e8ff464..9033a9e74a 100644 --- a/.serena/memories/critical-info.md +++ b/.serena/memories/critical-info.md @@ -15,6 +15,7 @@ You are working on the GitHub project `penpot/penpot`, a monorepo. - Before `gh issue create` → `mem:workflow/creating-issues` (title derivation, body template, labels, Issue Type) - Before `gh pr create` / `gh pr edit` → `mem:workflow/creating-prs` (title format, body structure, "Note:" line) - **Never `git push`, force-push, or modify `git origin`** (or any other remote). The user pushes from their own shell; if a push is required, say so and wait. Never amend a commit that the user has already pushed unless explicitly asked. +- **Never edit `CHANGES.md` by hand.** The changelog is generated from GitHub milestones during the release process; update it only via the `update-changelog` skill flow or on explicit user request. - You have access to the GitHub CLI `gh` or corresponding MCP tools. - Issues are also managed on Taiga. Read issues using the `read_taiga_issue` tool. - Before writing code, analyze the task in depth and describe your plan. If the task is complex, break it down into atomic steps. diff --git a/AGENTS.md b/AGENTS.md index 06507f18d1..a02706f460 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,9 @@ wait for the user to push. Do not change the remote URL, do not switch SSH↔HTTPS. - **Never amend a commit that has been pushed** unless the user explicitly asks. If the user pushes, treat that commit as final from the agent's side. +- **Never edit `CHANGES.md` by hand** in commits or PRs. The changelog is + generated from GitHub milestones during the release process; update it only + via the `update-changelog` skill flow or on explicit user request. - **Never pipe test output directly to filters** (`| head`, `| tail`, `| grep`, etc.). Always redirect to a file first: `command > /tmp/output.txt 2>&1`, then read/grep the file. This prevents hiding test failures. See `mem:testing` for details. From 3d1393e8fc59626d4f4ac6685130d9a373c87ca9 Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 8 Sep 2026 18:00:05 +0000 Subject: [PATCH 14/16] :recycle: Make commands thin dispatchers over canonical skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every command in .opencode/commands now only switches to the build agent, injects the user context, and delegates to a same-named skill that holds the full flow logic — skills are the primary mechanism for Claude Code and Codex compatibility. - make-a-plan, implement-plan, resolve-git-conflicts: flow logic moved to same-named skills. - review-plan / review-code: orchestration skills delegating to the renamed criteria skills plan-review-criteria and code-review-criteria, with redirecting descriptions so there is no trigger overlap between flow and criteria. - create-pr: absorbed the open-pr flow as Open mode (validated branch-to-PR pipeline using scripts/detect-target-branch) plus a new Update mode (gh pr edit); /open-pr removed. AI-assisted-by: omen-alpha --- .opencode/commands/create-pr.md | 10 ++ .opencode/commands/implement-plan.md | 73 +------------ .opencode/commands/make-a-plan.md | 76 +------------ .opencode/commands/open-pr.md | 60 ----------- .opencode/commands/resolve-git-conflicts.md | 38 +------ .opencode/commands/review-code.md | 54 +--------- .opencode/commands/review-plan.md | 52 +-------- .../SKILL.md | 6 +- .opencode/skills/create-pr/SKILL.md | 102 ++++++++++++++---- .opencode/skills/implement-plan/SKILL.md | 84 +++++++++++++++ .opencode/skills/make-a-plan/SKILL.md | 90 ++++++++++++++++ .../SKILL.md | 20 ++-- .../skills/resolve-git-conflicts/SKILL.md | 40 +++++++ .opencode/skills/review-code/SKILL.md | 65 +++++++++++ .opencode/skills/review-plan/SKILL.md | 63 +++++++++++ 15 files changed, 459 insertions(+), 374 deletions(-) create mode 100644 .opencode/commands/create-pr.md delete mode 100644 .opencode/commands/open-pr.md rename .opencode/skills/{code-review => code-review-criteria}/SKILL.md (97%) create mode 100644 .opencode/skills/implement-plan/SKILL.md create mode 100644 .opencode/skills/make-a-plan/SKILL.md rename .opencode/skills/{plan-review => plan-review-criteria}/SKILL.md (94%) create mode 100644 .opencode/skills/resolve-git-conflicts/SKILL.md create mode 100644 .opencode/skills/review-code/SKILL.md create mode 100644 .opencode/skills/review-plan/SKILL.md diff --git a/.opencode/commands/create-pr.md b/.opencode/commands/create-pr.md new file mode 100644 index 0000000000..a6c21f7bb2 --- /dev/null +++ b/.opencode/commands/create-pr.md @@ -0,0 +1,10 @@ +--- +description: Create a PR for the current task branch or update an existing one — loads and follows the create-pr skill +agent: build +--- + +Load the **`create-pr`** skill and follow it as your only instruction. + +## User input, overrides and additional context + +$ARGUMENTS diff --git a/.opencode/commands/implement-plan.md b/.opencode/commands/implement-plan.md index c25686b722..5d5f902d51 100644 --- a/.opencode/commands/implement-plan.md +++ b/.opencode/commands/implement-plan.md @@ -1,78 +1,9 @@ --- -description: Execute a ready plan — create issue + branch when on a base branch, or continue on the current branch; implement and commit +description: Execute a ready plan — create issue + branch when on a base branch, or continue on the current branch; implement and commit — loads and follows the implement-plan skill agent: build --- -This command is run once a plan is ready (for example, from plan mode). Execute -the plan already prepared in the current session context. This command ends -with exactly one commit. It never pushes — the user pushes. - -## 1. Detect the flow (no questions) - -Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the -mode, and announce it in one line before acting. - -- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**: - create the issue and the branch, then implement and commit. -- **On any other branch** (a feature branch, typically `issue-NNNN`) → - **continue mode**: implement on the current branch and commit. No issue or - branch is created. - -Arguments override detection: `standalone`, `continue`, -`no issue` / `without issue`, or an explicit base such as -`from origin/develop`. - -### Standalone mode - -1. Create the issue with the **`create-issue`** skill, following the - *Creating Issues from Draft Body* flow in `mem:workflow/creating-issues`. - Derive the issue title and body from the plan. Capture the new issue's - number — call it **NNNN** (needed for the branch name and the commit - reference). -2. Create the branch from the current HEAD: - - ``` - git checkout -b issue-NNNN - ``` - -3. If the arguments say `no issue` / `without issue`, skip the issue and - create a branch named `plan-<slug>` instead, where `<slug>` is the plan - title, lowercase and hyphen-separated. - -**Standalone while already on a feature branch:** stop and explain that this -would stack branches. Ask the user to re-run with an explicit base, for -example `from origin/develop` — then branch from that base instead of HEAD. - -### Continue mode - -No issue and no branch. Implement on the current branch. The branch name -provides the issue reference when it follows the `issue-NNNN` pattern. - -## 2. Execute the plan - -Implement the prepared plan from the session context. Work methodically, -keeping changes focused on what the issue requires. Respect the plan's -proposed parallelization when it applies. Do not commit — the commit happens -in the next step. - -## 3. Commit with the create-commit skill - -After the implementation is complete, load the **`create-commit`** skill and -follow its workflow to commit the changes. Provide a brief summary of what was -implemented and why, the issue reference (`issue-NNNN`) when there is one, and -the model name you are running as so the `AI-assisted-by` trailer is set -correctly. - -Do not push. Pushing is handled separately by the user. - -## When you are done - -End by suggesting the next steps (suggestions, not a required pipeline — any -instruction from me overrides them): - -- `/review-code` — to review the changes just committed; it routes to - `/make-a-plan` by itself if the findings need one. -- `/open-pr` — when the task is done and the branch is ready to merge. +Load the **`implement-plan`** skill and follow it as your only instruction. ## User input, overrides and additional context diff --git a/.opencode/commands/make-a-plan.md b/.opencode/commands/make-a-plan.md index bb4502baa4..27a2be5559 100644 --- a/.opencode/commands/make-a-plan.md +++ b/.opencode/commands/make-a-plan.md @@ -1,81 +1,9 @@ --- -description: Investigate the chosen task, produce an implementation plan, and save it +description: Investigate the chosen task, produce an implementation plan, and save it — loads and follows the make-a-plan skill agent: build --- -Act as a senior software engineer: research the subject of this session in depth and -produce a well-grounded, actionable implementation plan. - -## Instructions - -1. **Produce the plan** with the `planner` skill. By default, research the - subject of this session and draft the plan yourself. If I ask for it (for - example, `delegated` in the arguments), delegate to the `general` subagent - instead — the delegate must also follow the `planner` skill and receive all - the relevant session context (a review, user feedback, and so on). -2. Before asking me to decide anything, explain the plan and every open question in - plain language. Assume I know only the high-level project goal, not the codebase, - architecture, implementation terms, or the problem this task solves. -3. Once all decisions are answered and the plan is final, save it verbatim to the - announced path under `.opencode/plans/` (create the directory if it does not - exist). This step is this command's explicit authorization to write the plan - file — the only write allowed here. If I later ask for changes, update the - saved file directly. -4. Present me with a clear, self-contained summary of the plan's most relevant points - only after all required decisions have been answered. Write it for someone who knows - only the project's high-level goal and may not know the plan's low-level context. - Explain necessary technical language in plain terms, include the problem being - solved and the proposed outcome, and do not assume that listing technical task names - is enough. - -### Hard rule — read-only while planning - -While this command runs, act read-only: research with read-only tools only. -Never edit source files, never run builds, tests, linters, or any command that -modifies state, and never commit. The single allowed write is the plan file in -step 3. This rule expires when I approve the plan or move on to another task; -then you act as a normal build agent again. - -When the plan contains open questions, do not show them as bare technical questions or -assume that I understand the technical language or technical words used in the plan. -For each question, first explain: - -- What part of the user problem the decision affects. -- The relevant concept from the beginning, with a small concrete example. -- What each available option would make the system do. -- The practical benefits, costs, risks, and user-visible consequences of each option. -- Which option the planner recommends and why. - -Only after that explanation, use the `question` tool to ask the decision with clear, -non-technical option labels. Put the recommended option first and mark it as -`(Recommended)`. Group related questions when their context is shared, but do not ask a -question whose meaning has not already been explained. - -If I say that I do not understand a question or its choices, do not treat my previous -answer as valid. Explain the concepts again from the high-level project goal, use a more -concrete example, explain the implications, and ask the question again with the -`question` tool. Repeat this until I can make an informed choice. If one answer creates -new design consequences or additional decisions, explain those consequences before -asking any new question. - -Distinguish clearly between requirements already fixed by the roadmap or existing -architecture and choices that actually require my input. Do not ask me to choose an -implementation detail when the plan can resolve it safely without changing the public -behavior. If there are no decisions that require my input, say so and present the -summary. - -IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it -after all possible questions have been answered.** The final summary must explain the -problem being solved, the proposed behavior, the main user-visible workflow, important -constraints and risks, what is deliberately out of scope, and the path where the plan -is saved. Never assume that a short list of task names is enough context. End -the final response by suggesting the next steps, in this order: - -1. `/review-plan` — to get a second opinion on the plan before executing it. -2. `/implement-plan` — to execute the plan from the current session context. - -These are suggestions, not a required pipeline — any instruction from me -overrides them (for example, asking you to implement the plan directly). +Load the **`make-a-plan`** skill and follow it as your only instruction. ## User input, overrides and additional context diff --git a/.opencode/commands/open-pr.md b/.opencode/commands/open-pr.md deleted file mode 100644 index a91d2e109a..0000000000 --- a/.opencode/commands/open-pr.md +++ /dev/null @@ -1,60 +0,0 @@ ---- -description: Open the PR for the current task branch — detects the base branch, requires a clear issue, never pushes -agent: build ---- - -Open the pull request for the current task branch. Gather information, -validate, and create the PR in one pass. If validation fails, STOP with a -single coherent message that lists every problem and states exactly what -information is missing — never fix or work around problems silently. - -## 1. Gather context (read-only) - -- Current branch: `git rev-parse --abbrev-ref HEAD`. -- Target base branch: run `./scripts/detect-target-branch` from the repo root. - It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1). -- Commits: `git log --oneline <base>..HEAD`. -- Remote state: `git ls-remote origin <branch>`. -- Issue: from the session context, or from the branch name — `issue-NNNN` - maps to issue NNNN; recover its title and body with `gh issue view NNNN`. - -## 2. Validate — stop with one message if anything fails - -Run all checks before reporting, then report every failure together: - -1. **Base branch not usable.** If the script fails (exit 1) or its output is - not one of the canonical branches (`develop`, `staging`, `main`), stop and - ask the user to re-run with more context — for example, passing the base - branch explicitly in the arguments. An explicit base given in the - arguments overrides the script's output. -2. **On a base branch.** There is no task branch to merge — say so and stop. -3. **No commits.** The branch has no commits ahead of the base — say so and - stop. -4. **No clear issue.** There is no issue in the session context, and the - branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing) - — say so and stop. Exception: the arguments say `no issue` / - `without issue` — then continue without an issue reference. -5. **Branch not pushed.** `git ls-remote origin <branch>` finds nothing — - never push yourself; ask the user to push and to re-run `/open-pr` - afterwards, then stop. - -## 3. Already-open PR - -Check whether a PR already exists for this branch (`gh pr list --head -<branch>`). If one exists, report its URL and stop — do not create a second -one. - -## 4. Create the PR - -Load the **`create-pr`** skill and follow its workflow -(`mem:workflow/creating-prs` has the title format and body structure). Derive -the title and body from the commits and, when there is one, from the issue -body. Reference the issue with `Closes #NNNN`. - -## 5. Report - -Report the PR URL and stop. - -## User input, overrides and additional context - -$ARGUMENTS diff --git a/.opencode/commands/resolve-git-conflicts.md b/.opencode/commands/resolve-git-conflicts.md index 1b17ca0001..05b13d3a4b 100644 --- a/.opencode/commands/resolve-git-conflicts.md +++ b/.opencode/commands/resolve-git-conflicts.md @@ -1,40 +1,6 @@ --- -description: Resolve local git conflicts and stage the resolved files with git add — never continues the rebase +description: Resolve local git conflicts and stage the resolved files; never continues the rebase — loads and follows the resolve-git-conflicts skill agent: build --- -# Fix Git Conflicts - -Resolve conflicts in the local repository. The user handles finishing the -rebase themselves — you must **never** run `git rebase --continue`, -`git rebase --skip`, `git merge --continue`, or anything similar. - -## Phase 1 — Understand the problem (read-only) - -1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files. -2. For each conflicted (unmerged) file, understand the situation **without modifying anything**: - - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). - - Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent. - - Identify what each side changed and why, and how they should be combined. - -## Phase 2 — Present the resolution plan - -3. **Present a clear plan to the user before touching any file.** For each conflicted file, state: - - What each side changed and why. - - Your proposed resolution and the reasoning behind it. - - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context). -4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly. -5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything. - -## Phase 3 — Execute - -6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers. - -## Phase 4 — Stage and verify - -7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution. -8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths. - -## Phase 5 — Report - -9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command. +Load the **`resolve-git-conflicts`** skill and follow it as your only instruction. diff --git a/.opencode/commands/review-code.md b/.opencode/commands/review-code.md index 6a66872ab5..4bea9ab7ff 100644 --- a/.opencode/commands/review-code.md +++ b/.opencode/commands/review-code.md @@ -1,59 +1,9 @@ --- -description: Code review — review a diff, PR, or code change with the code-review skill (read-only while reviewing) +description: Code review — review a diff, PR, or code change — loads and follows the review-code skill agent: build --- -Act as a senior software engineer and perform a thorough code review. - -## Instructions - -1. **Determine what is being reviewed** from the user context or arguments: a - working-tree diff, a commit range, a branch, a PR (number or URL), or - specific files. If the target is ambiguous, ask before reviewing. -2. Delegate the review to the `general` subagent (via the task tool), unless the - user specifies another agent. Include in the prompt the **`code-review`** - skill name and all user context. -3. When the subagent returns, output the review to the user verbatim. Do not - summarize it and do not act on its findings. -4. Right after the review, suggest how to proceed based on the findings. These - are suggestions — the user decides: - - **Approve (no required changes):** say so — there is nothing to address. - - **Minor findings (nits):** applying them directly as-is is fine once the - review is done — no plan needed. - - **Substantive findings:** suggest `/make-a-plan` to make a plan to address - them. - -### Hard rule — read-only while reviewing - -This command is read-only **for the duration of the review**: from the moment it -starts until the user considers the review finished (including any feedback, -questions, or clarifications about it). During that period, never fix, -implement, edit files or create commits — not even "obvious" fixes derived from -the findings. Once the user explicitly states the review is done (or moves on to -a different task), this rule no longer applies and you act as a normal build -agent again. - -## Instructions for the subagent - -1. Load the **`code-review`** skill and follow its process and output format. -2. Read `AGENTS.md` (if present) and follow its instructions for finding and - reading all related testing documentation from memories before reviewing. -3. Return in your final message the COMPLETE review, verbatim, exactly as the - skill instructs it to be produced. Do not summarize it — include the full - structured review. - -### Strong rules for the subagent - -1. Do not invent problems. Every finding must be real and actionable. -2. Read-only: do not modify any file and do not create a commit — this command - only reviews. -3. Be specific and constructive. "This could be better" is not helpful — explain - why and how. -4. Prioritize by impact. One structural issue outweighs ten nits. -5. Missing tests are an issue, not a suggestion. Report as a severity-tagged - finding — never as a recommendation. -6. Skip generated files, lockfile-only changes, and unrelated modifications - unless they introduce security risks. +Load the **`review-code`** skill and follow it as your only instruction. ## User input, overrides and additional context diff --git a/.opencode/commands/review-plan.md b/.opencode/commands/review-plan.md index c1d0bf4e63..90bc6161a9 100644 --- a/.opencode/commands/review-plan.md +++ b/.opencode/commands/review-plan.md @@ -1,57 +1,9 @@ --- -description: Plan review — evaluate an implementation plan with the plan-review skill before executing it (read-only while reviewing) +description: Plan review — evaluate an implementation plan before executing it — loads and follows the review-plan skill agent: build --- -Act as a senior software engineer and perform a thorough review of an -implementation plan. - -## Instructions - -1. **Determine the plan under review** from the session context (for example, a - plan just produced by `/make-a-plan`) or from a plan file path given by the - user (typically under `.opencode/plans/`). If a file path is given, read the - file first so the complete plan is in context. -2. Delegate the review to the `general` subagent (via the task tool), unless the - user specifies another agent. Include in the prompt the **`plan-review`** - skill name and all user context. -3. When the subagent returns, output the review to the user verbatim. Do not - summarize it and do not act on its findings. -4. Right after the review, suggest the next step based on the verdict. These - are suggestions — the user decides, and any instruction overrides them: - - **Approve** → suggest `/implement-plan` to execute it. - - **Request changes** → suggest `/make-a-plan` to make a plan to address the - findings. - -### Hard rule — read-only while reviewing - -This command is read-only **for the duration of the review**: from the moment it -starts until the user considers the review finished (including any feedback, -questions, or clarifications about it). During that period, never fix, -implement, edit files or create commits — not even "obvious" fixes derived from -the findings. Once the user explicitly states the review is done (or moves on to -a different task), this rule no longer applies and you act as a normal build -agent again. - -## Instructions for the subagent - -1. Load the **`plan-review`** skill and follow its process and output format. -2. Read `AGENTS.md` (if present) and follow its instructions for finding and - reading all related documentation and testing memories before reviewing. -3. Return in your final message the COMPLETE review, verbatim, exactly as the - skill instructs it to be produced. Do not summarize it — include the full - structured review. - -### Strong rules for the subagent - -1. Do not invent problems. Every finding must be real and actionable. -2. Read-only: do not modify any file and do not create a commit — this command - only reviews. -3. Be specific and constructive. "This could be better" is not helpful — explain - why and how. -4. Prioritize by impact. One structural issue outweighs ten nits. -5. Judge the plan as the implementer would: every task executable without - guessing, ordering follows the dependency graph, risks named. +Load the **`review-plan`** skill and follow it as your only instruction. ## User input, overrides and additional context diff --git a/.opencode/skills/code-review/SKILL.md b/.opencode/skills/code-review-criteria/SKILL.md similarity index 97% rename from .opencode/skills/code-review/SKILL.md rename to .opencode/skills/code-review-criteria/SKILL.md index 7fa581efa0..8e7a75aa97 100644 --- a/.opencode/skills/code-review/SKILL.md +++ b/.opencode/skills/code-review-criteria/SKILL.md @@ -1,9 +1,9 @@ --- -name: code-review -description: Conducts multi-axis code review. Use before merging any change. Use when reviewing code written by yourself, another agent, or a human. Use when you need to assess code quality across multiple dimensions before it enters the main branch. +name: code-review-criteria +description: Code review criteria — the five review axes, core principles, severity format, and verdict for reviewing code changes. Loaded by the reviewer subagent of the review-code flow. Not a user-facing flow — to review code, use the review-code flow. --- -# Code Review and Quality +# Code Review Criteria and Quality ## Overview diff --git a/.opencode/skills/create-pr/SKILL.md b/.opencode/skills/create-pr/SKILL.md index 4980048849..703609c9a1 100644 --- a/.opencode/skills/create-pr/SKILL.md +++ b/.opencode/skills/create-pr/SKILL.md @@ -1,39 +1,105 @@ --- name: create-pr -description: Create or update a GitHub PR following Penpot conventions. +description: PR flow — open a new PR for the current task branch (validates base branch, commits, issue and push state) or update an existing PR's title or description to match Penpot conventions. Use it when the user asks to open or create a PR, in any phrasing. --- -# Skill: create-pr +# Create PR -Create or update a GitHub PR. Read and follow: -- `mem:workflow/creating-prs` — title format, description structure, writing principles -- `mem:workflow/creating-commits` — commit type emojis +Two modes. **Open mode** takes the current task branch to a new, validated +PR. **Update mode** rewrites an existing PR's title or description. Gather +information, validate, and act in one pass. If validation fails, STOP with a +single coherent message that lists every problem and states exactly what +information is missing — never fix or work around problems silently. -## When to Use +Both modes require an authenticated `gh` CLI (`gh auth status`) and never +push — the user pushes from their own shell. -- Creating a new PR from a feature branch -- Updating an existing PR's title or description to match conventions +## When to use -## Prerequisites +- The user asks to open or create a NEW PR for the current task branch, in + any phrasing ("open a PR", "create the pull request", "put this up for + review") — or runs `/create-pr`. → **Open mode**. +- The user asks to fix or update an EXISTING PR's title or description to + match conventions. → **Update mode**. -- `gh` CLI authenticated (`gh auth status`) +If the running agent cannot write (for example, the plan agent), say so and +stop — this skill needs the build agent. -## Commands +## Open mode -**Create:** +### 1. Gather context (read-only) + +- Current branch: `git rev-parse --abbrev-ref HEAD`. +- Target base branch: run `./scripts/detect-target-branch` from the repo root. + It prints the nearest ancestor branch of HEAD (exit 0) or fails (exit 1). +- Commits: `git log --oneline <base>..HEAD`. +- Push state (local): `git rev-parse --verify origin/<branch>` and compare + with HEAD. It reads the local remote-tracking ref — no network, no SSH. It + reflects the last push or fetch this clone knows about. +- Issue: from the session context, or from the branch name — `issue-NNNN` + maps to issue NNNN; recover its title and body with `gh issue view NNNN`. + +### 2. Validate — stop with one message if anything fails + +Run all checks before reporting, then report every failure together: + +1. **Base branch not usable.** If the script fails (exit 1), or its output — + after stripping an optional `remotes/origin/` prefix — is not one of the + canonical branches (`develop`, `staging`, `main`), stop and ask the user + to re-run with more context — for example, passing the base branch + explicitly in their invocation. An explicit base given by the user + overrides the script's output. +2. **On a base branch.** There is no task branch to merge — say so and stop. +3. **No commits.** The branch has no commits ahead of the base — say so and + stop. +4. **No clear issue.** There is no issue in the session context, and the + branch name has no `issue-NNNN` pattern (or `gh issue view` finds nothing) + — say so and stop. Exception: the user's invocation says `no issue` / + `without issue` — then continue without an issue reference. +5. **Branch not pushed.** The remote-tracking ref `origin/<branch>` is + missing, or `git rev-parse origin/<branch>` differs from HEAD — the + branch was never pushed, or has commits the remote does not have. Never + push yourself; ask the user to push and to run `/create-pr` again + afterwards, then stop. + +### 3. Already-open PR + +Check whether a PR already exists for this branch (`gh pr list --head +<branch>`). If one exists, report its URL and stop — do not create a second +one. Title or description fixes belong to Update mode. + +### 4. Write and create the PR + +Write the title and body following `mem:workflow/creating-prs` (title format, +description structure, writing principles) and `mem:workflow/creating-commits` +(commit type emojis). Derive the title and body from the commits and, when +there is one, from the issue body. Reference the issue with `Closes #NNNN`. ```bash gh pr create --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md ``` -**Update:** +### 5. Report + +Report the PR URL and stop. + +## Update mode + +1. Identify the PR: the number given by the user, or `gh pr list --head + <branch>`. +2. Write the new title and/or body following `mem:workflow/creating-prs`. +3. Apply and verify: ```bash gh pr edit <NUMBER> --repo penpot/penpot --title "<TITLE>" --body-file /tmp/pr-body.md -``` - -**Verify:** - -```bash gh pr view <NUMBER> --repo penpot/penpot --json title,body ``` + +4. Report and stop. + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: overrides such as `no issue` / +`without issue`, an explicit base branch (`from origin/staging`), a PR number +for Update mode, and so on. diff --git a/.opencode/skills/implement-plan/SKILL.md b/.opencode/skills/implement-plan/SKILL.md new file mode 100644 index 0000000000..2f8cb43888 --- /dev/null +++ b/.opencode/skills/implement-plan/SKILL.md @@ -0,0 +1,84 @@ +--- +name: implement-plan +description: Implementation flow — execute a ready plan from the session context: detect the flow (new issue + branch when on a base branch, or continue on the current branch), implement, and commit. Use it when the user asks to implement or execute a plan, in any phrasing. +--- + +# Implement Plan + +This flow is run once a plan is ready (for example, from plan mode). Execute +the plan already prepared in the current session context. This flow ends +with exactly one commit. It never pushes — the user pushes. + +## 1. Detect the flow (no questions) + +Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the +mode, and announce it in one line before acting. + +- **On a base branch** (`main`, `develop`, `staging`) → **standalone mode**: + create the issue and the branch, then implement and commit. +- **On any other branch** (a feature branch, typically `issue-NNNN`) → + **continue mode**: implement on the current branch and commit. No issue or + branch is created. + +Arguments override detection: `standalone`, `continue`, +`no issue` / `without issue`, or an explicit base such as +`from origin/develop`. + +### Standalone mode + +1. Create the issue with the **`create-issue`** skill, following the + *Creating Issues from Draft Body* flow in `mem:workflow/creating-issues`. + Derive the issue title and body from the plan. Capture the new issue's + number — call it **NNNN** (needed for the branch name and the commit + reference). +2. Create the branch from the current HEAD: + + ``` + git checkout -b issue-NNNN + ``` + +3. If the arguments say `no issue` / `without issue`, skip the issue and + create a branch named `plan-<slug>` instead, where `<slug>` is the plan + title, lowercase and hyphen-separated. + +**Standalone while already on a feature branch:** stop and explain that this +would stack branches. Ask the user to re-run with an explicit base, for +example `from origin/develop` — then branch from that base instead of HEAD. + +### Continue mode + +No issue and no branch. Implement on the current branch. The branch name +provides the issue reference when it follows the `issue-NNNN` pattern. + +## 2. Execute the plan + +Implement the prepared plan from the session context. Work methodically, +keeping changes focused on what the issue requires. Respect the plan's +proposed parallelization when it applies. Do not commit — the commit happens +in the next step. + +## 3. Commit with the create-commit skill + +After the implementation is complete, load the **`create-commit`** skill and +follow its workflow to commit the changes. Provide a brief summary of what was +implemented and why, the issue reference (`issue-NNNN`) when there is one, and +the model name you are running as so the `AI-assisted-by` trailer is set +correctly. + +Do not push. Pushing is handled separately by the user. + +## When you are done + +End by suggesting the next steps (suggestions, not a required pipeline — any +instruction from me overrides them): + +- `/review-code` — to review the changes just committed; it routes to + `/make-a-plan` by itself if the findings need one. +- `/create-pr` — when the task is done and the branch is ready to merge. + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: `standalone`, `continue`, +`no issue` / `without issue`, or an explicit base such as +`from origin/develop`. diff --git a/.opencode/skills/make-a-plan/SKILL.md b/.opencode/skills/make-a-plan/SKILL.md new file mode 100644 index 0000000000..4569a536ee --- /dev/null +++ b/.opencode/skills/make-a-plan/SKILL.md @@ -0,0 +1,90 @@ +--- +name: make-a-plan +description: Planning flow — research the subject of this session, produce an implementation plan with the planner skill, resolve open questions with the user in plain language, and save the final plan to .opencode/plans/. Use it when the user asks to plan, design, or break down a task, in any phrasing. +--- + +# Make a Plan + +Act as a senior software engineer: research the subject of this session in depth and +produce a well-grounded, actionable implementation plan. + +If the running agent cannot write (for example, the plan agent), say so and +stop — this skill needs the build agent to save the plan. + +## Instructions + +1. **Produce the plan** with the `planner` skill. By default, research the + subject of this session and draft the plan yourself. If I ask for it (for + example, `delegated` in the user context), delegate to the `general` subagent + instead — the delegate must also follow the `planner` skill and receive all + the relevant session context (a review, user feedback, and so on). +2. Before asking me to decide anything, explain the plan and every open question in + plain language. Assume I know only the high-level project goal, not the codebase, + architecture, implementation terms, or the problem this task solves. +3. Once all decisions are answered and the plan is final, save it verbatim to the + announced path under `.opencode/plans/` (create the directory if it does not + exist). This step is the flow's explicit authorization to write the plan + file — the only write allowed here. If I later ask for changes, update the + saved file directly. +4. Present me with a clear, self-contained summary of the plan's most relevant points + only after all required decisions have been answered. Write it for someone who knows + only the project's high-level goal and may not know the plan's low-level context. + Explain necessary technical language in plain terms, include the problem being + solved and the proposed outcome, and do not assume that listing technical task names + is enough. + +### Hard rule — read-only while planning + +While this flow runs, act read-only: research with read-only tools only. +Never edit source files, never run builds, tests, linters, or any command that +modifies state, and never commit. The single allowed write is the plan file in +step 3. This rule expires when I approve the plan or move on to another task; +then you act as a normal build agent again. + +When the plan contains open questions, do not show them as bare technical questions or +assume that I understand the technical language or technical words used in the plan. +For each question, first explain: + +- What part of the user problem the decision affects. +- The relevant concept from the beginning, with a small concrete example. +- What each available option would make the system do. +- The practical benefits, costs, risks, and user-visible consequences of each option. +- Which option the planner recommends and why. + +Only after that explanation, use the `question` tool to ask the decision with clear, +non-technical option labels. Put the recommended option first and mark it as +`(Recommended)`. Group related questions when their context is shared, but do not ask a +question whose meaning has not already been explained. + +If I say that I do not understand a question or its choices, do not treat my previous +answer as valid. Explain the concepts again from the high-level project goal, use a more +concrete example, explain the implications, and ask the question again with the +`question` tool. Repeat this until I can make an informed choice. If one answer creates +new design consequences or additional decisions, explain those consequences before +asking any new question. + +Distinguish clearly between requirements already fixed by the roadmap or existing +architecture and choices that actually require my input. Do not ask me to choose an +implementation detail when the plan can resolve it safely without changing the public +behavior. If there are no decisions that require my input, say so and present the +summary. + +IMPORTANT: **Under no circumstances execute the plan. Wait for the user to review it +after all possible questions have been answered.** The final summary must explain the +problem being solved, the proposed behavior, the main user-visible workflow, important +constraints and risks, what is deliberately out of scope, and the path where the plan +is saved. Never assume that a short list of task names is enough context. End +the final response by suggesting the next steps, in this order: + +1. `/review-plan` — to get a second opinion on the plan before executing it. +2. `/implement-plan` — to execute the plan from the current session context. + +These are suggestions, not a required pipeline — any instruction from me +overrides them (for example, asking you to implement the plan directly). + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: for example, `delegated` to +hand the research and drafting to the `general` subagent, or corrections and +feedback about a previous plan. diff --git a/.opencode/skills/plan-review/SKILL.md b/.opencode/skills/plan-review-criteria/SKILL.md similarity index 94% rename from .opencode/skills/plan-review/SKILL.md rename to .opencode/skills/plan-review-criteria/SKILL.md index 4386d701cc..649bd1118f 100644 --- a/.opencode/skills/plan-review/SKILL.md +++ b/.opencode/skills/plan-review-criteria/SKILL.md @@ -1,9 +1,9 @@ --- -name: plan-review -description: Reviews implementation plans for quality, completeness, and actionability. Use after a plan is produced by the planner skill, before starting implementation. Use when evaluating a plan written by yourself, another agent, or a human. +name: plan-review-criteria +description: Plan review criteria — the six review axes, severity rubric, approval standard, and output format for reviewing implementation plans. Loaded by the reviewer subagent of the review-plan flow. Not a user-facing flow — to review a plan, use the review-plan flow. --- -# Plan Review +# Plan Review Criteria ## Overview @@ -13,10 +13,10 @@ Multi-dimensional plan review with quality gates. Every plan gets reviewed befor ## When to Use -- After the planner skill produces a plan -- Before starting implementation on any non-trivial task -- When reviewing a plan written by another agent or a human -- When a plan feels too large, vague, or risky to start +- The reviewer subagent of the `review-plan` flow loads this skill to perform + the review of a plan. +- To review a plan, always go through the `review-plan` flow — never load this + skill directly for that. This is the criteria reference, not the flow. **Do NOT use for:** Single-file changes with obvious scope, or when the task is trivial enough to just do. @@ -87,7 +87,7 @@ Can an implementer actually execute this? ### 6. Proposed Code Quality *(when the plan includes implementation details)* -If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review` criteria: +If the plan proposes code shapes, function signatures, data structures, or API designs, evaluate those proposals against `code-review-criteria`: - **Correctness:** Do the proposed types/signatures handle edge cases (null, empty, boundaries)? - **Readability:** Are proposed names descriptive and consistent with project conventions? @@ -215,7 +215,7 @@ Check that the plan can actually confirm it worked: If the plan includes code snippets, types, or API designs: ``` -- Load code-review skill for criteria +- Load code-review-criteria skill for criteria - Check proposed signatures for edge cases - Verify naming follows project conventions - Confirm abstractions follow existing patterns @@ -310,6 +310,6 @@ If the plan includes code snippets, types, or API designs: ## See Also - For producing plans, use the `planner` skill -- For reviewing implemented code, use `code-review` — also the criteria source for axis 6 +- For reviewing implemented code, use `code-review-criteria` — also the criteria source for axis 6 - For security-specific concerns, see `security-and-hardening` - For testing strategy guidance, see `testing` diff --git a/.opencode/skills/resolve-git-conflicts/SKILL.md b/.opencode/skills/resolve-git-conflicts/SKILL.md new file mode 100644 index 0000000000..e2cf2cff2a --- /dev/null +++ b/.opencode/skills/resolve-git-conflicts/SKILL.md @@ -0,0 +1,40 @@ +--- +name: resolve-git-conflicts +description: Conflict resolution flow — understand the local git conflicts, present a resolution plan, and resolve them after the user approves it. Never continues the rebase. Use it when the repo has unresolved conflicts (rebase, merge, cherry-pick) or the user asks to resolve them. +--- + +# Resolve Git Conflicts + +Resolve conflicts in the local repository. The user handles finishing the +rebase themselves — you must **never** run `git rebase --continue`, +`git rebase --skip`, `git merge --continue`, or anything similar. + +## Phase 1 — Understand the problem (read-only) + +1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files. +2. For each conflicted (unmerged) file, understand the situation **without modifying anything**: + - Read the file and identify the conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`). + - Inspect both sides — `git show <ours>:<file>` and `git show <theirs>:<file>` — plus `git log`/`git show` on the commits involved to understand intent. + - Identify what each side changed and why, and how they should be combined. + +## Phase 2 — Present the resolution plan + +3. **Present a clear plan to the user before touching any file.** For each conflicted file, state: + - What each side changed and why. + - Your proposed resolution and the reasoning behind it. + - How the two sides are combined (both additive → merge; both modify the same code → keep the semantically correct version, merging intent from both sides when clear from code and context). +4. **Ask the user only when genuinely unclear.** Do not ask about anything you can determine yourself from the code, commit messages, or context. Only decisions that are not determinable and change the outcome (e.g. conflicting product decisions, which side to discard) warrant a question. **Collect all such questions together in an "Open Questions" section at the end of the plan**, so the user has full context to answer them properly. +5. **Wait for the user to accept the plan** (and answer any open questions) before editing, staging, or otherwise modifying anything. + +## Phase 3 — Execute + +6. Resolve each conflicted file by editing the file to the agreed merged content and removing all conflict markers. + +## Phase 4 — Stage and verify + +7. **Stage every resolved file** with `git add <file>`. Do not stage unrelated untracked files unless clearly part of the resolution. +8. Verify no conflict markers remain (search for `<<<<<<<` / `>>>>>>>` in resolved files) and that `git status` shows no unmerged paths. + +## Phase 5 — Report + +9. Briefly report the conflict state, how each conflicted file was resolved (and any answers received to open questions), and stop — do **not** run `git rebase --continue` or any other continuation command. diff --git a/.opencode/skills/review-code/SKILL.md b/.opencode/skills/review-code/SKILL.md new file mode 100644 index 0000000000..6e4337dc7f --- /dev/null +++ b/.opencode/skills/review-code/SKILL.md @@ -0,0 +1,65 @@ +--- +name: review-code +description: Code review flow — review a diff, PR, or code change, delegating the review to a subagent that follows the code-review-criteria skill. Use it when the user asks to review code or a PR, in any phrasing. +--- + +# Review Code + +Act as a senior software engineer and perform a thorough code review. + +## Instructions + +1. **Determine what is being reviewed** from the user context: a working-tree + diff, a commit range, a branch, a PR (number or URL), or specific files. If + the target is ambiguous, ask before reviewing. +2. Delegate the review to the `general` subagent (via the task tool), unless the + user specifies another agent. Include in the prompt the + **`code-review-criteria`** skill name and all user context. +3. When the subagent returns, output the review to the user verbatim. Do not + summarize it and do not act on its findings. +4. Right after the review, suggest how to proceed based on the findings. These + are suggestions — the user decides: + - **Approve (no required changes):** say so — there is nothing to address. + - **Minor findings (nits):** applying them directly as-is is fine once the + review is done — no plan needed. + - **Substantive findings:** suggest `/make-a-plan` to make a plan to address + them. + +### Hard rule — read-only while reviewing + +This flow is read-only **for the duration of the review**: from the moment it +starts until the user considers the review finished (including any feedback, +questions, or clarifications about it). During that period, never fix, +implement, edit files or create commits — not even "obvious" fixes derived from +the findings. Once the user explicitly states the review is done (or moves on to +a different task), this rule no longer applies and you act as a normal build +agent again. + +## Instructions for the subagent + +1. Load the **`code-review-criteria`** skill and follow its process and output + format. +2. Read `AGENTS.md` (if present) and follow its instructions for finding and + reading all related testing documentation from memories before reviewing. +3. Return in your final message the COMPLETE review, verbatim, exactly as the + skill instructs it to be produced. Do not summarize it — include the full + structured review. + +### Strong rules for the subagent + +1. Do not invent problems. Every finding must be real and actionable. +2. Read-only: do not modify any file and do not create a commit — reviewing + never writes. +3. Be specific and constructive. "This could be better" is not helpful — explain + why and how. +4. Prioritize by impact. One structural issue outweighs ten nits. +5. Missing tests are an issue, not a suggestion. Report as a severity-tagged + finding — never as a recommendation. +6. Skip generated files, lockfile-only changes, and unrelated modifications + unless they introduce security risks. + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: for example, a PR number or +URL, a commit range, specific files, or a different agent to run the review. diff --git a/.opencode/skills/review-plan/SKILL.md b/.opencode/skills/review-plan/SKILL.md new file mode 100644 index 0000000000..2030ad2b74 --- /dev/null +++ b/.opencode/skills/review-plan/SKILL.md @@ -0,0 +1,63 @@ +--- +name: review-plan +description: Plan review flow — evaluate an implementation plan before it is executed, delegating the review to a subagent that follows the plan-review-criteria skill. Use it when the user asks to review a plan, in any phrasing. +--- + +# Review Plan + +Act as a senior software engineer and perform a thorough review of an +implementation plan. + +## Instructions + +1. **Determine the plan under review** from the session context (for example, a + plan just produced by `/make-a-plan`) or from a plan file path given by the + user (typically under `.opencode/plans/`). If a file path is given, read the + file first so the complete plan is in context. +2. Delegate the review to the `general` subagent (via the task tool), unless the + user specifies another agent. Include in the prompt the + **`plan-review-criteria`** skill name and all user context. +3. When the subagent returns, output the review to the user verbatim. Do not + summarize it and do not act on its findings. +4. Right after the review, suggest the next step based on the verdict. These + are suggestions — the user decides, and any instruction overrides them: + - **Approve** → suggest `/implement-plan` to execute it. + - **Request changes** → suggest `/make-a-plan` to make a plan to address the + findings. + +### Hard rule — read-only while reviewing + +This flow is read-only **for the duration of the review**: from the moment it +starts until the user considers the review finished (including any feedback, +questions, or clarifications about it). During that period, never fix, +implement, edit files or create commits — not even "obvious" fixes derived from +the findings. Once the user explicitly states the review is done (or moves on to +a different task), this rule no longer applies and you act as a normal build +agent again. + +## Instructions for the subagent + +1. Load the **`plan-review-criteria`** skill and follow its process and output + format. +2. Read `AGENTS.md` (if present) and follow its instructions for finding and + reading all related documentation and testing memories before reviewing. +3. Return in your final message the COMPLETE review, verbatim, exactly as the + skill instructs it to be produced. Do not summarize it — include the full + structured review. + +### Strong rules for the subagent + +1. Do not invent problems. Every finding must be real and actionable. +2. Read-only: do not modify any file and do not create a commit — reviewing + never writes. +3. Be specific and constructive. "This could be better" is not helpful — explain + why and how. +4. Prioritize by impact. One structural issue outweighs ten nits. +5. Judge the plan as the implementer would: every task executable without + guessing, ordering follows the dependency graph, risks named. + +## User context + +Extra context in the user's invocation (the message that triggered this skill) +plays the role command arguments play elsewhere: for example, a plan file path +to review, or a different agent to run the review. From b115b75d83b0dc1f986e943f96c8fb40a5cff0af Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 8 Sep 2026 18:04:15 +0000 Subject: [PATCH 15/16] :books: Add CLAUDE.md pointer and Claude Code skills symlink CLAUDE.md points Claude Code to AGENTS.md as the canonical project instruction file, and .claude/skills symlinks the opencode skills so both tools discover the same single source of truth. The root .gitignore keeps ignoring new files under those paths; the tracked entries are unaffected from now on. AI-assisted-by: omen-alpha --- .claude/skills | 1 + CLAUDE.md | 3 +++ 2 files changed, 4 insertions(+) create mode 120000 .claude/skills create mode 100644 CLAUDE.md diff --git a/.claude/skills b/.claude/skills new file mode 120000 index 0000000000..e5da19da9a --- /dev/null +++ b/.claude/skills @@ -0,0 +1 @@ +../.opencode/skills \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000..70bf134a48 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,3 @@ +Read and follow the instructions in `AGENTS.md`. + +Treat `AGENTS.md` as the canonical project instruction file. From 59c8a690da629a3cbee8eb9c1d4c436b00751e4d Mon Sep 17 00:00:00 2001 From: Andrey Antukh <niwi@niwi.nz> Date: Tue, 8 Sep 2026 19:12:01 +0000 Subject: [PATCH 16/16] :recycle: Add when to use sections to all skills Every skill in .opencode/skills now carries a "When to use" section: triggers in any phrasing plus the matching /command for the flow skills, one-line triggers for the utility skills, an explicit-invocation mirror for ste, and the proactive case for resolve-git-conflicts. code-review-criteria drops its old usage bullets ("before merging any PR ...") for the same role pattern as plan-review-criteria: loaded by the reviewer subagent of the review-code flow, redirect there to review code. Flow and criteria skills no longer compete for the same trigger. AI-assisted-by: omen-alpha --- .opencode/skills/bat-cat/SKILL.md | 5 +++++ .opencode/skills/code-review-criteria/SKILL.md | 9 ++++----- .opencode/skills/fd-find/SKILL.md | 5 +++++ .opencode/skills/implement-plan/SKILL.md | 10 ++++++++++ .opencode/skills/jq-json-processor/SKILL.md | 5 +++++ .opencode/skills/make-a-plan/SKILL.md | 10 ++++++++++ .opencode/skills/nrepl-eval/SKILL.md | 6 ++++++ .opencode/skills/resolve-git-conflicts/SKILL.md | 7 +++++++ .opencode/skills/review-code/SKILL.md | 8 ++++++++ .opencode/skills/review-plan/SKILL.md | 8 ++++++++ .opencode/skills/ripgrep/SKILL.md | 5 +++++ .opencode/skills/ste/SKILL.md | 7 +++++++ .opencode/skills/taiga/SKILL.md | 6 ++++++ 13 files changed, 86 insertions(+), 5 deletions(-) diff --git a/.opencode/skills/bat-cat/SKILL.md b/.opencode/skills/bat-cat/SKILL.md index 61ca8def8f..2d67404725 100644 --- a/.opencode/skills/bat-cat/SKILL.md +++ b/.opencode/skills/bat-cat/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🦇","requires":{"bins":["bat"]},"install":[{"i `cat` with syntax highlighting, line numbers, and Git integration. +## When to use + +- Reading or displaying a file in the terminal — prefer it over plain + `cat`: syntax highlighting, line numbers, git-side indicators. + ## Quick Start ### Basic usage diff --git a/.opencode/skills/code-review-criteria/SKILL.md b/.opencode/skills/code-review-criteria/SKILL.md index 8e7a75aa97..59abf1c21e 100644 --- a/.opencode/skills/code-review-criteria/SKILL.md +++ b/.opencode/skills/code-review-criteria/SKILL.md @@ -13,11 +13,10 @@ Multi-dimensional code review with quality gates. Every change gets reviewed bef ## When to Use -- Before merging any PR or change -- After completing a feature implementation -- When another agent or model produced code you need to evaluate -- When refactoring existing code -- After any bug fix (review both the fix and the regression test) +- The reviewer subagent of the `review-code` flow loads this skill to perform + the review of a code change. +- To review code, always go through the `review-code` flow — never load this + skill directly for that. This is the criteria reference, not the flow. ## Core Principles diff --git a/.opencode/skills/fd-find/SKILL.md b/.opencode/skills/fd-find/SKILL.md index e218ac9bfd..7d5e8fae4f 100644 --- a/.opencode/skills/fd-find/SKILL.md +++ b/.opencode/skills/fd-find/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"📂","requires":{"bins":["fd"]},"install":[{"id User-friendly alternative to `find` with smart defaults. +## When to use + +- Locating files or directories by name or pattern — prefer it over + plain `find`: simpler syntax, smart defaults, respects `.gitignore`. + ## Quick Start ### Basic search diff --git a/.opencode/skills/implement-plan/SKILL.md b/.opencode/skills/implement-plan/SKILL.md index 2f8cb43888..745aeb2322 100644 --- a/.opencode/skills/implement-plan/SKILL.md +++ b/.opencode/skills/implement-plan/SKILL.md @@ -9,6 +9,16 @@ This flow is run once a plan is ready (for example, from plan mode). Execute the plan already prepared in the current session context. This flow ends with exactly one commit. It never pushes — the user pushes. +## When to use + +- The user asks to implement or execute a plan, in any phrasing: + "implement the plan", "execute it", "go build it" — or runs + `/implement-plan`. +- A ready, reviewed plan is in the session context or a plan file path + was given (typically after `/make-a-plan` or `/review-plan`). + +Do not use it to produce plans — that is the `make-a-plan` flow. + ## 1. Detect the flow (no questions) Inspect the current branch with `git rev-parse --abbrev-ref HEAD`, pick the diff --git a/.opencode/skills/jq-json-processor/SKILL.md b/.opencode/skills/jq-json-processor/SKILL.md index 83fe48d7bf..11687a09ff 100644 --- a/.opencode/skills/jq-json-processor/SKILL.md +++ b/.opencode/skills/jq-json-processor/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔍","requires":{"bins":["jq"]},"install":[{"id Process, filter, and transform JSON data with jq. +## When to use + +- Parsing, filtering, or transforming JSON from commands, files, or API + responses — slicing, reshaping, or validating JSON output. + ## Quick Examples ### Basic filtering diff --git a/.opencode/skills/make-a-plan/SKILL.md b/.opencode/skills/make-a-plan/SKILL.md index 4569a536ee..a5e2ebcb04 100644 --- a/.opencode/skills/make-a-plan/SKILL.md +++ b/.opencode/skills/make-a-plan/SKILL.md @@ -11,6 +11,16 @@ produce a well-grounded, actionable implementation plan. If the running agent cannot write (for example, the plan agent), say so and stop — this skill needs the build agent to save the plan. +## When to use + +- The user asks to plan, design, or break down a task, in any phrasing: + "make a plan", "how would we build X", "design an approach for Y" — + or runs `/make-a-plan`. +- The user asks to rework or extend an existing plan (for example, after + review findings) — revise the saved plan file in place. + +Do not use it to execute a plan — that is the `implement-plan` flow. + ## Instructions 1. **Produce the plan** with the `planner` skill. By default, research the diff --git a/.opencode/skills/nrepl-eval/SKILL.md b/.opencode/skills/nrepl-eval/SKILL.md index c84dc803c1..0f7a025cbe 100644 --- a/.opencode/skills/nrepl-eval/SKILL.md +++ b/.opencode/skills/nrepl-eval/SKILL.md @@ -10,6 +10,12 @@ Evaluate Clojure (or ClojureScript) code via a running nREPL server using Full documentation: `mem:scripts/nrepl-eval` (file: `.serena/memories/scripts/nrepl-eval.md`) +## When to use + +- Evaluating Clojure or ClojureScript code against the running nREPL + sessions (backend 6064, frontend 3447) — live inspection, patching, or + debugging. + ## Quick Reference ```bash diff --git a/.opencode/skills/resolve-git-conflicts/SKILL.md b/.opencode/skills/resolve-git-conflicts/SKILL.md index e2cf2cff2a..7fcfd53283 100644 --- a/.opencode/skills/resolve-git-conflicts/SKILL.md +++ b/.opencode/skills/resolve-git-conflicts/SKILL.md @@ -9,6 +9,13 @@ Resolve conflicts in the local repository. The user handles finishing the rebase themselves — you must **never** run `git rebase --continue`, `git rebase --skip`, `git merge --continue`, or anything similar. +## When to use + +- The repository has unresolved conflicts — during a rebase, merge, or + cherry-pick — whether the user asks about them or not. +- The user asks to resolve conflicts, in any phrasing: "fix the merge + conflicts", "resolve these", "what's conflicting here?". + ## Phase 1 — Understand the problem (read-only) 1. Run `git status` to detect the conflict state (rebase, merge, cherry-pick, etc.) and list conflicted files. diff --git a/.opencode/skills/review-code/SKILL.md b/.opencode/skills/review-code/SKILL.md index 6e4337dc7f..bc50d56721 100644 --- a/.opencode/skills/review-code/SKILL.md +++ b/.opencode/skills/review-code/SKILL.md @@ -7,6 +7,14 @@ description: Code review flow — review a diff, PR, or code change, delegating Act as a senior software engineer and perform a thorough code review. +## When to use + +- The user asks to review code, in any phrasing: "review this diff", + "review the PR", "check my changes", "code review" — or runs + `/review-code`. +- A commit, branch, PR, or diff is ready and the user wants it assessed + before merge. + ## Instructions 1. **Determine what is being reviewed** from the user context: a working-tree diff --git a/.opencode/skills/review-plan/SKILL.md b/.opencode/skills/review-plan/SKILL.md index 2030ad2b74..91b152e752 100644 --- a/.opencode/skills/review-plan/SKILL.md +++ b/.opencode/skills/review-plan/SKILL.md @@ -8,6 +8,14 @@ description: Plan review flow — evaluate an implementation plan before it is e Act as a senior software engineer and perform a thorough review of an implementation plan. +## When to use + +- The user asks to review a plan, in any phrasing: "review this plan", + "does this plan look right?", "second opinion on the plan" — or runs + `/review-plan`. +- A plan was just produced (typically by `/make-a-plan`) and the user + wants it evaluated before executing it. + ## Instructions 1. **Determine the plan under review** from the session context (for example, a diff --git a/.opencode/skills/ripgrep/SKILL.md b/.opencode/skills/ripgrep/SKILL.md index 31c3a83d5e..1b028d8c58 100644 --- a/.opencode/skills/ripgrep/SKILL.md +++ b/.opencode/skills/ripgrep/SKILL.md @@ -9,6 +9,11 @@ metadata: {"clawdbot":{"emoji":"🔎","requires":{"bins":["rg"]},"install":[{"id Fast, smart recursive search. Respects `.gitignore` by default. +## When to use + +- Searching file contents across the repo for regex patterns — the + default code search, respects `.gitignore`. + ## Quick Start ### Basic search diff --git a/.opencode/skills/ste/SKILL.md b/.opencode/skills/ste/SKILL.md index a53456ccf0..67e474ef6b 100644 --- a/.opencode/skills/ste/SKILL.md +++ b/.opencode/skills/ste/SKILL.md @@ -9,6 +9,13 @@ Apply the ASD-STE100 standard to all prose you produce in this task. Do not anno Compliance note (for you, not for output): the official specification and its dictionary are copyright ASD. This skill encodes paraphrased rules and a publicly sourced word list. For certified aerospace/defense deliverables, tell the user that full compliance requires the free official specification (asd-ste100.org) and a human sign-off. Never claim certified compliance. +## When to use + +Only when the user explicitly invokes it: they type `/ste`, or say "use +the ste skill" / "apply ASD-STE100". Requests like "simplify this", +"make it clearer", or "shorter sentences" do NOT invoke it — respond +normally unless it is named. + ## Step 0 — Classify the text Before writing a single sentence, decide: is this **procedural** text (instructions someone follows) or **descriptive** text (explanation, background, description)? Every limit below depends on this. Mixed documents get classified section by section. diff --git a/.opencode/skills/taiga/SKILL.md b/.opencode/skills/taiga/SKILL.md index e63788698c..a5f32565a1 100644 --- a/.opencode/skills/taiga/SKILL.md +++ b/.opencode/skills/taiga/SKILL.md @@ -11,6 +11,12 @@ Fetch information from Taiga public API for the **Penpot** project **No authentication required** — only public project data is accessed. +## When to use + +- The user asks about Penpot issues, user stories, or tasks tracked in + Taiga — fetch them via the public API (project id 345963), no + authentication needed. + ## Prerequisites - `python3` — the `scripts/taiga.py` CLI script is self-contained (stdlib only)