diff --git a/.github/workflows/build-staging-render.yml b/.github/workflows/build-staging-render.yml deleted file mode 100644 index 7e65a518a9..0000000000 --- a/.github/workflows/build-staging-render.yml +++ /dev/null @@ -1,15 +0,0 @@ -name: _STAGING RENDER - -on: - workflow_dispatch: - schedule: - - cron: '36 5-20 * * 1-5' - -jobs: - build-bundle: - uses: ./.github/workflows/build-bundle.yml - secrets: inherit - with: - gh_ref: "staging-render" - build_wasm: "yes" - build_storybook: "yes" diff --git a/.gitignore b/.gitignore index 8586839ba0..dc4861f51f 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ /frontend/.storybook/preview-body.html /frontend/.storybook/preview-head.html /frontend/playwright-report/ +/frontend/playwright/ui/visual-specs/ /frontend/text-editor/src/wasm/ /frontend/dist/ /frontend/npm-debug.log @@ -84,3 +85,4 @@ /**/node_modules /**/.yarn/* /.pnpm-store +/.vscode diff --git a/CHANGES.md b/CHANGES.md index 380a52feeb..149fc49f05 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,5 +1,15 @@ # CHANGELOG +## 2.17.0 (Unreleased) + +### :boom: Breaking changes & Deprecations + +### :rocket: Epics and highlights + +### :sparkles: New features & Enhancements + +### :bug: Bugs fixed + ## 2.16.0 (Unreleased) ### :boom: Breaking changes & Deprecations @@ -9,6 +19,54 @@ ### :sparkles: New features & Enhancements - Enhance readability of applied tokens in plugins API [Taiga #13714](https://tree.taiga.io/project/penpot/issue/13714) +- Add "Delete group" option to the assets panel context menu for components, colors and typographies (by @FairyPigDev) [Github #9141](https://github.com/penpot/penpot/issues/9141) +- Add `Alt+click` on a layer's disclosure arrow to recursively expand the entire subtree in the Layers sidebar (by @MilosM348) [Github #9179](https://github.com/penpot/penpot/pull/9179) +- Show alpha percentage next to library color values to distinguish colors that differ only in opacity (by @rockchris099) [Github #6328](https://github.com/penpot/penpot/issues/6328) +- Add "Clear artboard guides" option to right-click context menu for frames (by @eureka0928) [Github #6987](https://github.com/penpot/penpot/issues/6987) +- Add loader feedback while importing and exporting files (by @moorsecopers99) [Github #9024](https://github.com/penpot/penpot/pull/9024) +- Allow duplicating color and typography styles (by @MkDev11) [Github #2912](https://github.com/penpot/penpot/issues/2912) +- Add woff2 support on user uploaded fonts (by @Nivl) [Github #8248](https://github.com/penpot/penpot/pull/8248) +- Import Tokens from linked library (by @dfelinto) [Github #8391](https://github.com/penpot/penpot/pull/8391) +- Option to download custom fonts (by @dfelinto) [Github #8320](https://github.com/penpot/penpot/issues/8320) +- Add copy as image to clipboard option to workspace context menu (by @dfelinto) [Github #8313](https://github.com/penpot/penpot/pull/8313) +- Add Tab/Shift+Tab navigation to rename layers sequentially (by @bittoby) [Github #8474](https://github.com/penpot/penpot/pull/8474) +- Copy and paste entire rows in existing table (by @bittoby) [Github #8498](https://github.com/penpot/penpot/pull/8498) +- Rename token group [Taiga #13137](https://tree.taiga.io/project/penpot/us/13137) +- Duplicate token group [Taiga #10653](https://tree.taiga.io/project/penpot/us/10653) +- Copy token name from contextual menu [Taiga #13568](https://tree.taiga.io/project/penpot/issue/13568) +- Add natural sorting on token names [Taiga #13713](https://tree.taiga.io/project/penpot/issue/13713) +- Add drag-to-change for numeric inputs in workspace sidebar (by @RenzoMXD) [Github #8536](https://github.com/penpot/penpot/pull/8536) +- Add CSS linter [Taiga #13790](https://tree.taiga.io/project/penpot/us/13790) +- Save and restore selection state in undo/redo (by @eureka0928) [Github #6007](https://github.com/penpot/penpot/issues/6007) +- Fix warnings for unsupported token $type (by @Dexterity104) [Github #8790](https://github.com/penpot/penpot/issues/8790) +- Add per-group add button for typographies (by @eureka0928) [Github #5275](https://github.com/penpot/penpot/issues/5275) +- Add Find & Replace for text content and layer names (by @statxc) [Github #7108](https://github.com/penpot/penpot/issues/7108) +- Use page name for multi-export ZIP/PDF downloads (by @Dexterity104) [Github #8773](https://github.com/penpot/penpot/issues/8773) +- Make links in comments clickable (by @eureka0928) [Github #1602](https://github.com/penpot/penpot/issues/1602) +- Add visibility toggle for strokes (by @eureka0928) [Github #7438](https://github.com/penpot/penpot/issues/7438) +- Sort asset library subfolders alphabetically at every nesting level (by @eureka0928) [Github #2572](https://github.com/penpot/penpot/issues/2572) +- Add Paste to replace (Cmd+Shift+V) to replace the selected shape with clipboard contents (by @eureka0928) [Github #4240](https://github.com/penpot/penpot/issues/4240) +- Differentiate incoming and outgoing interaction link colors (by @claytonlin1110) [Github #7794](https://github.com/penpot/penpot/issues/7794) +- Add guide locking and fix locked elements not selectable in viewer (by @Dexterity104) [Github #8358](https://github.com/penpot/penpot/issues/8358) +- Apply styles to selection (by @AzazelN28) [Taiga #13647](https://tree.taiga.io/project/penpot/task/13647) +- Reorder prototyping overlay options to show Position before Relative to (by @rockchris099) [Github #2910](https://github.com/penpot/penpot/issues/2910) +- Add customizable colors for ruler guides (by @Dexterity104) [Github #5199](https://github.com/penpot/penpot/issues/5199) +- Persist asset search query and section filter when switching sidebar tabs (by @eureka0928) [Github #2913](https://github.com/penpot/penpot/issues/2913) +- Add delete and duplicate buttons to typography dialog (by @eureka0928) [Github #5270](https://github.com/penpot/penpot/issues/5270) +- Edit ruler guide position by double-clicking the guide pill (by @eureka0928) [Github #2311](https://github.com/penpot/penpot/issues/2311) +- Add a search bar to filter colors in the color palette toolbar (by @eureka0928) [Github #7653](https://github.com/penpot/penpot/issues/7653) +- Add a search bar to filter board size presets (by @eureka0928) [Github #4658](https://github.com/penpot/penpot/issues/4658) +- Allow customising the OIDC login button label (by @wdeveloper16) [Github #7027](https://github.com/penpot/penpot/issues/7027) +- Add page separators in Workspace [Taiga #13611](https://tree.taiga.io/project/penpot/us/13611?milestone=262806) +- Preserve vector content when pasting SVG from external tools such as Inkscape (by @RenzoMXD) [Github #9182](https://github.com/penpot/penpot/pull/9182) +- Add Shift+Numpad0/1/2 as aliases to Shift+0/1/2 for zoom shortcuts (by @RenzoMXD) [Github #9063](https://github.com/penpot/penpot/pull/9063) +- Add pixel grid color picker in viewport settings (by @Yakehira) [Github #7750](https://github.com/penpot/penpot/issues/7750) +- Add HEX, HSB and HSL support to the color picker with a model switcher that persists across sessions (by @edwin-rivera-dev) [Github #9133](https://github.com/penpot/penpot/issues/9133) +- Show specific invitation-link error messages for expired, email-mismatch and invalid token cases [Github #9220](https://github.com/penpot/penpot/issues/9220) +- Show detailed messages on file import errors to help diagnose why a file could not be imported (by @jsdevninja) [Github #9004](https://github.com/penpot/penpot/issues/9004) +- Add read-only preview mode for saved versions — click a version name to open a dedicated preview view (by @wdeveloper16) [Github #8976](https://github.com/penpot/penpot/issues/8976) +- Add clipboard read/write permissions to the plugin system (by @wdeveloper16) [Github #9053](https://github.com/penpot/penpot/issues/9053) +- Add new numeric inputs for token management on the right sidebar [Taiga #12109](https://tree.taiga.io/project/penpot/us/12109?milestone=513226) ### :bug: Bugs fixed @@ -23,7 +81,56 @@ - Fix text editor v1 focus [Taiga #13961](https://tree.taiga.io/project/penpot/issue/13961) - Fix color dropdown option update [Taiga #14035](https://tree.taiga.io/project/penpot/issue/14035) - Fix themes modal height [Taiga #14046](https://tree.taiga.io/project/penpot/issue/14046) - +- Fix layers panel rename input showing the default type name instead of the saved layer name (by @jack-stormentswe) [Github #9231](https://github.com/penpot/penpot/pull/9231) +- Suppress browser context menu on right-click in workspace sidebars while preserving it on text inputs (by @sujyotraut) [Github #5127](https://github.com/penpot/penpot/issues/5127) +- Fix release notes modal appearing behind the dashboard sidebar (by @ciaokitty) [Github #8296](https://github.com/penpot/penpot/issues/8296) +- Fix plugin API `fileVersion.restore()` promise hanging indefinitely on restore failure (by @thomascolden585-svg) [Github #9092](https://github.com/penpot/penpot/issues/9092) +- Fix imported stroke-only SVG paths losing their rounded join when split into adjacent subpaths (by @Chrissi2812) [Github #5283](https://github.com/penpot/penpot/issues/5283) +- Fix plugin API `library.connectLibrary()` not returning a Promise when the plugin lacks `library:write` permission (by @boskodev790) [Github #9158](https://github.com/penpot/penpot/pull/9158) +- Fix LDAP provider schema typo (`bind-passwor` → `bind-password`) introduced during the `clojure.spec` → `malli` migration (by @boskodev790) [Github #9165](https://github.com/penpot/penpot/pull/9165) +- Fix `login-with-ldap` silently dropping the error message when LDAP is not initialized (typo `:hide` → `:hint`) (by @boskodev790) [Github #9159](https://github.com/penpot/penpot/pull/9159) +- Fix plugin API `applyToken()` / `applyToShapes()` / `applyToSelected()` rejecting JS-array attribute lists (by @brunopbezerra) [Github #9162](https://github.com/penpot/penpot/issues/9162) +- Fix `PENPOT_OIDC_USER_INFO_SOURCE` flag being silently ignored in the OIDC callback (by @GeekClassy) [Github #9108](https://github.com/penpot/penpot/issues/9108) +- Fix crash in share-link viewer when a team member's email is missing `@` or has no domain TLD (by @boskodev790) [Github #9120](https://github.com/penpot/penpot/pull/9120) +- Fix crash when pasting a component with variants from an external shared library into a file that uses that library (by @FairyPigDev) [Github #8144](https://github.com/penpot/penpot/issues/8144) +- Remove `corepack` from the MCP local launcher so it runs on Node.js 25+, where corepack is no longer bundled (by @TheAifam5) [Github #8877](https://github.com/penpot/penpot/issues/8877) +- Fix Copy as SVG to produce a valid document for multi-shape selections and use `image/svg+xml` MIME type (by @RenzoMXD) [Github #9066](https://github.com/penpot/penpot/pull/9066) +- Reset profile submenu state when the account menu closes (by @eureka0928) [Github #8947](https://github.com/penpot/penpot/issues/8947) +- Preserve OpenType variant name table for custom fonts in the dashboard (by @rutherfordcraze) [Github #8924](https://github.com/penpot/penpot/issues/8924) +- Add export panel to inspect styles tab [Taiga #13582](https://tree.taiga.io/project/penpot/issue/13582) +- Fix styles between grid layout inputs [Taiga #13526](https://tree.taiga.io/project/penpot/issue/13526) +- Fix id prop on switch component [Taiga #13534](https://tree.taiga.io/project/penpot/issue/13534) +- Update copy on penpot update message [Taiga #12924](https://tree.taiga.io/project/penpot/issue/12924) +- Fix scroll on library modal [Taiga #13639](https://tree.taiga.io/project/penpot/issue/13639) +- Fix dates to avoid show them in english when browser is in auto [Taiga #13786](https://tree.taiga.io/project/penpot/issue/13786) +- Fix focus radio button [Taiga #13841](https://tree.taiga.io/project/penpot/issue/13841) +- Token tree should be expanded by default [Taiga #13631](https://tree.taiga.io/project/penpot/issue/13631) +- Fix opacity incorrectly disabled for visible shapes [Taiga #13906](https://tree.taiga.io/project/penpot/issue/13906) +- Update onboarding image [Taiga #13864](https://tree.taiga.io/project/penpot/issue/13864) +- Fix plugin modal drag interactions over iframe and close-button behavior (by @marekhrabe) [Github #8871](https://github.com/penpot/penpot/pull/8871) +- Fix hot update on color-row on texts [Taiga #13923](https://tree.taiga.io/project/penpot/issue/13923) +- Fix selected color tokens [Taiga #13930](https://tree.taiga.io/project/penpot/issue/13930) +- Fix dashboard Recent/Deleted titles overlapped by scrolling content (by @rockchris099) [Github #8577](https://github.com/penpot/penpot/issues/8577) +- Display resolved values of inactive tokens [Taiga #13628](https://tree.taiga.io/project/penpot/issue/13628) +- Fix hyphens stripped from export filenames (by @jamesrayammons) [Github #8901](https://github.com/penpot/penpot/issues/8901) +- Fix app crash when selecting shapes with one hidden [Taiga #13959](https://tree.taiga.io/project/penpot/issue/13959) +- Fix opacity mixed value [Taiga #13960](https://tree.taiga.io/project/penpot/issue/13960) +- Fix gap input throwing an error [Github #8984](https://github.com/penpot/penpot/pull/8984) +- Fix non-functional clear icon in change email modal inputs (by @Dexterity104) [Github #8977](https://github.com/penpot/penpot/issues/8977) +- Disable save button after saving account profile settings (by @Dexterity104) [Github #8979](https://github.com/penpot/penpot/issues/8979) +- Fix copy to be more specific [Taiga #13990](https://tree.taiga.io/project/penpot/issue/13990) +- Allow deleting the profile avatar after uploading (by @moorsecopers99) [Github #9067](https://github.com/penpot/penpot/issues/9067) +- Fix incorrect rendering when exporting text as SVG, PNG and JPG (by @edwin-rivera-dev) [Github #8516](https://github.com/penpot/penpot/issues/8516) +- Fix Settings and Notifications "Update Settings" button enabled state when form has no changes (by @moorsecopers99) [Github #9090](https://github.com/penpot/penpot/issues/9090) +- Fix "Help & Learning" submenu vertical alignment in account menu (by @juan-flores077) [Github #9137](https://github.com/penpot/penpot/issues/9137) +- Fix plugin `addInteraction` silently rejecting `open-overlay` actions with `manualPositionLocation` (by @axelseis) [Github #8409](https://github.com/penpot/penpot/issues/8409) +- Fix typography style creation with tokenized line-height (by @juan-flores077) [Github #8479](https://github.com/penpot/penpot/issues/8479) +- Fix colorpicker layout so the eyedropper button is visible again [Taiga #14057](https://tree.taiga.io/project/penpot/issue/14057) +- Fix restore-deleted-team-files failing due to a typo in the reduce accumulator (by @Dexterity104) [Github #9241](https://github.com/penpot/penpot/issues/9241) +- Fix internal error on layer prev/next sibling selection (by @jsdevninja) [Github #9003](https://github.com/penpot/penpot/issues/9003) +- Fix tooltip appearing two times when nested elements [Github #9031](https://github.com/penpot/penpot/issues/9031) +- Fix broken update library notification link in the UI [Github #9070](https://github.com/penpot/penpot/issues/9070) +- Fix plugin API `ShapeBase.component()` returning the outermost component instead of the immediate component in case of nested component instances [Github #9183](https://github.com/penpot/penpot/issues/9183) ## 2.15.0 (Unreleased) @@ -39,7 +146,6 @@ - Fix incorrect handling of version restore operation [Github #9041](https://github.com/penpot/penpot/pull/9041) - Fix Plugin API token methods rejecting JS array of strings [Github #9162](https://github.com/penpot/penpot/issues/9162) - ## 2.14.4 ### :bug: Bugs fixed @@ -48,7 +154,6 @@ - Fix email blacklisting [Github #9122](https://github.com/penpot/penpot/pull/9122) - Fix removeChild errors from unmount race conditions [Github #8927](https://github.com/penpot/penpot/pull/8927) - ## 2.14.3 ### :sparkles: New features & Enhancements @@ -79,7 +184,6 @@ - Fix typo `:podition` in swap-shapes grid cell - Fix multiple selection on shapes with token applied to stroke color - ## 2.14.2 ### :sparkles: New features & Enhancements @@ -102,7 +206,6 @@ - Guard delete undo against missing sibling order [Github #8858](https://github.com/penpot/penpot/pull/8858) - Fix ICounted error on numeric-input token dropdown keyboard nav [Github #8803](https://github.com/penpot/penpot/pull/8803) - ## 2.14.1 ### :sparkles: New features & Enhancements @@ -126,7 +229,6 @@ - Ensure path content is always PathData when saving - Fix error when get-parent-with-data encounters non-Element nodes - ## 2.14.0 ### :boom: Breaking changes & Deprecations @@ -196,6 +298,7 @@ ## 2.13.0 ### :heart: Community contributions (Thank you!) + - Add 'page' special shapeId to MCP export_shape tool for full-page snapshots [Github #8689](https://github.com/penpot/penpot/issues/8689) - Fix mask issues with component swap (by @dfelinto) [Github #7675](https://github.com/penpot/penpot/issues/7675) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d733ea5c7a..532413194d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -63,12 +63,11 @@ Advisories](https://github.com/penpot/penpot/security/advisories) 1. **Read the DCO** — see [Developer's Certificate of Origin](#developers-certificate-of-origin-dco) below. All code patches must include a `Signed-off-by` line. 2. **Discuss before building** — open a [GitHub - Issue](https://github.com/penpot/penpot/issues) or start a [GitHub - Discussion](https://github.com/penpot/penpot/discussions) before starting - work on a new feature or significant change. For planned features on the - roadmap, reference the corresponding Taiga story. No PR will be accepted - without prior discussion, whether it is a new feature, a planned one, or a - quick win. + Issue](https://github.com/penpot/penpot/issues) before starting work on + a new feature or significant change. For planned features on the roadmap, + reference the corresponding Taiga story. Do not expect your contribution + to be accepted if you submit it without prior discussion — this applies + to new features, planned features, and quick wins alike. 3. **Bug fixes** — you may submit a PR directly, but we still recommend filing an issue first so we can track it independently of your fix. 4. **Format and lint** — run the checks described in @@ -136,7 +135,11 @@ refactor/layout-sizing ### Review process -- Maintainers review PRs when time permits. Please be patient. +- We are a small team and maintainers juggle reviews alongside other + tasks. Please do not expect your code to be reviewed instantly. +- Reviews are handled in dedicated blocks of time, usually in the order + PRs arrive. It may take a few days to get a first review, especially + when urgent tasks come up. - Address review feedback by **pushing new commits** — do not force-push during review, as it breaks comment threads. - PRs require at least **one approval** before merge. diff --git a/README.md b/README.md index 07190bcb29..fd681f9afc 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,56 @@ + [uri_license]: https://www.mozilla.org/en-US/MPL/2.0 [uri_license_image]: https://img.shields.io/badge/MPL-2.0-blue.svg - - - - penpot header image - -

-License: MPL-2.0 -Penpot Community -Managed with Taiga.io -Gitpod ready-to-code + + Verified DPG + + + Penpot Community + + + Managed with Taiga.io + + + Gitpod ready-to-code +

- Website • - User Guide • - Learning Center • - Community + Website • + User Guide • + Learning Center • + Community

- Youtube • - Peertube • - Linkedin • - Instagram • - Mastodon • - Bluesky • - X - + Youtube • + Peertube • + Linkedin • + Instagram • + Mastodon • + Bluesky • + X

-
+[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332) -[Penpot video](https://github.com/user-attachments/assets/7c67fd7c-04d3-4c9b-88ec-b6f5e23f8332 -) +Penpot is the open-source design platform for teams that build digital products at scale. -
+Penpot’s key strength lies in giving you **full ownership of your design infrastructure**. Built on open source and designed for [self-hosting](https://help.penpot.app/technical-guide/getting-started/), it puts teams in complete control of their design environment supporting strict compliance and governance requirements. Whether used in the **browser or deployed on your own servers**, Penpot **works with open standards** like SVG, CSS, HTML, and JSON. -Penpot is the first **open-source** design tool for design and code collaboration. Designers can create stunning designs, interactive prototypes, design systems at scale, while developers enjoy ready-to-use code and make their workflow easy and fast. And all of this with no handoff drama. +Real-time collaboration strengthens this foundation, helping teams scale and bring design closer to the product through top-tier capabilities. Additionally, developers feel at home using Penpot, because design is expressed as code, enabling a direct translation and shipping products faster. -Available on browser or self-hosted, Penpot works with open standards like SVG, CSS, HTML and JSON, and it’s free! +Best-in-class native [Design Tokens](https://penpot.dev/collaboration/design-tokens) provide a single source of truth between design and development. They ensure consistency, improve collaboration, and make it easier to manage complex design systems. -The latest updates take Penpot even further. It’s the first design tool to integrate native [design tokens](https://penpot.dev/collaboration/design-tokens)—a single source of truth to improve efficiency and collaboration between product design and development. -With the [huge 2.0 release](https://penpot.app/dev-diaries), Penpot took the platform to a whole new level. This update introduces the ground-breaking [CSS Grid Layout feature](https://penpot.app/penpot-2.0), a complete UI redesign, a new Components system, and much more. -For organizations that need extra service for its teams, [get in touch](https://cal.com/team/penpot/talk-to-us) +The [MCP server](https://penpot.app/penpot-mcp-server) takes it further by enabling multi-directional workflows between design and code. A [powerful open API](https://help.penpot.app/mcp/#quick-start) and plugin system makes the workspace programmable, enabling automation, AI-driven workflows, and integrations with the tools and systems you already use. -🎇 Design, code, and Open Source meet at [Penpot Fest](https://penpot.app/penpotfest)! Be part of the 2025 edition in Madrid, Spain, on October 9-10. +With [CSS Grid and Flex Layout](https://help.penpot.app/user-guide/designing/flexible-layouts/), teams can design responsive interfaces that behave like real code from the start. + +Combined, these features turn Penpot into a **full-stack design platform** for building scalable design systems and fully integrated product development processes. + +If your organization is scaling and needs extra support, we’re here to help. [Talk to us](https://penpot.app/talk-to-us) ## Table of contents ## @@ -60,101 +63,78 @@ For organizations that need extra service for its teams, [get in touch](https:// ## Why Penpot ## -Penpot expresses designs as code. Designers can do their best work and see it will be beautifully implemented by developers in a two-way collaboration. +Penpot connects design, code, and AI workflows through a code-based approach, making designs readable by developers and AI via the MCP server. This approach helps teams ship what’s actually designed and manage design systems at scale with powerful design tokens. As a self-hosted, open-source and real-time collaboration platform, Penpot offers full flexibility, security, and ownership without vendor lock-in. Learn more about [why Penpot](https://penpot.app/why-penpot) is the platform for your team. ### Plugin system ### + [Penpot plugins](https://penpot.app/penpothub/plugins) let you expand the platform's capabilities, give you the flexibility to integrate it with other apps, and design custom solutions. ### Designed for developers ### + Penpot was built to serve both designers and developers and create a fluid design-code process. You have the choice to enjoy real-time collaboration or play "solo". ### Inspect mode ### + Work with ready-to-use code and make your workflow easy and fast. The inspect tab gives instant access to SVG, CSS and HTML code. -### Self host your own instance ### -Provide your team or organization with a completely owned collaborative design tool. Use Penpot's cloud service or deploy your own Penpot server. - ### Integrations ### -Penpot offers integration into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens. -### Building Design Systems: design tokens, components and variants ### -Penpot brings design systems to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms. +Penpot offers [integration](https://penpot.app/integrations-api) into the development toolchain, thanks to its support for webhooks and an API accessible through access tokens. +### Building Design Systems: design tokens, components and variants ### -
+Penpot brings [design systems](https://penpot.app/design/design-systems) to code-minded teams: a single source of truth with native Design Tokens, Components, and Variants for scalable, reusable, and consistent UI across projects and platforms. -

- -

- -
+Penpot Design Systems ## Getting started ## Penpot is the only design & prototype platform that is deployment agnostic. You can use it in our [SAAS](https://design.penpot.app) or deploy it anywhere. Learn how to install it with Docker, Kubernetes, Elestio or other options on [our website](https://penpot.app/self-host). -
- -

- Open Source -

-
## Community ## We love the Open Source software community. Contributing is our passion and if it’s yours too, participate and [improve](https://community.penpot.app/c/help-us-improve-penpot/7) Penpot. All your designs, code and ideas are welcome! +Want to go a step further? Become a [Penpot Ambassador](https://penpot.app/ambassador-program) and help grow the Penpot community in your region while contributing to a global, open design ecosystem. + If you need help or have any questions; if you’d like to share your experience using Penpot or get inspired; if you’d rather meet our community of developers and designers, [join our Community](https://community.penpot.app/)! -You will find the following categories: +Categories include: + - [Ask the Community](https://community.penpot.app/c/ask-for-help-using-penpot/6) - [Troubleshooting](https://community.penpot.app/c/technical/8) - [Help us Improve Penpot](https://community.penpot.app/c/help-us-improve-penpot/7) -- [#MadeWithPenpot](https://community.penpot.app/c/madewithpenpot/9) - [Events and Announcements](https://community.penpot.app/c/announcements/5) -- [Inside Penpot](https://community.penpot.app/c/inside-penpot/21) - [Penpot in your language](https://community.penpot.app/c/penpot-in-your-language/12) -- [Design and Code Essentials](https://community.penpot.app/c/design-and-code-essentials/22) +- [Education](https://community.penpot.app/c/education/28) - -
- -

- Community -

-
+Pentpot Community ### Code of Conduct ### Anyone who contributes to Penpot, whether through code, in the community, or at an event, must adhere to the [code of conduct](https://help.penpot.app/contributing-guide/coc/) and foster a positive and safe environment. - -## Contributing ## +### Contributing ### Any contribution will make a difference to improve Penpot. How can you get involved? Choose your way: -- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community -- Invite your [team to join](https://design.penpot.app/#/auth/register) -- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app) +- Create and [share Libraries & Templates](https://penpot.app/libraries-templates.html) that will be helpful for the community. +- Invite your [team to join](https://design.penpot.app/#/auth/register). +- Give this repo a star and follow us on Social Media: [Mastodon](https://fosstodon.org/@penpot/), [Youtube](https://www.youtube.com/c/Penpot), [Instagram](https://instagram.com/penpot.app), [Linkedin](https://www.linkedin.com/company/penpotdesign), [Peertube](https://peertube.kaleidos.net/a/penpot_app), [X](https://twitter.com/penpotapp) and [BlueSky](https://bsky.app/profile/penpot.app). - Participate in the [Community](https://community.penpot.app/) space by asking and answering questions; reacting to others’ articles; opening your own conversations and following along on decisions affecting the project. -- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues) -- Become a [translator](https://help.penpot.app/contributing-guide/translations) -- Give feedback: [Email us](mailto:support@penpot.app) -- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpot’s repository and make changes in both front and back end +- Report bugs with our easy [guide for bugs hunting](https://help.penpot.app/contributing-guide/reporting-bugs/) or [GitHub issues](https://github.com/penpot/penpot/issues). +- Become a [translator](https://help.penpot.app/contributing-guide/translations). +- Give feedback: [Email us](mailto:support@penpot.app). +- **Contribute to Penpot's code:** [Watch this video](https://www.youtube.com/watch?v=TpN0osiY-8k) by Alejandro Alonso, CIO and developer at Penpot, where he gives us a hands-on demo of how to use Penpot’s repository and make changes in both front and back end. To find (almost) everything you need to know on how to contribute to Penpot, refer to the [contributing guide](https://help.penpot.app/contributing-guide/). -
- -

- Libraries and templates -

- -
+Penpot hub ## Resources ## @@ -170,6 +150,8 @@ You can ask and answer questions, have open-ended conversations, and follow alon 📚 [Dev Diaries](https://penpot.app/dev-diaries.html) +🧑‍🏫​ [UI Design Course](https://penpot.app/courses/) + ## License ## diff --git a/backend/resources/app/email/invite-to-org/en.html b/backend/resources/app/email/invite-to-org/en.html new file mode 100644 index 0000000000..5ee16f6942 --- /dev/null +++ b/backend/resources/app/email/invite-to-org/en.html @@ -0,0 +1,264 @@ + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + + + + +
+ +
+ + + + +
+ + + + + + +
+ +
+
+
+ +
+
+ +
+ + + + + + +
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+ Hi{% if user-name %} {{ user-name|abbreviate:25 }}{% endif %}, +
+
+
+ {{invited-by|abbreviate:25}} sent you an invitation to join the organization: +
+
+
+ + + + +
+ {% if organization-initials %}{{organization-initials}}{% endif %} +
+ + “{{ organization-name|abbreviate:25 }}” + +
+
+ + + + +
+ ACCEPT INVITE +
+
+
+ Enjoy!
+
+
+ The Penpot team.
+
+
+ +
+
+ + {% include "app/email/includes/footer.html" %} + +
+ + + diff --git a/backend/resources/app/email/invite-to-org/en.subj b/backend/resources/app/email/invite-to-org/en.subj new file mode 100644 index 0000000000..765d186236 --- /dev/null +++ b/backend/resources/app/email/invite-to-org/en.subj @@ -0,0 +1 @@ +{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}” diff --git a/backend/resources/app/email/invite-to-org/en.txt b/backend/resources/app/email/invite-to-org/en.txt new file mode 100644 index 0000000000..8e22eba453 --- /dev/null +++ b/backend/resources/app/email/invite-to-org/en.txt @@ -0,0 +1,10 @@ +Hello! + +{{invited-by|abbreviate:25}} has invited you to join the organization “{{ organization-name|abbreviate:25 }}”. + +Accept invitation using this link: + +{{ public-uri }}/#/auth/verify-token?token={{token}} + +Enjoy! +The Penpot team. diff --git a/backend/resources/app/email/invite-to-team/en.html b/backend/resources/app/email/invite-to-team/en.html index 337593902d..31d1ddf4a3 100644 --- a/backend/resources/app/email/invite-to-team/en.html +++ b/backend/resources/app/email/invite-to-team/en.html @@ -186,7 +186,8 @@
- {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”.
+ {{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”{% if organization %} + part of the organization “{{ organization|abbreviate:25 }}”{% endif %}. diff --git a/backend/resources/app/email/invite-to-team/en.txt b/backend/resources/app/email/invite-to-team/en.txt index 55e61d8e23..3482fab0a5 100644 --- a/backend/resources/app/email/invite-to-team/en.txt +++ b/backend/resources/app/email/invite-to-team/en.txt @@ -1,6 +1,6 @@ Hello! -{{invited-by|abbreviate:25}} has invited you to join the team “{{ team|abbreviate:25 }}”. +{{invited-by|abbreviate:25}} has invited you to join the team "{{ team|abbreviate:25 }}"{% if organization %}, part of the organization "{{ organization|abbreviate:25 }}"{% endif %}. Accept invitation using this link: diff --git a/backend/src/app/auth/ldap.clj b/backend/src/app/auth/ldap.clj index 63b7c93672..687a10dd4d 100644 --- a/backend/src/app/auth/ldap.clj +++ b/backend/src/app/auth/ldap.clj @@ -111,7 +111,7 @@ [:host {:optional true} :string] [:port {:optional true} ::sm/int] [:bind-dn {:optional true} :string] - [:bind-passwor {:optional true} :string] + [:bind-password {:optional true} :string] [:query {:optional true} :string] [:base-dn {:optional true} :string] [:attrs-email {:optional true} :string] diff --git a/backend/src/app/auth/oidc.clj b/backend/src/app/auth/oidc.clj index fa819c5e0c..782dfabca7 100644 --- a/backend/src/app/auth/oidc.clj +++ b/backend/src/app/auth/oidc.clj @@ -401,8 +401,9 @@ (defn- parse-attr-path [provider path] - (let [[fitem & items] (str/split path "__")] - (into [(keyword (:type provider) fitem)] (map keyword) items))) + (let [separator (if (str/includes? path "__") "__" ".") + [fitem & items] (str/split path separator)] + (into [(keyword (:type provider) (str/kebab fitem))] (map keyword) items))) (defn- build-redirect-uri [] @@ -488,9 +489,9 @@ (let [attr-ph (parse-attr-path provider "nickname")] (get-in props attr-ph))))] - (let [info (assoc info :provider-id (str (:id provider))) - props (qualify-props provider info) - email (get-email props)] + (let [info (assoc info :provider-id (str (:id provider))) + props (qualify-props provider info) + email (get-email props)] {:backend (:type provider) :fullname (or (get-name props) email) :email email @@ -547,16 +548,29 @@ (def ^:private valid-info? (sm/validator schema:info)) +(defn- select-user-info-source + "Normalise the provider's configured user-info source into a keyword the + dispatch below can match. The raw value comes from config as a string + per the malli schema in `app.config` (`\"token\"`, `\"userinfo\"`, or + `\"auto\"`) and from hard-coded per-provider maps as strings as well; + any unrecognised or missing value falls back to `:auto` (prefer claims, + use userinfo as fallback)." + [source] + (case source + "token" :token + "userinfo" :userinfo + :auto)) + (defn- get-info [cfg provider state code] (let [tdata (fetch-access-token cfg provider code) claims (get-id-token-claims provider tdata) - info (case (get provider :user-info-source) - :token (dissoc claims :exp :iss :iat :aud :sub :sid) + info (case (select-user-info-source (get provider :user-info-source)) + :token (dissoc claims :exp :iss :iat :aud :sid) :userinfo (fetch-user-info cfg provider tdata) - (or (some-> claims (dissoc :exp :iss :iat :aud :sub :sid)) - (fetch-user-info cfg provider tdata))) + :auto (or (some-> claims (dissoc :exp :iss :iat :aud :sid)) + (fetch-user-info cfg provider tdata))) info (process-user-info provider tdata info)] diff --git a/backend/src/app/email.clj b/backend/src/app/email.clj index b42206dc93..fe57118a58 100644 --- a/backend/src/app/email.clj +++ b/backend/src/app/email.clj @@ -412,6 +412,21 @@ :id ::invite-to-team :schema schema:invite-to-team)) +(def ^:private schema:invite-to-org + [:map + [:invited-by ::sm/text] + [:organization-name ::sm/text] + [:organization-initials [:maybe :string]] + [:organization-logo ::sm/uri] + [:user-name [:maybe ::sm/text]] + [:token ::sm/text]]) + +(def invite-to-org + "Org member invitation email." + (template-factory + :id ::invite-to-org + :schema schema:invite-to-org)) + (def ^:private schema:join-team [:map [:invited-by ::sm/text] diff --git a/backend/src/app/features/file_snapshots.clj b/backend/src/app/features/file_snapshots.clj index 192030cbf8..e013b90d00 100644 --- a/backend/src/app/features/file_snapshots.clj +++ b/backend/src/app/features/file_snapshots.clj @@ -112,8 +112,9 @@ THEN (c.deleted_at IS NULL OR c.deleted_at >= ?::timestamptz) END")) -(defn- get-snapshot - "Get snapshot with decoded data" +(defn get-snapshot-data + "Get a fully decoded snapshot for read-only preview or restoration. + Returns the snapshot map with decoded :data field." [cfg file-id snapshot-id] (let [now (ct/now)] (->> (db/get-with-sql cfg [sql:get-snapshot file-id snapshot-id now] @@ -326,7 +327,7 @@ (sto/resolve cfg {::db/reuse-conn true}) snapshot - (get-snapshot cfg file-id snapshot-id)] + (get-snapshot-data cfg file-id snapshot-id)] (when-not snapshot (ex/raise :type :not-found diff --git a/backend/src/app/migrations.clj b/backend/src/app/migrations.clj index be9dc4bace..a188c3c1f0 100644 --- a/backend/src/app/migrations.clj +++ b/backend/src/app/migrations.clj @@ -471,8 +471,14 @@ {:name "0146-mod-access-token-table" :fn (mg/resource "app/migrations/sql/0146-mod-access-token-table.sql")} + {:name "0147-mod-team-invitation-table" + :fn (mg/resource "app/migrations/sql/0147-mod-team-invitation-table.sql")} + {:name "0147-add-upload-session-table" - :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")}]) + :fn (mg/resource "app/migrations/sql/0147-add-upload-session-table.sql")} + + {:name "0148-add-variant-name-team-font-variant" + :fn (mg/resource "app/migrations/sql/0148-add-variant-name-team-font-variant.sql")}]) (defn apply-migrations! [pool name migrations] diff --git a/backend/src/app/migrations/sql/0147-mod-team-invitation-table.sql b/backend/src/app/migrations/sql/0147-mod-team-invitation-table.sql new file mode 100644 index 0000000000..6c60428f06 --- /dev/null +++ b/backend/src/app/migrations/sql/0147-mod-team-invitation-table.sql @@ -0,0 +1,13 @@ +ALTER TABLE team_invitation + ADD COLUMN org_id uuid NULL; + +ALTER TABLE team_invitation + ALTER COLUMN team_id DROP NOT NULL; + +ALTER TABLE team_invitation + ADD CONSTRAINT team_invitation_team_or_org_not_null + CHECK (team_id IS NOT NULL OR org_id IS NOT NULL); + +CREATE UNIQUE INDEX team_invitation_org_unique + ON team_invitation (org_id, email_to) + WHERE team_id IS NULL; diff --git a/backend/src/app/migrations/sql/0148-add-variant-name-team-font-variant.sql b/backend/src/app/migrations/sql/0148-add-variant-name-team-font-variant.sql new file mode 100644 index 0000000000..d90fb83538 --- /dev/null +++ b/backend/src/app/migrations/sql/0148-add-variant-name-team-font-variant.sql @@ -0,0 +1,2 @@ +ALTER TABLE team_font_variant + ADD COLUMN variant_name text NULL; diff --git a/backend/src/app/nitrate.clj b/backend/src/app/nitrate.clj index cfa0ff9014..28f2cf1eb7 100644 --- a/backend/src/app/nitrate.clj +++ b/backend/src/app/nitrate.clj @@ -1,15 +1,23 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + (ns app.nitrate "Module that make calls to the external nitrate aplication" (:require + [app.common.exceptions :as ex] + [app.common.json :as json] [app.common.logging :as l] [app.common.schema :as sm] [app.common.schema.generators :as sg] [app.common.time :as ct] + [app.common.types.organization :as cto] [app.config :as cf] [app.http.client :as http] [app.rpc :as-alias rpc] [app.setup :as-alias setup] - [app.util.json :as json] [clojure.core :as c] [integrant.core :as ig])) @@ -18,16 +26,16 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (defn- request-builder - [cfg method uri shared-key profile-id] + [cfg method uri shared-key profile-id request-params] (fn [] - (http/req! cfg {:method method - :headers {"content-type" "application/json" - "accept" "application/json" - "x-shared-key" shared-key - "x-profile-id" (str profile-id)} - :uri uri - :version :http1.1}))) - + (http/req! cfg (cond-> {:method method + :headers {"content-type" "application/json" + "accept" "application/json" + "x-shared-key" shared-key + "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)))))) (defn- with-retries [handler max-retries] @@ -47,24 +55,49 @@ result))))) -(defn- with-validate [handler uri schema] +(defn- with-validate [handler uri schema & {:keys [throw-on-error?]}] (fn [] - (let [coercer-http (sm/coercer schema - :type :validation - :hint (str "invalid data received calling " uri))] - (try - (coercer-http (-> (handler) :body json/decode)) - (catch Exception e - ;; TODO Error handling - (l/error :hint "error validating json response" :cause e) - nil))))) + (let [response (handler) + status (:status response)] + (when-not status + (l/error :hint "could't do the nitrate request, it is probably down" + :uri uri) + ;; TODO decide what to do when Nitrate is inaccesible + nil) + (cond + (>= status 400) + ;; For error status codes (4xx, 5xx), fail immediately without validation + (do + (when (not= status 404) ;; Don't need to log 404 + (l/error :hint "nitrate request failed with error status" + :uri uri + :status status + :body (:body response))) + (if throw-on-error? + (ex/raise :type :nitrate-http-error + :status status + :hint (str "nitrate HTTP " status " at " uri)) + nil)) + (= status 204) ;; 204 doesn't return any body + nil + :else ;; For success status codes, validate the response + (let [coercer-http (sm/coercer schema + :type :validation + :hint (str "invalid data received calling " uri)) + data (-> response :body (json/decode :key-fn json/read-kebab-key))] + (try + (coercer-http data) + (catch Exception e + ;; TODO Error handling + (l/error :hint "error validating json response" :cause e) + nil))))))) (defn- request-to-nitrate - [cfg method uri schema {:keys [::rpc/profile-id] :as params}] + [cfg method uri schema {:keys [::rpc/profile-id request-params throw-on-error?] :as params}] (let [shared-key (-> cfg ::setup/shared-keys :nitrate) - full-http-call (-> (request-builder cfg method uri shared-key profile-id) + full-http-call (-> (request-builder cfg method uri shared-key profile-id request-params) (with-retries 3) - (with-validate uri schema))] + (with-validate uri schema :throw-on-error? throw-on-error?))] (full-http-call))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -80,11 +113,23 @@ ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(def ^:private schema:organization +(def ^:private schema:org-summary [:map [:id ::sm/uuid] [:name ::sm/text] - [:slug ::sm/text]]) + [:owner-id ::sm/uuid] + [:teams + [:vector + [:map + [:id ::sm/uuid] + [:is-your-penpot :boolean]]]]]) + +(def ^:private schema:profile-org + [:map + [:is-member :boolean] + [:organization-id {:optional true} [:maybe ::sm/uuid]] + [:default-team-id {:optional true} [:maybe ::sm/uuid]]]) + ;; TODO Unify with schemas on backend/src/app/http/management.clj (def ^:private schema:timestamp @@ -158,20 +203,158 @@ [:map [:licenses ::sm/boolean]]) -(defn- get-team-org +(defn- get-team-org-api [cfg {:keys [team-id] :as params}] (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get (str baseuri "/api/teams/" (str team-id)) schema:organization params))) + (request-to-nitrate cfg :get + (str baseuri + "/api/teams/" + team-id) + cto/schema:team-with-organization params))) -(defn- get-subscription +(defn- get-org-membership-api + [cfg {:keys [profile-id organization-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/organizations/" + organization-id + "/members/" + profile-id) + schema:profile-org params))) + +(defn- get-org-membership-by-team-api + [cfg {:keys [profile-id team-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/teams/" + team-id + "/users/" + profile-id) + schema:profile-org params))) + + +(defn- get-org-summary-api + [cfg {:keys [organization-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/organizations/" + organization-id + "/summary") + schema:org-summary params))) + +(defn- get-owned-orgs-api [cfg {:keys [profile-id] :as params}] (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get (str baseuri "/api/subscriptions/" (str profile-id)) schema:subscription params))) + (request-to-nitrate cfg :get + (str baseuri + "/api/users/" + profile-id + "/owned-organizations") + [:vector schema:org-summary] + params))) -(defn- get-connectivity +(defn- set-team-org-api + [cfg {:keys [organization-id team-id is-default] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri) + params (assoc params :request-params {:team-id team-id + :is-your-penpot (true? is-default)}) + team (request-to-nitrate cfg :post + (str baseuri + "/api/organizations/" + organization-id + "/add-team") + cto/schema:team-with-organization params) + custom-photo (when-let [logo-id (get-in team [:organization :logo-id])] + (str (cf/get :public-uri) "/assets/by-id/" logo-id))] + (cond-> team + custom-photo + (assoc-in [:organization :custom-photo] custom-photo)))) + +(defn- add-profile-to-org-api + [cfg {:keys [profile-id organization-id team-id email] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri) + request-params (cond-> {:user-id profile-id :team-id team-id} + (some? email) (assoc :email email)) + params (assoc params :request-params request-params)] + (request-to-nitrate cfg :post + (str baseuri + "/api/organizations/" + organization-id + "/add-user") + schema:profile-org params))) + +(defn- remove-profile-from-org-api + [cfg {:keys [profile-id organization-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri) + params (assoc params :request-params {:user-id profile-id})] + (request-to-nitrate cfg :post + (str baseuri + "/api/organizations/" + organization-id + "/remove-user") + nil params))) + +(defn- remove-profile-from-all-orgs-api + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :post + (str baseuri + "/api/users/" + profile-id + "/remove-organizations") + nil params))) + +(defn- remove-team-from-org-api + [cfg {:keys [team-id organization-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri) + params (assoc params :request-params {:team-id team-id})] + (request-to-nitrate cfg :post + (str baseuri + "/api/organizations/" + organization-id + "/remove-team") + nil params))) + +(defn- delete-team-api + [cfg {:keys [team-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :delete + (str baseuri + "/api/teams/" + team-id) + nil params))) + +(defn- get-subscription-api + [cfg {:keys [profile-id] :as params}] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :get + (str baseuri + "/api/subscriptions/" + profile-id) + schema:subscription params))) + +(defn- get-connectivity-api [cfg params] (let [baseuri (cf/get :nitrate-backend-uri)] - (request-to-nitrate cfg :get (str baseuri "/api/connectivity") schema:connectivity params))) + (request-to-nitrate cfg :get + (str baseuri + "/api/connectivity") + schema:connectivity params))) + +(def ^:private schema:redeem-result + [:map + [:cancel-at [:maybe schema:timestamp]]]) + +(defn- redeem-activation-code-api + [cfg params] + (let [baseuri (cf/get :nitrate-backend-uri)] + (request-to-nitrate cfg :post + (str baseuri "/api/activation-codes/redeem") + schema:redeem-result + (assoc params :throw-on-error? true)))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; INITIALIZATION @@ -180,9 +363,20 @@ (defmethod ig/init-key ::client [_ cfg] (when (contains? cf/flags :nitrate) - {:get-team-org (partial get-team-org cfg) - :get-subscription (partial get-subscription cfg) - :connectivity (partial get-connectivity cfg)})) + {:get-team-org (partial get-team-org-api cfg) + :set-team-org (partial set-team-org-api cfg) + :get-org-membership (partial get-org-membership-api cfg) + :get-org-membership-by-team (partial get-org-membership-by-team-api cfg) + :get-org-summary (partial get-org-summary-api cfg) + :get-owned-orgs (partial get-owned-orgs-api cfg) + :add-profile-to-org (partial add-profile-to-org-api cfg) + :remove-profile-from-org (partial remove-profile-from-org-api cfg) + :remove-profile-from-all-orgs (partial remove-profile-from-all-orgs-api cfg) + :delete-team (partial delete-team-api cfg) + :remove-team-from-org (partial remove-team-from-org-api cfg) + :get-subscription (partial get-subscription-api cfg) + :connectivity (partial get-connectivity-api cfg) + :redeem-activation-code (partial redeem-activation-code-api cfg)})) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; UTILS @@ -205,18 +399,18 @@ (defn add-org-info-to-team "Enriches a team map with organization information from Nitrate. - Adds organization-id, organization-name, organization-slug, and your-penpot fields. + Adds organization-id, organization-name, organization-slug, organization-owner-id, and your-penpot fields. Returns the original team unchanged if the request fails or org data is nil." [cfg team params] (try - (let [params (assoc (or params {}) :team-id (:id team)) - org (call cfg :get-team-org params)] + (let [params (assoc (or params {}) :team-id (:id team)) + team-with-org (call cfg :get-team-org params) + org (:organization team-with-org)] (if (some? org) - (assoc team - :organization-id (:id org) - :organization-name (:name org) - :organization-slug (:slug org) - :is-default (or (:is-default team) (true? (:isYourPenpot org)))) + (-> (cto/apply-organization team (assoc org :custom-photo + (when-let [logo-id (:logo-id org)] + (str (cf/get :public-uri) "/assets/by-id/" logo-id)))) + (assoc :is-default (or (:is-default team) (true? (:is-your-penpot team-with-org))))) team)) (catch Throwable cause (l/error :hint "failed to get team organization info" @@ -224,6 +418,23 @@ :cause cause) team))) -(defn connectivity - [cfg] - (call cfg :connectivity {})) +(defn set-team-organization + "Associates a team with an organization in Nitrate. + Requires organization-id and is-default in params. + Throws an exception if the request fails." + [cfg team params] + (let [params (assoc (or params {}) + :team-id (:id team) + :organization-id (:organization-id params) + :is-default (:is-default params)) + result (call cfg :set-team-org params)] + (when (nil? result) + (ex/raise :type :internal + :code :failed-to-set-team-org + :context {:team-id (:id team) + :organization-id (:organization-id params)})) + team)) + + + + diff --git a/backend/src/app/rpc/commands/auth.clj b/backend/src/app/rpc/commands/auth.clj index c3592d790c..c3d5cdf7eb 100644 --- a/backend/src/app/rpc/commands/auth.clj +++ b/backend/src/app/rpc/commands/auth.clj @@ -372,9 +372,11 @@ (throw cause)))))) (defn create-profile-rels - [conn {:keys [id] :as profile}] + [{:keys [::db/conn] :as cfg} {:keys [id] :as profile}] + (assert (db/connection-map? cfg) + "expected cfg with valid connection") (let [features (cfeat/get-enabled-features cf/flags) - team (teams/create-team conn + team (teams/create-team cfg {:profile-id id :name "Default" :features features @@ -429,7 +431,7 @@ (assoc :is-active is-active) (update :password auth/derive-password)) profile (->> (create-profile cfg params) - (create-profile-rels conn))] + (create-profile-rels cfg))] (vary-meta profile assoc :created true)))) created? (-> profile meta :created true?) diff --git a/backend/src/app/rpc/commands/demo.clj b/backend/src/app/rpc/commands/demo.clj index d4f46e750b..f3ff979113 100644 --- a/backend/src/app/rpc/commands/demo.clj +++ b/backend/src/app/rpc/commands/demo.clj @@ -49,9 +49,9 @@ :deleted-at (ct/in-future (cf/get-deletion-delay)) :password (derive-password password) :props {}} - profile (db/tx-run! cfg (fn [{:keys [::db/conn] :as cfg}] + profile (db/tx-run! cfg (fn [cfg] (->> (auth/create-profile cfg params) - (auth/create-profile-rels conn))))] + (auth/create-profile-rels cfg))))] (with-meta {:email email :password password} {::audit/profile-id (:id profile)}))) diff --git a/backend/src/app/rpc/commands/files.clj b/backend/src/app/rpc/commands/files.clj index 69c36a0e44..346ff8b0fc 100644 --- a/backend/src/app/rpc/commands/files.clj +++ b/backend/src/app/rpc/commands/files.clj @@ -13,6 +13,7 @@ [app.common.features :as cfeat] [app.common.files.helpers :as cfh] [app.common.files.migrations :as fmg] + [app.common.files.stats :as cfs] [app.common.logging :as l] [app.common.schema :as sm] [app.common.schema.desc-js-like :as-alias smdj] @@ -606,6 +607,76 @@ (get-file-summary cfg id)) +;; --- COMMAND QUERY: get-file-stats + +(def ^:private sql:file-stats-library-counts + "SELECT + (SELECT COUNT(*) + FROM file_library_rel AS flr + JOIN file AS fl ON (fl.id = flr.library_file_id) + WHERE flr.file_id = ?::uuid + AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS library_count, + (SELECT COUNT(*) + FROM file_library_rel AS flr + JOIN file AS fl ON (fl.id = flr.file_id) + WHERE flr.library_file_id = ?::uuid + AND (fl.deleted_at IS NULL OR fl.deleted_at > now())) AS referenced_by_count") + +(defn- get-file-stats-library-counts + [conn file-id] + (let [row (db/exec-one! conn [sql:file-stats-library-counts file-id file-id])] + {:library-count (or (:library-count row) 0) + :referenced-by-count (or (:referenced-by-count row) 0)})) + +(defn- get-file-stats + [{:keys [::db/conn] :as cfg} file-id] + (let [file (bfc/get-file cfg file-id) + base (binding [pmap/*load-fn* (partial feat.fdata/load-pointer cfg file-id)] + (cfs/calc-file-stats (:data file))) + lib-cnt (get-file-stats-library-counts conn file-id)] + (-> base + (merge lib-cnt) + (assoc :file-id file-id + :revn (:revn file) + :updated-at (:modified-at file))))) + +(def ^:private schema:shape-counts + [:map {:title "FileStatsShapeCounts"} + [:total [::sm/int {:min 0}]] + [:by-type [:map-of :keyword [::sm/int {:min 0}]]]]) + +(def ^:private schema:get-file-stats-result + [:map {:title "FileStats"} + [:file-id ::sm/uuid] + [:page-count [::sm/int {:min 0}]] + [:shape-counts schema:shape-counts] + [:component-count [::sm/int {:min 0}]] + [:deleted-component-count [::sm/int {:min 0}]] + [:color-count [::sm/int {:min 0}]] + [:typography-count [::sm/int {:min 0}]] + [:library-count [::sm/int {:min 0}]] + [:referenced-by-count [::sm/int {:min 0}]] + [:revn [::sm/int {:min 0}]] + [:updated-at ::ct/inst]]) + +(def ^:private schema:get-file-stats + [:map {:title "get-file-stats"} + [:id ::sm/uuid]]) + +(sv/defmethod ::get-file-stats + "Return aggregate statistics for a single file: page count, shape + counts by type, component/color/typography counts, and inbound and + outbound library reference counts. Cheap alternative to `get-file` + when only metrics are needed." + {::doc/added "2.17" + ::sm/params schema:get-file-stats + ::sm/result schema:get-file-stats-result + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id]}] + (check-read-permissions! conn profile-id id) + (get-file-stats cfg id)) + + ;; --- COMMAND QUERY: get-file-libraries (def ^:private schema:get-file-libraries @@ -1155,38 +1226,39 @@ AND t.id = ? AND f.id = ANY(?::uuid[])") -(defn- restore-file - [conn file-id] - (db/update! conn :file - {:deleted-at nil - :has-media-trimmed false} - {:id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-files + "UPDATE file SET deleted_at = null, has_media_trimmed = false + WHERE id = ANY(?::uuid[])") - (db/update! conn :file-media-object - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-media-objects + "UPDATE file_media_object SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-change - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-changes + "UPDATE file_change SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-data - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-data + "UPDATE file_data SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-thumbnail - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false}) +(def ^:private sql:restore-file-thumbnails + "UPDATE file_thumbnail SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") - (db/update! conn :file-tagged-object-thumbnail - {:deleted-at nil} - {:file-id file-id} - {::db/return-keys false})) +(def ^:private sql:restore-file-tagged-object-thumbnails + "UPDATE file_tagged_object_thumbnail SET deleted_at = null + WHERE file_id = ANY(?::uuid[])") + +(defn- restore-files + [conn file-ids] + (let [file-ids (db/create-array conn "uuid" file-ids)] + (db/exec-one! conn [sql:restore-files file-ids]) + (db/exec-one! conn [sql:restore-file-media-objects file-ids]) + (db/exec-one! conn [sql:restore-file-changes file-ids]) + (db/exec-one! conn [sql:restore-file-data file-ids]) + (db/exec-one! conn [sql:restore-file-thumbnails file-ids]) + (db/exec-one! conn [sql:restore-file-tagged-object-thumbnails file-ids]))) (def ^:private sql:restore-projects "UPDATE project SET deleted_at = null WHERE id = ANY(?::uuid[])") @@ -1207,17 +1279,18 @@ (reduce (fn [result {:keys [id project-id]}] (let [index (-> result :files count)] (events/tap :progress {:file-id id :index (inc index) :total total-files}) - (restore-file conn id) - (-> result (update :files conj id) (update :projects conj project-id)))) - - {:files #{} :projectes #{}} + {:files #{} :projects #{}} (db/plan conn [sql:resolve-editable-files team-id (db/create-array conn "uuid" ids)]))] - (restore-projects conn projects) + (when (seq files) + (restore-files conn files)) + + (when (seq projects) + (restore-projects conn projects)) files)) diff --git a/backend/src/app/rpc/commands/files_snapshot.clj b/backend/src/app/rpc/commands/files_snapshot.clj index 8325772361..7736b66cd9 100644 --- a/backend/src/app/rpc/commands/files_snapshot.clj +++ b/backend/src/app/rpc/commands/files_snapshot.clj @@ -8,6 +8,7 @@ (:require [app.binfile.common :as bfc] [app.common.exceptions :as ex] + [app.common.features :as-alias cfeat] [app.common.schema :as sm] [app.common.time :as ct] [app.db :as db] @@ -35,6 +36,43 @@ (files/check-read-permissions! conn profile-id file-id) (fsnap/get-visible-snapshots conn file-id)))) +;; --- COMMAND QUERY: get-file-snapshot + +(def ^:private schema:get-file-snapshot + [:map {:title "get-file-snapshot"} + [:file-id ::sm/uuid] + [:id ::sm/uuid] + [:features {:optional true} ::cfeat/features]]) + +(sv/defmethod ::get-file-snapshot + "Retrieve a file bundle with data from a specific snapshot for + read-only preview. Does not modify any database state." + {::doc/added "2.16" + ::sm/params schema:get-file-snapshot + ::sm/result files/schema:file-with-permissions + ::db/transaction true} + [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id file-id id] :as params}] + (let [perms (bfc/get-file-permissions conn profile-id file-id)] + (files/check-read-permissions! perms) + (let [snapshot (fsnap/get-snapshot-data cfg file-id id)] + (when-not snapshot + (ex/raise :type :not-found + :code :snapshot-not-found + :hint "unable to find snapshot with the provided id" + :snapshot-id id + :file-id file-id)) + ;; Load current file metadata only (no data decoding) then overlay + ;; the snapshot data so the client receives the same shape as a + ;; normal get-file response but with historical page/object content. + (let [base-file (bfc/get-file cfg file-id :load-data? false)] + (-> base-file + (assoc :data (:data snapshot)) + (assoc :version (:version snapshot)) + (assoc :features (:features snapshot)) + (assoc :revn (:revn snapshot)) + (assoc :vern (rand-int 100000)) + (assoc :permissions perms)))))) + (def ^:private schema:create-file-snapshot [:map [:file-id ::sm/uuid] diff --git a/backend/src/app/rpc/commands/fonts.clj b/backend/src/app/rpc/commands/fonts.clj index b47c6c2e38..e8c759fed7 100644 --- a/backend/src/app/rpc/commands/fonts.clj +++ b/backend/src/app/rpc/commands/fonts.clj @@ -98,7 +98,8 @@ [:font-id ::sm/uuid] [:font-family ::sm/text] [:font-weight [::sm/one-of {:format "number"} valid-weight]] - [:font-style [::sm/one-of {:format "string"} valid-style]]]) + [:font-style [::sm/one-of {:format "string"} valid-style]] + [:variant-name {:optional true} [:maybe ::sm/text]]]) ;; FIXME: IMPORTANT: refactor this, we should not hold a whole db ;; connection around the font creation @@ -184,6 +185,7 @@ :font-family (:font-family params) :font-weight (:font-weight params) :font-style (:font-style params) + :variant-name (:variant-name params) :woff1-file-id (:id woff1) :woff2-file-id (:id woff2) :otf-file-id (:id otf) diff --git a/backend/src/app/rpc/commands/ldap.clj b/backend/src/app/rpc/commands/ldap.clj index 5aa2a21935..c4f0f565d1 100644 --- a/backend/src/app/rpc/commands/ldap.clj +++ b/backend/src/app/rpc/commands/ldap.clj @@ -42,7 +42,7 @@ (when-not provider (ex/raise :type :restriction :code :ldap-not-initialized - :hide "ldap auth provider is not initialized")) + :hint "ldap auth provider is not initialized")) (let [info (ldap/authenticate provider params)] (when-not info @@ -84,5 +84,5 @@ (profile/get-profile-by-email conn)) (->> (assoc info :is-active true :is-demo false) (auth/create-profile cfg) - (auth/create-profile-rels conn) + (auth/create-profile-rels cfg) (profile/strip-private-attrs)))))) diff --git a/backend/src/app/rpc/commands/management.clj b/backend/src/app/rpc/commands/management.clj index 0908b358d7..d078983a27 100644 --- a/backend/src/app/rpc/commands/management.clj +++ b/backend/src/app/rpc/commands/management.clj @@ -207,8 +207,7 @@ (update :team-id bfc/lookup-index) (assoc :created-at timestamp) (assoc :modified-at timestamp))] - (db/insert! conn :team-profile-rel params - {::db/return-keys false}))) + (teams/add-profile-to-team! cfg params {::db/return-keys false}))) ;; Duplicate team fonts (doseq [font fonts] @@ -339,6 +338,21 @@ ;; --- COMMAND: Move project (defn move-project + "Moves a project from one team to another. + + Performs comprehensive validation including: + - Permission checks on both source and destination teams + - Team compatibility verification between source and destination + - File features compatibility with destination team + + The operation also: + - Updates the project's team assignment + - Cleans up any broken library relations after the move + + Throws: + - :cant-move-to-same-team if trying to move project to its current team + - Permission exceptions if user lacks required permissions + - Team compatibility exceptions if teams are incompatible" [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id project-id] :as params}] (let [project (db/get-by-id conn :project project-id {:columns [:id :team-id]}) pids (->> (db/query conn :project {:team-id (:team-id project)} {:columns [:id]}) diff --git a/backend/src/app/rpc/commands/media.clj b/backend/src/app/rpc/commands/media.clj index 5bea17d379..22fedd39b9 100644 --- a/backend/src/app/rpc/commands/media.clj +++ b/backend/src/app/rpc/commands/media.clj @@ -255,7 +255,7 @@ [:session-id ::sm/uuid]]) (sv/defmethod ::create-upload-session - {::doc/added "2.16" + {::doc/added "2.17" ::sm/params schema:create-upload-session ::sm/result schema:create-upload-session-result} [{:keys [::db/pool] :as cfg} @@ -293,7 +293,7 @@ [:index ::sm/int]]) (sv/defmethod ::upload-chunk - {::doc/added "2.16" + {::doc/added "2.17" ::sm/params schema:upload-chunk ::sm/result schema:upload-chunk-result} [{:keys [::db/pool] :as cfg} @@ -389,7 +389,7 @@ [:id {:optional true} ::sm/uuid]]) (sv/defmethod ::assemble-file-media-object - {::doc/added "2.16" + {::doc/added "2.17" ::sm/params schema:assemble-file-media-object ::climit/id [[:process-image/by-profile ::rpc/profile-id] [:process-image/global]]} diff --git a/backend/src/app/rpc/commands/nitrate.clj b/backend/src/app/rpc/commands/nitrate.clj index 5313817fd3..91f58f6448 100644 --- a/backend/src/app/rpc/commands/nitrate.clj +++ b/backend/src/app/rpc/commands/nitrate.clj @@ -1,20 +1,319 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + (ns app.rpc.commands.nitrate + "Nitrate API for Penpot. Provides nitrate-related endpoints to be called + from Penpot frontend." (:require + [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.time :as ct] + [app.db :as db] [app.nitrate :as nitrate] [app.rpc :as-alias rpc] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] + [app.rpc.notifications :as notifications] [app.util.services :as sv])) +(defn assert-is-owner [cfg profile-id team-id] + (let [perms (teams/get-permissions cfg profile-id team-id)] + (when-not (:is-owner perms) + (ex/raise :type :validation + :code :insufficient-permissions)))) + +(defn assert-not-default-team [cfg team-id] + (let [team (teams/get-team-info cfg {:id team-id})] + (when (:is-default team) + (ex/raise :type :validation + :code :cant-move-default-team)))) + +(defn assert-membership [cfg profile-id organization-id] + (let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id + :organization-id organization-id})] + (when-not (:organization-id membership) + (ex/raise :type :validation + :code :organization-doesnt-exists)) + + (when-not (:is-member membership) + (ex/raise :type :validation + :code :user-doesnt-belong-organization)))) + + (def schema:connectivity [:map {:title "nitrate-connectivity"} [:licenses ::sm/boolean]]) (sv/defmethod ::get-nitrate-connectivity - {::rpc/auth false - ::doc/added "1.18" + {::rpc/auth true + ::doc/added "2.14" ::sm/params [:map] ::sm/result schema:connectivity} [cfg _params] - (nitrate/connectivity cfg)) + (nitrate/call cfg :connectivity {})) + +(def ^:private schema:redeem-activation-code-params + [:map {:title "RedeemActivationCodeParams"} + [:activation-code ::sm/text]]) + +(def ^:private schema:redeem-activation-code-result + [:map {:title "RedeemActivationCodeResult"} + [:cancel-at [:maybe ct/schema:inst]]]) + +(sv/defmethod ::redeem-nitrate-activation-code + {::rpc/auth true + ::doc/added "2.14" + ::sm/params schema:redeem-activation-code-params + ::sm/result schema:redeem-activation-code-result} + [cfg {:keys [::rpc/profile-id activation-code]}] + (let [profile (db/get cfg :profile {:id profile-id})] + (try + (let [result (nitrate/call cfg :redeem-activation-code + {:request-params {:code activation-code + :penpot-id profile-id + :email (:email profile)}})] + (when-not result + (ex/raise :type :validation + :code :invalid-activation-code + :hint "The activation code is invalid, expired or fully redeemed")) + result) + (catch Exception cause + (let [{:keys [type status]} (ex-data cause)] + (if (= type :nitrate-http-error) + (ex/raise :type :validation + :code (case status + 410 :expired-activation-code + :invalid-activation-code) + :cause cause) + (throw cause))))))) + +(def ^:private sql:prefix-team-name-and-unset-default + "UPDATE team + SET name = ? || name, + is_default = FALSE + WHERE id = ?;") + +(def ^:private sql:get-member-teams-info + "SELECT t.id, + t.is_default, + tpr.is_owner, + (SELECT count(*) FROM team_profile_rel WHERE team_id = t.id) AS num_members, + (SELECT array_agg(profile_id) FROM team_profile_rel WHERE team_id = t.id) AS member_ids + FROM team AS t + JOIN team_profile_rel AS tpr ON (tpr.team_id = t.id) + WHERE tpr.profile_id = ? + AND t.id = ANY(?) + AND t.deleted_at IS NULL") + +(def sql:get-team-files-count + "SELECT count(*) AS total + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + WHERE p.team_id = ? + AND f.deleted_at IS NULL") + +(def ^:private schema:leave-org + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:default-team-id ::sm/uuid] + [:teams-to-delete + [:vector ::sm/uuid]] + [:teams-to-leave + [:vector + [:map + [:id ::sm/uuid] + [:reassign-to {:optional true} ::sm/uuid]]]]]) + + +(defn- get-organization-teams-for-user + [{:keys [::db/conn] :as cfg} org-summary profile-id] + (let [org-team-ids (->> (:teams org-summary) + (map :id)) + ids-array (db/create-array conn "uuid" org-team-ids)] + (db/exec! conn [sql:get-member-teams-info profile-id ids-array]))) + +(defn- calculate-valid-teams + ([org-teams default-team-id] + (let [;; valid default team is the one which id is default-team-id + valid-default-team (d/seek #(= default-team-id (:id %)) org-teams) + + ;; Remove your-penpot for the rest of validations + org-teams (remove #(= default-team-id (:id %)) org-teams) + + ;; valid teams to delete are those that the user is owner, and only have one member + valid-teams-to-delete-ids (->> org-teams + (filter #(and (:is-owner %) + (= (:num-members %) 1))) + (map :id) + (into #{})) + ;; valid teams to transfer are those that the user is owner, and have more than one member + valid-teams-to-transfer (->> org-teams + (filter #(and (:is-owner %) + (> (:num-members %) 1)))) + + ;; valid teams to exit are those that the user isn't owner, and have more than one member + valid-teams-to-exit (->> org-teams + (filter #(and (not (:is-owner %)) + (> (:num-members %) 1))))] + {:valid-teams-to-delete-ids valid-teams-to-delete-ids + :valid-teams-to-transfer valid-teams-to-transfer + :valid-teams-to-exit valid-teams-to-exit + :valid-default-team valid-default-team}))) + +(defn get-valid-teams [cfg organization-id profile-id default-team-id] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + org-teams (get-organization-teams-for-user cfg org-summary profile-id)] + (calculate-valid-teams org-teams default-team-id))) + +(defn- assert-valid-teams [cfg profile-id organization-id default-team-id teams-to-delete teams-to-leave] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + org-teams (get-organization-teams-for-user cfg org-summary profile-id) + {:keys [valid-teams-to-delete-ids + valid-teams-to-transfer + valid-teams-to-exit + valid-default-team]} (calculate-valid-teams org-teams default-team-id) + + + + valid-teams-to-exit-ids (->> valid-teams-to-exit (map :id) (into #{})) + valid-teams-to-transfer-ids (->> valid-teams-to-transfer (map :id) (into #{})) + valid-teams-to-leave-ids (into valid-teams-to-transfer-ids valid-teams-to-exit-ids) + + valid-default-team-id? (some? valid-default-team) + + + + valid-teams-to-delete? (= valid-teams-to-delete-ids (into #{} teams-to-delete)) + + ;; for every team in teams-to-leave, check that: + ;; - if it has a reassign-to, it belongs to valid-teams-to-transfer and + ;; the reassign-to is a member of the team and not the current user; + ;; - if it hasn't a reassign-to, check that it belongs to valid-teams-to-exit + teams-by-id (d/index-by :id org-teams) + valid-teams-to-leave? (and + (= valid-teams-to-leave-ids (->> teams-to-leave (map :id) (into #{}))) + (every? (fn [{:keys [id reassign-to]}] + (if reassign-to + (let [members (db/pgarray->set (:member-ids (get teams-by-id id)))] + (and (contains? valid-teams-to-transfer-ids id) + (not= reassign-to profile-id) + (contains? members reassign-to))) + (contains? valid-teams-to-exit-ids id))) + teams-to-leave))] + ;; the org owner cannot leave + (when (= (:owner-id org-summary) profile-id) + (ex/raise :type :validation + :code :org-owner-cannot-leave)) + + (when (or + (not valid-teams-to-delete?) + (not valid-teams-to-leave?) + (not valid-default-team-id?)) + (ex/raise :type :validation + :code :not-valid-teams)))) + + +(defn leave-org + [{:keys [::db/conn] :as cfg} {:keys [profile-id id name default-team-id teams-to-delete teams-to-leave skip-validation] :as params}] + (let [org-prefix (str "[" (d/sanitize-string name) "] ") + + default-team-files-count (-> (db/exec-one! conn [sql:get-team-files-count default-team-id]) + :total) + delete-default-team? (= default-team-files-count 0)] + + + + + ;; assert that the received teams are valid, checking the different constraints + (when-not skip-validation + (assert-valid-teams cfg profile-id id default-team-id teams-to-delete teams-to-leave)) + + (assert-membership cfg profile-id id) + + ;; delete the teams-to-delete + (doseq [id teams-to-delete] + (teams/delete-team cfg {:profile-id profile-id :team-id id})) + + ;; leave the teams-to-leave + (doseq [{:keys [id reassign-to]} teams-to-leave] + (teams/leave-team cfg {:profile-id profile-id :id id :reassign-to reassign-to})) + + ;; Delete default-team-id if empty; otherwise keep it and prefix the name. + (if delete-default-team? + (do + (db/update! conn :team {:is-default false} {:id default-team-id}) + (teams/delete-team cfg {:profile-id profile-id :team-id default-team-id})) + (db/exec! conn [sql:prefix-team-name-and-unset-default org-prefix default-team-id])) + + ;; Api call to nitrate + (nitrate/call cfg :remove-profile-from-org {:profile-id profile-id :organization-id id}) + + nil)) + + +(sv/defmethod ::leave-org + {::rpc/auth true + ::doc/added "2.15" + ::sm/params schema:leave-org + ::db/transaction true} + [cfg {:keys [::rpc/profile-id] :as params}] + (leave-org cfg (assoc params :profile-id profile-id))) + + +(def ^:private schema:remove-team-from-org + [:map + [:team-id ::sm/uuid] + [:organization-id ::sm/uuid] + [:organization-name ::sm/text]]) + +(sv/defmethod ::remove-team-from-org + {::doc/added "2.17" + ::sm/params schema:remove-team-from-org} + [cfg {:keys [::rpc/profile-id team-id organization-id organization-name]}] + + (assert-is-owner cfg profile-id team-id) + (assert-not-default-team cfg team-id) + (assert-membership cfg profile-id organization-id) + + ;; Api call to nitrate + (nitrate/call cfg :remove-team-from-org {:team-id team-id :organization-id organization-id}) + + ;; Notify connected users + (notifications/notify-team-change cfg {:id team-id :organization {:name organization-name}} "dashboard.team-no-longer-belong-org") + nil) + + +(def ^:private schema:add-team-to-organization + [:map + [:team-id ::sm/uuid] + [:organization-id ::sm/uuid]]) + +(sv/defmethod ::add-team-to-organization + {::rpc/auth true + ::doc/added "2.17" + ::sm/params schema:add-team-to-organization + ::db/transaction true} + [cfg {:keys [::rpc/profile-id team-id organization-id]}] + + (assert-is-owner cfg profile-id team-id) + (assert-not-default-team cfg team-id) + (assert-membership cfg profile-id organization-id) + + (let [team-members (db/query cfg :team-profile-rel {:team-id team-id})] + ;; Add teammates to the org if needed + (doseq [{member-id :profile-id} team-members + :when (not= member-id profile-id)] + (teams/initialize-user-in-nitrate-org cfg member-id organization-id))) + + ;; Api call to nitrate + (let [team (nitrate/call cfg :set-team-org {:team-id team-id :organization-id organization-id :is-default false})] + + ;; Notify connected users + (notifications/notify-team-change cfg team "dashboard.team-belong-org")) + nil) diff --git a/backend/src/app/rpc/commands/profile.clj b/backend/src/app/rpc/commands/profile.clj index 10f21d8080..ed09d90586 100644 --- a/backend/src/app/rpc/commands/profile.clj +++ b/backend/src/app/rpc/commands/profile.clj @@ -315,6 +315,25 @@ (climit/invoke! generate-thumbnail file))] (sto/put-object! storage params))) +;; --- MUTATION: Delete Photo + +(sv/defmethod ::delete-profile-photo + {::doc/added "2.17" + ::sm/params [:map] + ::sm/result :nil + ::db/transaction true} + [{:keys [::db/conn ::sto/storage]} {:keys [::rpc/profile-id]}] + (let [profile (get-profile conn profile-id ::db/for-update true)] + (when-let [id (:photo-id profile)] + (sto/touch-object! storage id)) + + (db/update! conn :profile + {:photo-id nil} + {:id profile-id} + {::db/return-keys false}) + + nil)) + ;; --- MUTATION: Request Email Change (declare ^:private request-email-change!) @@ -463,6 +482,9 @@ {:deleted-at deleted-at} {:id profile-id}) + ;; Api call to nitrate + (nitrate/call cfg :remove-profile-from-all-orgs {:profile-id profile-id}) + ;; Schedule cascade deletion to a worker (wrk/submit! {::db/conn conn ::wrk/task :delete-object diff --git a/backend/src/app/rpc/commands/teams.clj b/backend/src/app/rpc/commands/teams.clj index 93243ffee4..359b79c841 100644 --- a/backend/src/app/rpc/commands/teams.clj +++ b/backend/src/app/rpc/commands/teams.clj @@ -471,8 +471,8 @@ ;; --- COMMAND QUERY: get-team-info (defn get-team-info - [{:keys [::db/conn] :as cfg} {:keys [id] :as params}] - (-> (db/get* conn :team + [cfg {:keys [id] :as params}] + (-> (db/get* cfg :team {:id id} {::sql/columns [:id :is-default :features]}) (decode-row))) @@ -499,7 +499,9 @@ [:map {:title "create-team"} [:name types.team/schema:team-name] [:features {:optional true} ::cfeat/features] - [:id {:optional true} ::sm/uuid]]) + [:id {:optional true} ::sm/uuid] + [:organization-id {:optional true} ::sm/uuid] + [:is-default {:optional true} :boolean]]) (sv/defmethod ::create-team {::doc/added "1.17" @@ -520,17 +522,89 @@ (with-meta team {::audit/props {:id (:id team)}}))) + +(defn create-default-org-team + [cfg profile-id organization-id] + (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile + ::quotes/profile-id profile-id}) + + (let [features (-> (cfeat/get-enabled-features cf/flags) + (set/difference cfeat/frontend-only-features) + (set/difference cfeat/no-team-inheritable-features)) + params {:profile-id profile-id + :name "Your Penpot" + :features features + :organization-id organization-id + :is-default true} + team (create-team cfg params)] + (select-keys team [:id]))) + +(defn initialize-user-in-nitrate-org + "If needed, create a default team for the user on the organization, + and notify Nitrate that an user has been added to an org." + ([cfg profile-id organization-id] + (initialize-user-in-nitrate-org cfg profile-id organization-id nil)) + ([cfg profile-id organization-id email] + (assert (db/connection-map? cfg) + "expected cfg with valid connection") + (when (contains? cf/flags :nitrate) + (db/tx-run! + cfg + (fn [{:keys [::db/conn] :as tx-cfg}] + + (let [membership (nitrate/call cfg :get-org-membership {:profile-id profile-id + :organization-id organization-id})] + ;; Only when the user doesn't belong to the organization yet + (when (and + (some? (:organization-id membership)) ;; the organization exists + (not (:is-member membership))) ;; the user is not a member of the org yet + + + (let [organization-id organization-id + default-team (create-default-org-team (assoc tx-cfg ::db/conn conn) profile-id organization-id) + default-team-id (:id default-team) + result (nitrate/call tx-cfg :add-profile-to-org (cond-> {:profile-id profile-id + :team-id default-team-id + :organization-id organization-id} + (some? email) (assoc :email email)))] + (when (not (:is-member result)) + (ex/raise :type :internal + :code :failed-add-profile-org-nitrate + :context {:profile-id profile-id + :organization-id organization-id + :default-team-id default-team-id})) + default-team-id)))))))) + +(defn add-profile-to-team! + ([cfg params] + (add-profile-to-team! cfg params nil)) + ([{:keys [::db/conn] :as cfg} {:keys [:profile-id :team-id] :as params} options] + (assert (db/connection-map? cfg) + "expected cfg with valid connection") + (when (contains? cf/flags :nitrate) + (let [membership (nitrate/call cfg :get-org-membership-by-team {:profile-id profile-id :team-id team-id})] + ;; Only when the team belong to an organization and the user is not a member + (when (and + (some? (:organization-id membership)) ;; the team do belong to an organization + (not (:is-member membership))) ;; the user is not a member of the org yet + (initialize-user-in-nitrate-org cfg profile-id (:organization-id membership))))) + (db/insert! conn :team-profile-rel params options))) + (defn create-team "This is a complete team creation process, it creates the team object and all related objects (default role and default project)." - [cfg-or-conn params] - (let [conn (db/get-connection cfg-or-conn) - team (create-team* conn params) + [{:keys [::db/conn] :as cfg} params] + (assert (db/connection-map? cfg) + "expected cfg with valid connection") + (let [team (create-team* conn params) params (assoc params :team-id (:id team) :role :owner) project (create-team-default-project conn params)] - (create-team-role conn params) + (create-team-role cfg params) + ;; Set team organization in Nitrate if organization-id is provided + (when (and (contains? cf/flags :nitrate) (:organization-id params)) + (nitrate/set-team-organization cfg team params)) (assoc team :default-project-id (:id project)))) (defn- create-team* @@ -546,11 +620,13 @@ (decode-row team))) (defn- create-team-role - [conn {:keys [profile-id team-id role] :as params}] + [cfg {:keys [profile-id team-id role] :as params}] + (assert (db/connection-map? cfg) + "expected cfg with valid connection") (let [params {:team-id team-id :profile-id profile-id}] (->> (perms/assign-role-flags params role) - (db/insert! conn :team-profile-rel)))) + (add-profile-to-team! cfg)))) (defn- create-team-default-project [conn {:keys [profile-id team-id] :as params}] @@ -609,7 +685,7 @@ ;; --- Mutation: Leave Team (defn leave-team - [conn {:keys [profile-id id reassign-to]}] + [{:keys [::db/conn ::mbus/msgbus]} {:keys [profile-id id reassign-to]}] (let [perms (get-permissions conn profile-id id) members (get-team-members conn id)] @@ -624,7 +700,9 @@ ;; if the `reassign-to` is filled and has a different value ;; than the current profile-id, we proceed to reassing the ;; owner role to profile identified by the `reassign-to`. - (and reassign-to (not= reassign-to profile-id)) + ;; Ignore the reasignation if the current profile is not + ;; the owner + (and reassign-to (not= reassign-to profile-id) (:is-owner perms)) (let [member (d/seek #(= reassign-to (:id %)) members)] (when-not member (ex/raise :type :not-found :code :member-does-not-exist)) @@ -638,7 +716,15 @@ ;; assign owner role to new profile (db/update! conn :team-profile-rel (get types.team/permissions-for-role :owner) - {:team-id id :profile-id reassign-to})) + {:team-id id :profile-id reassign-to}) + + ;; notify new owner + (mbus/pub! msgbus + :topic reassign-to + :message {:type :team-role-change + :topic reassign-to + :team-id id + :role :owner})) ;; and finally, if all other conditions does not match and the ;; current profile is owner, we dont allow it because there @@ -663,32 +749,44 @@ {::doc/added "1.17" ::sm/params schema:leave-team ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id] :as params}] - (leave-team conn (assoc params :profile-id profile-id))) + [cfg {:keys [::rpc/profile-id] :as params}] + (leave-team cfg (assoc params :profile-id profile-id))) + ;; --- Mutation: Delete Team -(defn- delete-team +(defn delete-team "Mark a team for deletion" - [conn {:keys [id] :as team}] + [{:keys [::db/conn] :as cfg} {:keys [profile-id team-id]}] - (let [delay (ldel/get-deletion-delay team) - team (db/update! conn :team - {:deleted-at (ct/in-future delay)} - {:id id} - {::db/return-keys true})] + (let [team (get-team conn :profile-id profile-id :team-id team-id) + perms (get team :permissions)] + + (when-not (:is-owner perms) + (ex/raise :type :validation + :code :only-owner-can-delete-team)) (when (:is-default team) (ex/raise :type :validation :code :non-deletable-team :hint "impossible to delete default team")) - (wrk/submit! {::db/conn conn - ::wrk/task :delete-object - ::wrk/params {:object :team - :deleted-at (:deleted-at team) - :id id}}) - team)) + (let [delay (ldel/get-deletion-delay team) + team (db/update! conn :team + {:deleted-at (ct/in-future delay)} + {:id team-id} + {::db/return-keys true})] + + ;; Api call to nitrate + (when (contains? cf/flags :nitrate) + (nitrate/call cfg :delete-team {:profile-id profile-id :team-id team-id})) + + (wrk/submit! {::db/conn conn + ::wrk/task :delete-object + ::wrk/params {:object :team + :deleted-at (:deleted-at team) + :id team-id}}) + team))) (def ^:private schema:delete-team [:map {:title "delete-team"} @@ -698,16 +796,9 @@ {::doc/added "1.17" ::sm/params schema:delete-team ::db/transaction true} - [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id id] :as params}] - (let [team (get-team conn :profile-id profile-id :team-id id) - perms (get team :permissions)] - - (when-not (:is-owner perms) - (ex/raise :type :validation - :code :only-owner-can-delete-team)) - - (delete-team conn team) - nil)) + [cfg {:keys [::rpc/profile-id id] :as params}] + (delete-team cfg {:team-id id :profile-id profile-id}) + nil) ;; --- Mutation: Team Update Role diff --git a/backend/src/app/rpc/commands/teams_invitations.clj b/backend/src/app/rpc/commands/teams_invitations.clj index dfc83000a5..abf0b934b4 100644 --- a/backend/src/app/rpc/commands/teams_invitations.clj +++ b/backend/src/app/rpc/commands/teams_invitations.clj @@ -22,6 +22,7 @@ [app.email.blacklist :as email.blacklist] [app.loggers.audit :as audit] [app.main :as-alias main] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] @@ -36,20 +37,29 @@ ;; --- Mutation: Create Team Invitation (def sql:upsert-team-invitation - "insert into team_invitation(id, team_id, email_to, created_by, role, valid_until) - values (?, ?, ?, ?, ?, ?) + "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until) + values (?, ?, null, ?, ?, ?, ?) on conflict(team_id, email_to) do update set role = ?, valid_until = ?, updated_at = now() returning *") +(def sql:upsert-org-invitation + "insert into team_invitation(id, team_id, org_id, email_to, created_by, role, valid_until) + values (?, null, ?, ?, ?, ?, ?) + on conflict(org_id, email_to) where team_id is null do + update set role = ?, valid_until = ?, updated_at = now() + returning *") + (defn- create-invitation-token - [cfg {:keys [profile-id valid-until team-id member-id member-email role]}] + [cfg {:keys [profile-id valid-until organization-id organization-name team-id member-id member-email role]}] (tokens/generate cfg {:iss :team-invitation :exp valid-until :profile-id profile-id :role role :team-id team-id + :organization-id organization-id + :organization-name organization-name :member-email member-email :member-id member-id})) @@ -75,19 +85,41 @@ [:role types.team/schema:role] [:email ::sm/email]]) +(def ^:private schema:create-org-invitation + [:map {:title "params:create-org-invitation"} + [::rpc/profile-id ::sm/uuid] + [:organization + [:map + [:id ::sm/uuid] + [:name :string] + [:initials [:maybe :string]] + [:logo ::sm/uri]]] + [:profile + [:map + [:id ::sm/uuid] + [:fullname :string]]] + [:role types.team/schema:role] + [:email ::sm/email]]) + (def ^:private check-create-invitation-params (sm/check-fn schema:create-invitation)) +(def ^:private check-create-org-invitation-params + (sm/check-fn schema:create-org-invitation)) + (defn- allow-invitation-emails? [member] (let [notifications (dm/get-in member [:props :notifications])] (not= :none (:email-invites notifications)))) (defn- create-invitation - [{:keys [::db/conn] :as cfg} {:keys [team profile role email] :as params}] + [{:keys [::db/conn] :as cfg} {:keys [team organization profile role email] :as params}] - (assert (db/connection? conn) "expected valid connection on cfg parameter") - (assert (check-create-invitation-params params)) + (assert (db/connection-map? cfg) + "expected cfg with valid connection") + (if organization + (assert (check-create-org-invitation-params params)) + (assert (check-create-invitation-params params))) (let [email (profile/clean-email email) member (profile/get-profile-by-email conn email)] @@ -110,9 +142,12 @@ :profile-id (:id member)} (get types.team/permissions-for-role role))] - ;; Insert the invited member to the team - (db/insert! conn :team-profile-rel params - {::db/on-conflict-do-nothing? true}) + (if organization + ;; Insert the invited member to the org + (when (contains? cf/flags :nitrate) + (teams/initialize-user-in-nitrate-org cfg (:id member) (:id organization) email)) + ;; Insert the invited member to the team + (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true})) ;; If profile is not yet verified, mark it as verified because ;; accepting an invitation link serves as verification. @@ -129,18 +164,30 @@ (teams/check-email-spam conn email true) (let [id (uuid/next) - expire (ct/in-future "168h") ;; 7 days - invitation (db/exec-one! conn [sql:upsert-team-invitation id - (:id team) (str/lower email) - (:id profile) - (name role) expire - (name role) expire]) + expire (if organization + (ct/in-future "876000h") ;; Organization invitations doesn't expire + (ct/in-future "168h")) ;; 7 days + invitation (db/exec-one! conn (if organization + [sql:upsert-org-invitation id + (:id organization) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire] + [sql:upsert-team-invitation id + (:id team) + (str/lower email) + (:id profile) + (name role) expire + (name role) expire])) updated? (not= id (:id invitation)) profile-id (:id profile) tprops {:profile-id profile-id :invitation-id (:id invitation) :valid-until expire :team-id (:id team) + :organization-id (:id organization) + :organization-name (:name organization) :member-email (:email-to invitation) :member-id (:id member) :role role} @@ -152,28 +199,58 @@ (let [props (-> (dissoc tprops :profile-id) (audit/clean-props)) - evname (if updated? - "update-team-invitation" - "create-team-invitation") + evname (cond + (and updated? organization) "update-org-invitation" + updated? "update-team-invitation" + organization "create-org-invitation" + :else "create-team-invitation") event (-> (audit/event-from-rpc-params params) (assoc ::audit/name evname) (assoc ::audit/props props))] (audit/submit! cfg event)) (when (allow-invitation-emails? member) - (eml/send! {::eml/conn conn - ::eml/factory eml/invite-to-team - :public-uri (cf/get :public-uri) - :to email - :invited-by (:fullname profile) - :team (:name team) - :token itoken - :extra-data ptoken})) + (if organization + (when (contains? cf/flags :nitrate) + (eml/send! {::eml/conn conn + ::eml/factory eml/invite-to-org + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :user-name (:fullname member) + :organization-name (:name organization) + :organization-logo (:logo organization) + :organization-initials (:initials organization) + :token itoken + :extra-data ptoken})) + (let [team (if (contains? cf/flags :nitrate) + (nitrate/add-org-info-to-team cfg team {}) + team)] + (eml/send! {::eml/conn conn + ::eml/factory eml/invite-to-team + :public-uri (cf/get :public-uri) + :to email + :invited-by (:fullname profile) + :team (:name team) + :organization (:organization-name team) + :token itoken + :extra-data ptoken})))) itoken))))) +(defn create-org-invitation + [cfg {:keys [::rpc/profile-id id name initials logo] :as params}] + (let [profile (db/get-by-id cfg :profile profile-id)] + (create-invitation cfg + (assoc params + :organization {:id id :name name :initials initials :logo logo} + :profile profile + :role :editor)))) + (defn- add-member-to-team - [conn profile team role member] + [{:keys [::db/conn] :as cfg} profile team role member] + (assert (db/connection-map? cfg) + "expected cfg with valid connection") (let [team-id (:id team) params (merge @@ -193,7 +270,7 @@ ::quotes/team-id team-id}) ;; Insert the member to the team - (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) + (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) ;; Delete any request (db/delete! conn :team-access-request @@ -275,7 +352,7 @@ (filter #(contains? invitation-emails (key %))) (map (fn [[email member]] (let [role (:role (first (filter #(= (:email %) email) invitation-data)))] - (add-member-to-team conn profile team role member)))) + (add-member-to-team cfg profile team role member)))) (doall)) invitations)) diff --git a/backend/src/app/rpc/commands/verify_token.clj b/backend/src/app/rpc/commands/verify_token.clj index a3454f7135..e25be628ad 100644 --- a/backend/src/app/rpc/commands/verify_token.clj +++ b/backend/src/app/rpc/commands/verify_token.clj @@ -16,8 +16,10 @@ [app.http.session :as session] [app.loggers.audit :as audit] [app.main :as-alias main] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.profile :as profile] + [app.rpc.commands.teams :as teams] [app.rpc.doc :as-alias doc] [app.rpc.helpers :as rph] [app.rpc.quotes :as quotes] @@ -86,52 +88,74 @@ ;; --- Team Invitation (defn- accept-invitation - [{:keys [::db/conn] :as cfg} {:keys [team-id role member-email] :as claims} invitation member] + [{:keys [::db/conn] :as cfg} + {:keys [team-id organization-id role member-email] :as claims} invitation member] (let [;; Update the role if there is an invitation role (or (some-> invitation :role keyword) role) - params (merge - {:team-id team-id - :profile-id (:id member)} - (get types.team/permissions-for-role role))] + id-member (:id member)] ;; Do not allow blocked users accept invitations. (when (:is-blocked member) (ex/raise :type :restriction :code :profile-blocked)) - (quotes/check! cfg {::quotes/id ::quotes/profiles-per-team - ::quotes/profile-id (:id member) - ::quotes/team-id team-id}) + (when team-id + (quotes/check! cfg {::quotes/id ::quotes/profiles-per-team + ::quotes/profile-id id-member + ::quotes/team-id team-id})) - ;; Insert the invited member to the team - (db/insert! conn :team-profile-rel params {::db/on-conflict-do-nothing? true}) + (let [params (merge + {:team-id team-id + :profile-id id-member} + (get types.team/permissions-for-role role)) - ;; If profile is not yet verified, mark it as verified because - ;; accepting an invitation link serves as verification. - (when-not (:is-active member) - (db/update! conn :profile - {:is-active true} - {:id (:id member)})) + accepted-team-id (if organization-id + ;; Insert the invited member to the org + (when (contains? cf/flags :nitrate) + (teams/initialize-user-in-nitrate-org cfg id-member organization-id member-email)) + ;; Insert the invited member to the team + (do (teams/add-profile-to-team! cfg params {::db/on-conflict-do-nothing? true}) + team-id))] - ;; Delete the invitation - (db/delete! conn :team-invitation - {:team-id team-id :email-to member-email}) + (when-not accepted-team-id + (ex/raise :type :internal + :code :accept-invitation-failed + :hint "the accept invitation has failed")) - ;; Delete any request - (db/delete! conn :team-access-request - {:team-id team-id :requester-id (:id member)}) - (assoc member :is-active true))) + ;; If profile is not yet verified, mark it as verified because + ;; accepting an invitation link serves as verification. + (when-not (:is-active member) + (db/update! conn :profile + {:is-active true} + {:id id-member})) + + ;; Delete the invitation + (db/delete! conn :team-invitation + (cond-> {:email-to member-email} + team-id (assoc :team-id team-id) + organization-id (assoc :org-id organization-id))) + + ;; Delete any request (only applicable for team invitations) + (when team-id + (db/delete! conn :team-access-request + {:team-id team-id :requester-id id-member})) + + accepted-team-id))) (def schema:team-invitation-claims - [:map {:title "TeamInvitationClaims"} - [:iss :keyword] - [:exp ::ct/inst] - [:profile-id ::sm/uuid] - [:role types.team/schema:role] - [:team-id ::sm/uuid] - [:member-email ::sm/email] - [:member-id {:optional true} ::sm/uuid]]) + [:and + [:map {:title "TeamInvitationClaims"} + [:iss :keyword] + [:exp ::ct/inst] + [:profile-id ::sm/uuid] + [:role types.team/schema:role] + [:team-id {:optional true} ::sm/uuid] + [:organization-id {:optional true} ::sm/uuid] + [:member-email ::sm/email] + [:member-id {:optional true} ::sm/uuid]] + [:fn {:error/message "team-id or organization-id must be present"} + (fn [m] (or (:team-id m) (:organization-id m)))]]) (def valid-team-invitation-claims? (sm/lazy-validator schema:team-invitation-claims)) @@ -139,7 +163,7 @@ (defmethod process-token :team-invitation [{:keys [::db/conn] :as cfg} {:keys [::rpc/profile-id token] :as params} - {:keys [member-id team-id member-email] :as claims}] + {:keys [member-id team-id organization-id member-email] :as claims}] (when-not (valid-team-invitation-claims? claims) (ex/raise :type :validation @@ -147,19 +171,45 @@ :hint "invitation token contains unexpected data")) (let [invitation (db/get* conn :team-invitation - {:team-id team-id :email-to member-email}) + (cond-> {:email-to member-email} + team-id (assoc :team-id team-id) + organization-id (assoc :org-id organization-id))) profile (db/get* conn :profile {:id profile-id} - {:columns [:id :email]}) - registration-disabled? (not (contains? cf/flags :registration))] - (when (nil? invitation) - (ex/raise :type :validation - :code :invalid-token - :hint "no invitation associated with the token")) + {:columns [:id :email :default-team-id]}) + registration-disabled? (not (contains? cf/flags :registration)) + + org-invitation? (and (contains? cf/flags :nitrate) organization-id) + membership (when org-invitation? + (nitrate/call cfg :get-org-membership {:profile-id profile-id + :organization-id organization-id}))] + + (if profile + (do + (when-not (or (= member-id profile-id) + (= member-email (:email profile))) + (ex/raise :type :validation + :code :invalid-token + :reason :email-mismatch + :hint "logged-in user does not matches the invitation")) + + (when (:is-member membership) + (ex/raise :type :validation + :code :already-an-org-member + :team-id (:default-team-id membership) + :hint "the user is already a member of the organization")) + + (when (and org-invitation? (not (:organization-id membership))) + (ex/raise :type :validation + :code :org-not-found + :team-id (:default-team-id profile) + :hint "the organization doesn't exist")) + + (when (nil? invitation) + (ex/raise :type :validation + :code :invalid-token + :hint "no invitation associated with the token")) - (if (some? profile) - (if (or (= member-id profile-id) - (= member-email (:email profile))) ;; if we have logged-in user and it matches the invitation we proceed ;; with accepting the invitation and joining the current profile to the @@ -187,17 +237,16 @@ :profile-id (:id profile) :email (:email profile)))))) - (accept-invitation cfg claims invitation profile) - (assoc claims :state :created)) - - (ex/raise :type :validation - :code :invalid-token - :hint "logged-in user does not matches the invitation")) + (let [accepted-team-id (accept-invitation cfg claims invitation profile)] + (cond-> (assoc claims :state :created) + ;; when the invitation is to an org, instead of a team, add the + ;; accepted-team-id as :org-team-id + (:organization-id claims) + (assoc :org-team-id accepted-team-id))))) ;; If we have not logged-in user, and invitation comes with member-id we ;; redirect user to login, if no memeber-id is present and in the invitation ;; token and registration is enabled, we redirect user the the register page. - {:invitation-token token :iss :team-invitation :redirect-to (if (or member-id registration-disabled?) :auth-login :auth-register) diff --git a/backend/src/app/rpc/commands/viewer.clj b/backend/src/app/rpc/commands/viewer.clj index d2b191aeb4..37adca244f 100644 --- a/backend/src/app/rpc/commands/viewer.clj +++ b/backend/src/app/rpc/commands/viewer.clj @@ -28,19 +28,25 @@ (update :pages-index select-keys allowed))) (defn obfuscate-email + "Obfuscate the `email` for share-link members so the viewer only sees a + partially redacted address. Accepts any string shape (including nil, + missing `@`, or a domain with no `.`) and falls back to a fully-masked + result rather than throwing — the function is called while building the + view-only bundle for anonymous viewers, so an NPE here would abort the + entire share-link response." [email] (let [[name domain] - (str/split email "@" 2) + (str/split (or email "") "@" 2) [_ rest] - (str/split domain "." 2) + (str/split (or domain "") "." 2) name (if (> (count name) 3) (str (subs name 0 1) (apply str (take (dec (count name)) (repeat "*")))) "****")] - (str name "@****." rest))) + (str name "@****" (when rest (str "." rest))))) (defn anonymize-member [member] diff --git a/backend/src/app/rpc/management/nitrate.clj b/backend/src/app/rpc/management/nitrate.clj index 455b96705b..db8e6e0e06 100644 --- a/backend/src/app/rpc/management/nitrate.clj +++ b/backend/src/app/rpc/management/nitrate.clj @@ -8,22 +8,35 @@ "Internal Nitrate HTTP RPC API. Provides authenticated access to organization management and token validation endpoints." (:require - [app.common.features :as cfeat] + [app.common.data :as d] + [app.common.exceptions :as ex] [app.common.schema :as sm] + [app.common.time :as ct] + [app.common.types.organization :refer [schema:team-with-organization]] [app.common.types.profile :refer [schema:profile, schema:basic-profile]] [app.common.types.team :refer [schema:team]] - [app.common.uuid :as uuid] [app.config :as cf] [app.db :as db] - [app.msgbus :as mbus] + [app.media :as media] + [app.nitrate :as nitrate] [app.rpc :as-alias rpc] [app.rpc.commands.files :as files] + [app.rpc.commands.nitrate :as cnit] [app.rpc.commands.profile :as profile] [app.rpc.commands.teams :as teams] + [app.rpc.commands.teams-invitations :as ti] [app.rpc.doc :as doc] - [app.rpc.quotes :as quotes] + [app.rpc.notifications :as notifications] + [app.storage :as sto] [app.util.services :as sv] - [clojure.set :as set])) + [app.worker :as wrk])) + + +(defn- profile-to-map [profile] + {:id (:id profile) + :name (:fullname profile) + :email (:email profile) + :photo-url (files/resolve-public-uri (get profile :photo-id))}) ;; ---- API: authenticate @@ -33,11 +46,9 @@ ::sm/params [:map] ::sm/result schema:profile} [cfg {:keys [::rpc/profile-id] :as params}] - (let [profile (profile/get-profile cfg profile-id)] - {:id (get profile :id) - :name (get profile :fullname) - :email (get profile :email) - :photo-url (files/resolve-public-uri (get profile :photo-id))})) + (let [profile (profile/get-profile cfg profile-id)] + (-> (profile-to-map profile) + (assoc :theme (:theme profile))))) ;; ---- API: get-teams @@ -53,13 +64,26 @@ ;; ---- API: get-penpot-version (def ^:private schema:get-penpot-version-result - [:map [:version ::sm/text]]) + [:map + [:version + [:map + [:full [:maybe ::sm/text]] + [:branch [:maybe ::sm/text]] + [:base [:maybe ::sm/text]] + [:main [:maybe ::sm/text]] + [:major [:maybe ::sm/text]] + [:minor [:maybe ::sm/text]] + [:patch [:maybe ::sm/text]] + [:modifier [:maybe ::sm/text]] + [:commit [:maybe ::sm/text]] + [:commit-hash [:maybe ::sm/text]]]]]) (sv/defmethod ::get-penpot-version "Get the current Penpot version" {::doc/added "2.14" ::sm/params [:map] - ::sm/result schema:get-penpot-version-result} + ::sm/result schema:get-penpot-version-result + ::rpc/auth false} [_cfg _params] {:version cf/version}) @@ -76,29 +100,47 @@ (->> (db/exec! cfg [sql:get-teams current-user-id]) (map #(select-keys % [:id :name]))))) -;; ---- API: notify-team-change +;; ---- API: upload-org-logo -(def ^:private schema:notify-team-change +(def ^:private schema:upload-org-logo [:map - [:id ::sm/uuid] + [:content media/schema:upload] [:organization-id ::sm/uuid] - [:organization-name ::sm/text]]) + [:previous-id {:optional true} ::sm/uuid]]) + +(def ^:private schema:upload-org-logo-result + [:map [:id ::sm/uuid]]) + +(sv/defmethod ::upload-org-logo + "Store an organization logo in penpot storage and return its ID. + Accepts an optional previous-id to mark the old logo for garbage + collection when replacing an existing one." + {::doc/added "2.17" + ::sm/params schema:upload-org-logo + ::sm/result schema:upload-org-logo-result} + [{:keys [::sto/storage]} {:keys [content organization-id previous-id]}] + (when previous-id + (sto/touch-object! storage previous-id)) + (let [hash (sto/calculate-hash (:path content)) + data (-> (sto/content (:path content)) + (sto/wrap-with-hash hash)) + obj (sto/put-object! storage {::sto/content data + ::sto/deduplicate? true + :bucket "organization" + :content-type (:mtype content) + :organization-id organization-id})] + {:id (:id obj)})) + +;; ---- API: notify-team-change (sv/defmethod ::notify-team-change "Notify to Penpot a team change from nitrate" {::doc/added "2.14" - ::sm/params schema:notify-team-change + ::sm/params schema:team-with-organization ::rpc/auth false} - [cfg {:keys [id organization-id organization-name]}] - (let [msgbus (::mbus/msgbus cfg)] - (mbus/pub! msgbus - ;;TODO There is a bug on dashboard with teams notifications. - ;;For now we send it to uuid/zero instead of team-id - :topic uuid/zero - :message {:type :team-org-change - :team-id id - :organization-id organization-id - :organization-name organization-name}))) + [cfg team] + (notifications/notify-team-change cfg (select-keys team [:id :is-your-penpot :organization]) nil) + nil) ;; ---- API: notify-user-added-to-organization @@ -113,18 +155,8 @@ {::doc/added "2.14" ::sm/params schema:notify-user-added-to-organization ::rpc/auth false} - [cfg {:keys [profile-id]}] - (quotes/check! cfg {::quotes/id ::quotes/teams-per-profile - ::quotes/profile-id profile-id}) - - (let [features (-> (cfeat/get-enabled-features cf/flags) - (set/difference cfeat/frontend-only-features) - (set/difference cfeat/no-team-inheritable-features)) - params {:profile-id profile-id - :name "Default" - :features features} - team (db/tx-run! cfg teams/create-team params)] - (select-keys team [:id]))) + [cfg {:keys [profile-id organization-id]}] + (db/tx-run! cfg teams/create-default-org-team profile-id organization-id)) ;; ---- API: get-managed-profiles @@ -158,3 +190,440 @@ (let [current-user-id (-> (profile/get-profile cfg profile-id) :id)] (db/exec! cfg [sql:get-managed-profiles current-user-id current-user-id]))) +;; ---- API: get-teams-summary + +(def ^:private sql:get-teams-summary + "SELECT t.id, t.name, t.is_default + FROM team AS t + WHERE t.id = ANY(?) + AND t.deleted_at IS NULL;") + +(def ^:private sql:get-files-count + "SELECT COUNT(f.*) AS count + FROM file AS f + JOIN project AS p ON f.project_id = p.id + JOIN team AS t ON t.id = p.team_id + WHERE p.team_id = ANY(?) + AND t.deleted_at IS NULL + AND p.deleted_at IS NULL + AND f.deleted_at IS NULL;") + +(def ^:private schema:get-teams-summary-params + [:map + [:ids [:or ::sm/uuid [:vector ::sm/uuid]]]]) + +(def ^:private schema:get-teams-summary-result + [:map + [:teams [:vector [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:is-default ::sm/boolean]]]] + [:num-files ::sm/int]]) + +(sv/defmethod ::get-teams-summary + "Get summary information for a list of teams" + {::doc/added "2.15" + ::sm/params schema:get-teams-summary-params + ::sm/result schema:get-teams-summary-result} + [cfg {:keys [ids]}] + (let [;; Handle one or multiple params + ids (cond + (uuid? ids) + [ids] + + (and (vector? ids) (every? uuid? ids)) + ids + + :else + [])] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" ids) + teams (db/exec! conn [sql:get-teams-summary ids-array]) + files-count (-> (db/exec-one! conn [sql:get-files-count ids-array]) :count)] + {:teams teams + :num-files files-count}))))) + + +;; ---- API: delete-teams-keeping-your-penpot-projects + +(def ^:private sql:prefix-teams-name-and-unset-default + "UPDATE team + SET name = ? || name, + is_default = FALSE + WHERE id = ANY(?) +RETURNING id, name;") + +(def ^:private sql:get-teams-files-counts + "SELECT p.team_id, COUNT(f.*) AS total + FROM file AS f + JOIN project AS p ON (p.id = f.project_id) + JOIN team AS t ON (t.id = p.team_id) + WHERE t.id = ANY(?) + AND t.deleted_at IS NULL + AND p.deleted_at IS NULL + AND f.deleted_at IS NULL + GROUP BY p.team_id;") + +(def ^:private sql:soft-delete-teams + "UPDATE team + SET deleted_at = ? + WHERE id = ANY(?) +RETURNING id, deleted_at;") + + +;; ---- API: notify-organization-deletion + +(def ^:private schema:notify-organization-deletion + [:map + [:organization-id ::sm/uuid]]) + + +(defn- soft-delete-teams! + "Soft-delete the provided team ids and submit a delete task per team." + [{:keys [::db/conn] :as cfg} team-ids] + (when (seq team-ids) + (let [delay (cf/get-deletion-delay) + deleted-at (ct/in-future delay) + updated (db/exec! conn [sql:soft-delete-teams + deleted-at + (db/create-array conn "uuid" team-ids)])] + (doseq [{:keys [id deleted-at]} updated] + (wrk/submit! {::db/conn conn + ::wrk/task :delete-object + ::wrk/params {:object :team + :deleted-at deleted-at + :id id}})))) + nil) + +(defn manage-deleted-organization-teams + "For a list of teams, rename those with files and delete those without, then notify users." + [cfg {:keys [teams organization-name]}] + (let [teams (->> teams (filter uuid?) distinct (into []))] + (when (seq teams) + (let [org-prefix (str "[" (d/sanitize-string organization-name) "] ")] + (db/tx-run! + cfg + (fn [{:keys [::db/conn] :as cfg}] + (let [teams-array (db/create-array conn "uuid" teams) + teams-with-files (->> (db/exec! conn [sql:get-teams-files-counts teams-array]) + (filter (fn [{:keys [total]}] (pos? total))) + (map :team-id) + (into #{})) + teams-to-keep (->> teams (filter teams-with-files) (into [])) + teams-to-delete (->> teams (remove teams-with-files) (into []))] + + ;; Rename teams that have files in one go + (when (seq teams-to-keep) + (db/exec! conn [sql:prefix-teams-name-and-unset-default + org-prefix + (db/create-array conn "uuid" teams-to-keep)])) + + ;; Soft-delete empty teams in one go + (soft-delete-teams! cfg teams-to-delete) + + (notifications/notify-organization-deletion cfg organization-name teams teams-to-delete) + nil))))))) + + +(sv/defmethod ::notify-organization-deletion + "For a list of teams, rename them with the name of the deleted org, and notify + of the deletion to the connected users" + {::doc/added "2.15" + ::sm/params schema:notify-organization-deletion + ::rpc/auth false} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + teams (->> (:teams org-summary) + (map :id))] + (manage-deleted-organization-teams cfg {:teams teams :organization-name (:name org-summary)}) + nil)) + +;; ---- API: notify-user-organizations-deletion + +(def ^:private schema:notify-user-organizations-deletion + [:map + [:profile-id ::sm/uuid]]) + +(sv/defmethod ::notify-user-organizations-deletion + "For a given user, find all owned organizations and rename or delete their teams." + {::doc/added "2.18" + ::sm/params schema:notify-user-organizations-deletion} + [cfg {:keys [profile-id]}] + (let [owned-orgs (nitrate/call cfg :get-owned-orgs {:profile-id profile-id})] + (doseq [org owned-orgs] + (let [organization-name (:name org) + teams (map :id (:teams org))] + (manage-deleted-organization-teams cfg {:teams teams :organization-name organization-name})))) + nil) + + + + +;; ---- API: get-profile-by-email + +(def ^:private sql:get-profile-by-email + "SELECT DISTINCT id, fullname, email, photo_id + FROM profile + WHERE email = ? + AND deleted_at IS NULL;") + +(sv/defmethod ::get-profile-by-email + "Get profile by email" + {::doc/added "2.15" + ::sm/params [:map [:email ::sm/email]] + ::sm/result schema:profile} + [cfg {:keys [email]}] + (let [profile (db/exec-one! cfg [sql:get-profile-by-email email])] + (when-not profile + (ex/raise :type :not-found + :code :profile-not-found + :hint "profile does not exist" + :email email)) + (profile-to-map profile))) + + +;; ---- API: get-profile-by-id + +(def ^:private sql:get-profile-by-id + "SELECT DISTINCT id, fullname, email, photo_id + FROM profile + WHERE id = ? + AND deleted_at IS NULL;") + +(sv/defmethod ::get-profile-by-id + "Get profile by email" + {::doc/added "2.15" + ::sm/params [:map [:id ::sm/uuid]] + ::sm/result schema:profile} + [cfg {:keys [id]}] + (let [profile (db/exec-one! cfg [sql:get-profile-by-id id])] + (when-not profile + (ex/raise :type :not-found + :code :profile-not-found + :hint "profile does not exist" + :id id)) + (profile-to-map profile))) + + +;; ---- API: get-org-member-team-counts + +(def ^:private sql:get-org-member-team-counts + "SELECT tpr.profile_id, COUNT(DISTINCT t.id) AS team_count + FROM team_profile_rel AS tpr + JOIN team AS t ON t.id = tpr.team_id + WHERE t.id = ANY(?) + AND t.deleted_at IS NULL + AND t.is_default IS FALSE + GROUP BY tpr.profile_id;") + +(def ^:private schema:get-org-member-team-counts-params + [:map [:team-ids [:or ::sm/uuid [:vector ::sm/uuid]]]]) + +(def ^:private schema:get-org-member-team-counts-result + [:vector [:map + [:profile-id ::sm/uuid] + [:team-count ::sm/int]]]) + +(sv/defmethod ::get-org-member-team-counts + "Get the number of non-default teams each profile belongs to within a set of teams." + {::doc/added "2.15" + ::sm/params schema:get-org-member-team-counts-params + ::sm/result schema:get-org-member-team-counts-result + ::rpc/auth false} + [cfg {:keys [team-ids]}] + (let [team-ids (cond + (uuid? team-ids) + [team-ids] + + (and (vector? team-ids) (every? uuid? team-ids)) + team-ids + + :else + [])] + (if (empty? team-ids) + [] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:get-org-member-team-counts ids-array]))))))) + + +;; API: invite-to-org + +(sv/defmethod ::invite-to-org + "Invite to organization" + {::doc/added "2.15" + ::sm/params [:map + [:email ::sm/email] + [:id ::sm/uuid] + [:name ::sm/text] + [:initials [:maybe :string]] + [:logo ::sm/uri]]} + [cfg params] + (db/tx-run! cfg ti/create-org-invitation params) + nil) + + +;; API: get-org-invitations + +(def ^:private sql:get-org-invitations + "SELECT DISTINCT ON (email_to) + ti.id, + ti.org_id AS organization_id, + ti.email_to AS email, + ti.created_at AS sent_at, + p.fullname AS name, + p.photo_id + FROM team_invitation AS ti +LEFT JOIN profile AS p + ON p.email = ti.email_to + AND p.deleted_at IS NULL + WHERE ti.valid_until >= now() + AND (ti.org_id = ? OR ti.team_id = ANY(?)) + ORDER BY ti.email_to, ti.valid_until DESC, ti.created_at DESC;") + +(def ^:private schema:get-org-invitations-params + [:map + [:organization-id ::sm/uuid]]) + +(def ^:private schema:get-org-invitations-result + [:vector + [:map + [:id ::sm/uuid] + [:organization-id {:optional true} [:maybe ::sm/uuid]] + [:email ::sm/email] + [:sent-at ::sm/inst] + [:name {:optional true} [:maybe ::sm/text]] + [:photo-url {:optional true} ::sm/uri]]]) + +(sv/defmethod ::get-org-invitations + "Get valid invitations for an organization, returning at most one invitation per email." + {::doc/added "2.16" + ::sm/params schema:get-org-invitations-params + ::sm/result schema:get-org-invitations-result} + [cfg {:keys [organization-id]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + team-ids (->> (:teams org-summary) + (map :id) + (filter uuid?) + (into []))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (->> (db/exec! conn [sql:get-org-invitations organization-id ids-array]) + (mapv (fn [{:keys [photo-id] :as invitation}] + (cond-> (dissoc invitation :photo-id) + photo-id + (assoc :photo-url (files/resolve-public-uri photo-id))))))))))) + + +;; API: delete-org-invitations + +(def ^:private sql:delete-org-invitations + "DELETE FROM team_invitation AS ti + WHERE ti.email_to = ? + AND (ti.org_id = ? OR ti.team_id = ANY(?));") + +(def ^:private schema:delete-org-invitations-params + [:map + [:organization-id ::sm/uuid] + [:email ::sm/email]]) + +(sv/defmethod ::delete-org-invitations + "Delete all invitations for one email in an organization scope (org + org teams)." + {::doc/added "2.16" + ::sm/params schema:delete-org-invitations-params} + [cfg {:keys [organization-id email]}] + (let [org-summary (nitrate/call cfg :get-org-summary {:organization-id organization-id}) + clean-email (profile/clean-email email) + team-ids (->> (:teams org-summary) + (map :id) + (filter uuid?) + (into []))] + (db/run! cfg (fn [{:keys [::db/conn]}] + (let [ids-array (db/create-array conn "uuid" team-ids)] + (db/exec! conn [sql:delete-org-invitations clean-email organization-id ids-array])))) + nil)) + + + +;; API: remove-from-org + +(def ^:private sql:get-reassign-to + "SELECT tpr.profile_id + FROM team_profile_rel AS tpr + WHERE tpr.team_id = ? + AND tpr.profile_id <> ? + AND tpr.is_owner IS NOT TRUE + ORDER BY CASE + WHEN tpr.is_admin IS TRUE THEN 1 + ELSE 2 + END, + tpr.created_at, + tpr.profile_id + LIMIT 1;") + +(defn add-reassign-to [cfg profile-id team-to-transfer] + (let [reassign-to (-> (db/exec-one! cfg [sql:get-reassign-to (:id team-to-transfer) profile-id]) + :profile-id)] + (when-not reassign-to + (ex/raise :type :validation + :code :nobody-to-reassign-team)) + + (assoc team-to-transfer :reassign-to reassign-to))) + +(sv/defmethod ::remove-from-org + "Remove an user from an organization" + {::doc/added "2.17" + ::sm/params [:map + [:profile-id ::sm/uuid] + [:organization-id ::sm/uuid] + [:organization-name ::sm/text] + [:default-team-id ::sm/uuid]] + ::db/transaction true} + [cfg {:keys [profile-id organization-id organization-name default-team-id] :as params}] + (let [{:keys [valid-teams-to-delete-ids + valid-teams-to-transfer + valid-teams-to-exit]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id) + add-reassign-to (partial add-reassign-to cfg profile-id) + + valid-teams-to-leave (into valid-teams-to-exit + (map add-reassign-to valid-teams-to-transfer))] + + (cnit/leave-org cfg (assoc params + :id organization-id + :name organization-name + :teams-to-delete valid-teams-to-delete-ids + :teams-to-leave valid-teams-to-leave + :skip-validation true)) + (notifications/notify-user-org-change cfg profile-id organization-id organization-name "dashboard.user-no-longer-belong-org") + nil)) + +;; API: get-remove-from-org-summary + +(def ^:private schema:get-remove-from-org-summary-result + [:map + [:teams-to-delete ::sm/int] + [:teams-to-transfer ::sm/int] + [:teams-to-exit ::sm/int]]) + +(sv/defmethod ::get-remove-from-org-summary + "Get a summary of the teams that would be deleted, transferred, or exited + if the user were removed from the organization" + {::doc/added "2.17" + ::sm/params [:map + [:profile-id ::sm/uuid] + [:organization-id ::sm/uuid] + [:default-team-id ::sm/uuid]] + ::sm/result schema:get-remove-from-org-summary-result + ::db/transaction true} + [cfg {:keys [profile-id organization-id default-team-id]}] + (let [{:keys [valid-teams-to-delete-ids + valid-teams-to-transfer + valid-teams-to-exit + valid-default-team]} (cnit/get-valid-teams cfg organization-id profile-id default-team-id)] + (when-not valid-default-team + (ex/raise :type :validation + :code :not-valid-teams)) + {:teams-to-delete (count valid-teams-to-delete-ids) + :teams-to-transfer (count valid-teams-to-transfer) + :teams-to-exit (count valid-teams-to-exit)})) + diff --git a/backend/src/app/rpc/notifications.clj b/backend/src/app/rpc/notifications.clj new file mode 100644 index 0000000000..a439741092 --- /dev/null +++ b/backend/src/app/rpc/notifications.clj @@ -0,0 +1,44 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.rpc.notifications + (:require + [app.common.uuid :as uuid] + [app.msgbus :as mbus])) + +(defn notify-team-change + [cfg team notification] + (let [msgbus (::mbus/msgbus cfg)] + (mbus/pub! msgbus + ;;TODO There is a bug on dashboard with teams notifications. + ;;For now we send it to uuid/zero instead of team-id + :topic uuid/zero + :message {:type :team-org-change + :team team + :notification notification}))) + + +(defn notify-user-org-change + [cfg profile-id organization-id organization-name notification] + (let [msgbus (::mbus/msgbus cfg)] + (mbus/pub! msgbus + :topic profile-id + :message {:type :user-org-change + :topic profile-id + :organization-id organization-id + :organization-name organization-name + :notification notification}))) + + +(defn notify-organization-deletion + [cfg organization-name teams deleted-teams] + (let [msgbus (::mbus/msgbus cfg)] + (mbus/pub! msgbus + :topic uuid/zero + :message {:type :organization-deleted + :organization-name organization-name + :teams teams + :deleted-teams deleted-teams}))) \ No newline at end of file diff --git a/backend/src/app/srepl/cli.clj b/backend/src/app/srepl/cli.clj index 519df65b6e..cec1ec6a97 100644 --- a/backend/src/app/srepl/cli.clj +++ b/backend/src/app/srepl/cli.clj @@ -53,7 +53,7 @@ :or {is-active true}}] (some-> (get-current-system) (db/tx-run! - (fn [{:keys [::db/conn] :as system}] + (fn [system] (let [password (derive-password password) params {:id (uuid/next) :email email @@ -62,7 +62,7 @@ :password password :props {}}] (->> (cmd.auth/create-profile system params) - (cmd.auth/create-profile-rels conn))))))) + (cmd.auth/create-profile-rels system))))))) (defmethod exec-command "update-profile" [{:keys [fullname email password is-active]}] diff --git a/backend/src/app/srepl/main.clj b/backend/src/app/srepl/main.clj index 30c8b403dc..921aa04ebe 100644 --- a/backend/src/app/srepl/main.clj +++ b/backend/src/app/srepl/main.clj @@ -588,7 +588,7 @@ ::audit/tracked-at (ct/now)}) - (#'files/restore-file conn file-id)) + (#'files/restore-files conn [file-id])) :restored)))) (defn delete-project! @@ -622,7 +622,7 @@ (doseq [{:keys [id]} (db/query conn :file {:project-id project-id} {::sql/columns [:id]})] - (#'files/restore-file conn id)) + (#'files/restore-files conn [id])) :restored) @@ -905,5 +905,4 @@ (let [params (-> rel (assoc :id (uuid/next)) (assoc :team-id (:id team)))] - (db/insert! conn :team-profile-rel params - {::db/return-keys false})))))))) + (teams/add-profile-to-team! cfg params {::db/return-keys false})))))))) diff --git a/backend/src/app/storage.clj b/backend/src/app/storage.clj index 0fe48c2911..dc28a9e802 100644 --- a/backend/src/app/storage.clj +++ b/backend/src/app/storage.clj @@ -44,6 +44,7 @@ "file-object-thumbnail" "file-thumbnail" "profile" + "organization" "tempfile" "file-data" "file-data-fragment" diff --git a/backend/src/app/storage/gc_touched.clj b/backend/src/app/storage/gc_touched.clj index f00140d04e..971a5afd1d 100644 --- a/backend/src/app/storage/gc_touched.clj +++ b/backend/src/app/storage/gc_touched.clj @@ -166,6 +166,7 @@ "profile" (process-objects! conn has-profile-refs? bucket objects) "file-data" (process-objects! conn has-file-data-refs? bucket objects) "tempfile" (process-objects! conn (constantly false) bucket objects) + "organization" (process-objects! conn (constantly false) bucket objects) (ex/raise :type :internal :code :unexpected-unknown-reference :hint (dm/fmt "unknown reference '%'" bucket)))) diff --git a/backend/test/backend_tests/auth_oidc_test.clj b/backend/test/backend_tests/auth_oidc_test.clj new file mode 100644 index 0000000000..bdf18e5541 --- /dev/null +++ b/backend/test/backend_tests/auth_oidc_test.clj @@ -0,0 +1,55 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.auth-oidc-test + (:require + [app.auth.oidc :as oidc] + [clojure.test :as t])) + +(def ^:private oidc-provider + {:id "oidc" + :type "oidc"}) + +(t/deftest parse-attr-path-supports-dot-and-double-underscore + (t/is + (= [:oidc/resource-access :penpot_roles :roles] + (#'oidc/parse-attr-path oidc-provider "resource_access__penpot_roles__roles"))) + (t/is + (= [:oidc/ocs :data :email] + (#'oidc/parse-attr-path oidc-provider "ocs.data.email")))) + +(t/deftest process-user-info-supports-dot-notation-nested-attrs + (let [provider (assoc oidc-provider + :email-attr "ocs.data.email" + :name-attr "ocs.data.display-name") + info (#'oidc/process-user-info provider + {} + {:email_verified true + :ocs {:data {:email "nextcloud@example.com" + :display-name "Nextcloud User"}}})] + (t/is (= "nextcloud@example.com" (:email info))) + (t/is (= "Nextcloud User" (:fullname info))) + (t/is (true? (:email-verified info))))) + +;; The provider's `:user-info-source` value arrives as a string (enforced by +;; the malli schema in `app.config` and used as-is by the hard-coded Google / +;; GitHub provider maps), so the dispatch must interpret strings — not +;; keywords — to actually honour `PENPOT_OIDC_USER_INFO_SOURCE=userinfo`. +(t/deftest select-user-info-source-interprets-config-strings + (t/testing "explicit string values map to keyword dispatch tokens" + (t/is (= :token (#'oidc/select-user-info-source "token"))) + (t/is (= :userinfo (#'oidc/select-user-info-source "userinfo")))) + + (t/testing "missing or explicit \"auto\" falls back to auto dispatch" + (t/is (= :auto (#'oidc/select-user-info-source "auto"))) + (t/is (= :auto (#'oidc/select-user-info-source nil)))) + + (t/testing "unknown values fall back to auto dispatch safely" + (t/is (= :auto (#'oidc/select-user-info-source "unknown"))) + ;; Guards against the reverse regression — a stray keyword value must + ;; not silently slip through as if it were the matching string. + (t/is (= :auto (#'oidc/select-user-info-source :token))) + (t/is (= :auto (#'oidc/select-user-info-source :userinfo))))) diff --git a/backend/test/backend_tests/helpers.clj b/backend/test/backend_tests/helpers.clj index 8ddb3448a2..93197ddd6a 100644 --- a/backend/test/backend_tests/helpers.clj +++ b/backend/test/backend_tests/helpers.clj @@ -186,10 +186,10 @@ :is-demo false} params)] (db/run! system - (fn [{:keys [::db/conn] :as cfg}] + (fn [cfg] (->> params (cmd.auth/create-profile cfg) - (cmd.auth/create-profile-rels conn))))))) + (cmd.auth/create-profile-rels cfg))))))) (defn create-project* ([i params] (create-project* *system* i params)) @@ -234,10 +234,10 @@ (dm/with-open [conn (db/open system)] (let [id (mk-uuid "team" i) features (cfeat/get-enabled-features cf/flags)] - (teams/create-team conn {:id id - :profile-id profile-id - :features features - :name (str "team" i)}))))) + (teams/create-team {::db/conn conn} {:id id + :profile-id profile-id + :features features + :name (str "team" i)}))))) (defn create-file-media-object* ([params] (create-file-media-object* *system* params)) @@ -283,9 +283,10 @@ ([params] (create-team-role* *system* params)) ([system {:keys [team-id profile-id role] :or {role :owner}}] (dm/with-open [conn (db/open system)] - (#'teams/create-team-role conn {:team-id team-id - :profile-id profile-id - :role role})))) + (#'teams/create-team-role {::db/conn conn} + {:team-id team-id + :profile-id profile-id + :role role})))) (defn create-project-role* ([params] (create-project-role* *system* params)) @@ -384,6 +385,31 @@ (dissoc ::type) (assoc :app.rpc/request-at (ct/now))))))) +(defn management-command! + ([data] + (management-command! data nil)) + ([{:keys [::type] :as data} flags-to-add] + (let [flags (reduce conj cf/flags (or flags-to-add [])) + + resolve-management-methods + (requiring-resolve 'app.rpc/resolve-management-methods) + + methods + (with-redefs [cf/flags flags] + (resolve-management-methods *system*)) + + [_ method-fn] + (get methods type)] + + (when-not method-fn + (ex/raise :type :assertion + :code :rpc-method-not-found + :hint (str/ffmt "management rpc method '%' not found" (name type)))) + + (try-on! (method-fn (-> data + (dissoc ::type) + (assoc :app.rpc/request-at (ct/now)))))))) + (defn run-task! ([name] (run-task! name {})) diff --git a/backend/test/backend_tests/rpc_file_test.clj b/backend/test/backend_tests/rpc_file_test.clj index 281c834256..d45dec0453 100644 --- a/backend/test/backend_tests/rpc_file_test.clj +++ b/backend/test/backend_tests/rpc_file_test.clj @@ -2121,3 +2121,92 @@ (t/is (= 1 (count rows))) (t/is (= (:created-at row1) #penpot/inst "2025-10-31T00:00:00Z")) (t/is (nil? (:deleted-at row1)))))))) + +(t/deftest get-file-stats-empty-file + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + out (th/command! {::th/type :get-file-stats + ::rpc/profile-id (:id profile) + :id (:id file)})] + + ;; (th/print-result! out) + (t/is (nil? (:error out))) + + (let [result (:result out)] + (t/is (= (:id file) (:file-id result))) + (t/is (pos? (:page-count result))) + (t/is (zero? (:component-count result))) + (t/is (zero? (:deleted-component-count result))) + (t/is (zero? (:color-count result))) + (t/is (zero? (:typography-count result))) + (t/is (zero? (:library-count result))) + (t/is (zero? (:referenced-by-count result))) + (t/is (contains? result :shape-counts)) + (t/is (zero? (get-in result [:shape-counts :total]))) + (t/is (= {} (get-in result [:shape-counts :by-type])))))) + +(t/deftest get-file-stats-with-shapes + (let [profile (th/create-profile* 1 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id profile) + :project-id (:default-project-id profile) + :is-shared false}) + page-id (-> file :data :pages first) + rect-id (uuid/random) + frame-id (uuid/random)] + + (update-file! + :file-id (:id file) + :profile-id (:id profile) + :revn 0 + :vern 0 + :changes + [{:type :add-obj + :page-id page-id + :id frame-id + :parent-id uuid/zero + :frame-id uuid/zero + :components-v2 true + :obj (cts/setup-shape + {:id frame-id + :name "frame" + :frame-id uuid/zero + :parent-id uuid/zero + :type :frame})} + {:type :add-obj + :page-id page-id + :id rect-id + :parent-id frame-id + :frame-id frame-id + :components-v2 true + :obj (cts/setup-shape + {:id rect-id + :name "rect" + :frame-id frame-id + :parent-id frame-id + :type :rect})}]) + + (let [out (th/command! {::th/type :get-file-stats + ::rpc/profile-id (:id profile) + :id (:id file)}) + result (:result out)] + + (t/is (nil? (:error out))) + (t/is (= 2 (get-in result [:shape-counts :total]))) + (t/is (= 1 (get-in result [:shape-counts :by-type :rect]))) + (t/is (= 1 (get-in result [:shape-counts :by-type :frame])))))) + +(t/deftest get-file-stats-forbidden + (let [owner (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + file (th/create-file* 1 {:profile-id (:id owner) + :project-id (:default-project-id owner) + :is-shared false}) + out (th/command! {::th/type :get-file-stats + ::rpc/profile-id (:id other) + :id (:id file)})] + + (t/is (not (nil? (:error out)))) + (let [edata (-> out :error ex-data)] + (t/is (= :not-found (:type edata)))))) diff --git a/backend/test/backend_tests/rpc_management_nitrate_test.clj b/backend/test/backend_tests/rpc_management_nitrate_test.clj new file mode 100644 index 0000000000..c5de0bf6c4 --- /dev/null +++ b/backend/test/backend_tests/rpc_management_nitrate_test.clj @@ -0,0 +1,930 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.rpc-management-nitrate-test + (:require + [app.common.data :as d] + [app.common.time :as ct] + [app.common.uuid :as uuid] + [app.config :as cf] + [app.db :as-alias db] + [app.msgbus :as mbus] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [app.worker :as wrk] + [backend-tests.helpers :as th] + [clojure.set :as set] + [clojure.test :as t] + [cuerdas.core :as str])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +(defn- management-command-with-nitrate! + [data] + (th/management-command! data [:nitrate])) + +(t/deftest authenticate-success + (let [profile (th/create-profile* 1 {:is-active true + :fullname "Nitrate User"}) + out (management-command-with-nitrate! {::th/type :authenticate + ::rpc/profile-id (:id profile)})] + (t/is (th/success? out)) + (t/is (= (:id profile) (-> out :result :id))) + (t/is (= "Nitrate User" (-> out :result :name))) + (t/is (= (:email profile) (-> out :result :email))) + (t/is (nil? (-> out :result :photo-url))))) + +(t/deftest authenticate-requires-authentication + (let [out (management-command-with-nitrate! {::th/type :authenticate})] + (t/is (not (th/success? out))) + (t/is (= :authentication (th/ex-type (:error out)))) + (t/is (= :authentication-required (th/ex-code (:error out)))))) + +(t/deftest get-penpot-version + (let [out (management-command-with-nitrate! {::th/type :get-penpot-version}) + version (-> out :result :version)] + (t/is (th/success? out)) + (t/is (= #{:full :branch :base :main :major :minor :patch :modifier :commit :commit-hash} + (set (keys version)))) + (doseq [k [:full :branch :base :main :major :minor :patch :modifier :commit :commit-hash]] + (t/is (or (nil? (get version k)) + (string? (get version k))))) + (t/is (= cf/version version)))) + +(t/deftest get-teams-returns-only-owned-non-default-non-deleted + (let [profile (th/create-profile* 1 {:is-active true}) + other (th/create-profile* 2 {:is-active true}) + owned-team (th/create-team* 1 {:profile-id (:id profile)}) + deleted-team (th/create-team* 2 {:profile-id (:id profile)}) + _ (th/db-update! :team + {:deleted-at (ct/now)} + {:id (:id deleted-team)}) + other-team (th/create-team* 3 {:profile-id (:id other)}) + _ (th/create-team-role* {:team-id (:id other-team) + :profile-id (:id profile) + :role :editor}) + out (management-command-with-nitrate! {::th/type :get-teams + ::rpc/profile-id (:id profile)})] + (t/is (th/success? out)) + (t/is (= #{(:id owned-team)} + (->> out :result (map :id) set))) + (t/is (= #{(:name owned-team)} + (->> out :result (map :name) set))))) + +(t/deftest notify-team-change-publishes-event + (let [team-id (uuid/random) + organization-id (uuid/random) + organization {:id organization-id + :name "Acme Inc" + :slug "acme-inc" + :owner-id (uuid/random) + :avatar-bg-url "http://example.com/avatar.svg"} + calls (atom []) + out (with-redefs [mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic + :message message}))] + (management-command-with-nitrate! {::th/type :notify-team-change + :id team-id + :is-your-penpot false + :organization organization}))] + (t/is (th/success? out)) + (t/is (= 1 (count @calls))) + (t/is (= uuid/zero (-> @calls first :topic))) + (let [msg (-> @calls first :message)] + (t/is (= :team-org-change (:type msg))) + (t/is (= nil (:notification msg))) + (t/is (= team-id (-> msg :team :id))) + (t/is (= false (-> msg :team :is-your-penpot))) + (t/is (= (:id organization) (-> msg :team :organization :id))) + (t/is (= (:name organization) (-> msg :team :organization :name))) + (t/is (= (:slug organization) (-> msg :team :organization :slug))) + (t/is (= (:owner-id organization) (-> msg :team :organization :owner-id))) + (t/is (= (:avatar-bg-url organization) (str (-> msg :team :organization :avatar-bg-url))))))) + +(t/deftest notify-user-added-to-organization-creates-default-org-team + (let [profile (th/create-profile* 1 {:is-active true}) + before-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile) + :is-owner true}) + (map :team-id) + set) + out (management-command-with-nitrate! {::th/type :notify-user-added-to-organization + :profile-id (:id profile) + :organization-id (uuid/random) + :role "owner"}) + after-teams (->> (th/db-query :team-profile-rel {:profile-id (:id profile) + :is-owner true}) + (map :team-id) + set) + new-team-id (first (set/difference after-teams before-teams)) + new-team (th/db-get :team {:id new-team-id})] + (t/is (th/success? out)) + (t/is (= 1 (count (set/difference after-teams before-teams)))) + (t/is (= "Your Penpot" (:name new-team))) + (t/is (true? (:is-default new-team))))) + +(t/deftest get-managed-profiles-returns-unique-members-for-owned-teams + (let [owner (th/create-profile* 1 {:is-active true}) + member1 (th/create-profile* 2 {:is-active true}) + member2 (th/create-profile* 3 {:is-active true}) + team1 (th/create-team* 1 {:profile-id (:id owner)}) + team2 (th/create-team* 2 {:profile-id (:id owner)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id member1) + :role :editor}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id member2) + :role :editor}) + _ (th/create-team-role* {:team-id (:id team2) + :profile-id (:id member1) + :role :editor}) + out (management-command-with-nitrate! {::th/type :get-managed-profiles + ::rpc/profile-id (:id owner)})] + (t/is (th/success? out)) + (t/is (= #{(:id member1) (:id member2)} + (->> out :result (map :id) set))) + (t/is (= #{(:email member1) (:email member2)} + (->> out :result (map :email) set))))) + +(t/deftest get-teams-summary-returns-teams-and-files-count + (let [profile (th/create-profile* 1 {:is-active true}) + team1 (th/create-team* 1 {:profile-id (:id profile)}) + team2 (th/create-team* 2 {:profile-id (:id profile)}) + proj1 (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team1)}) + proj2 (th/create-project* 2 {:profile-id (:id profile) + :team-id (:id team2)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id proj1)}) + _ (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id proj2)}) + out (management-command-with-nitrate! {::th/type :get-teams-summary + ::rpc/profile-id (:id profile) + :ids [(:id team1) (:id team2)]})] + (t/is (th/success? out)) + (t/is (= 2 (-> out :result :num-files))) + (t/is (= #{(:id team1) (:id team2)} + (->> out :result :teams (map :id) set))))) + +(t/deftest notify-organization-deletion-prefixes-teams-and-publishes-org-deleted-event + (let [profile (th/create-profile* 1 {:is-active true}) + ;; One team will have files -> it will be kept and renamed. + team-with-files (th/db-get :team {:id (:default-team-id profile)}) + project (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id team-with-files)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id project)}) + + ;; One team will be empty -> it will be soft-deleted. + empty-team (th/create-team* 1 {:profile-id (:id profile)}) + + organization-id (uuid/random) + organization-name "Acme / Design" + expected-start (str "[" (d/sanitize-string organization-name) "] ") + org-summary {:id organization-id + :name organization-name + :teams [{:id (:id team-with-files)} + {:id (:id empty-team)}]} + calls (atom []) + submitted (atom []) + out (with-redefs [nitrate/call (fn [_cfg method params] + (t/is (= :get-org-summary method)) + (t/is (= {:organization-id organization-id} params)) + org-summary) + wrk/submit! (fn [task] + (swap! submitted conj task) + nil) + mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic + :message message}))] + (management-command-with-nitrate! {::th/type :notify-organization-deletion + ::rpc/profile-id (:id profile) + :organization-id organization-id})) + updated-with-files (th/db-get :team {:id (:id team-with-files)} {::db/remove-deleted false}) + updated-empty (th/db-get :team {:id (:id empty-team)} {::db/remove-deleted false})] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; Team with files is kept, unset as default, and renamed with org prefix. + (t/is (false? (:is-default updated-with-files))) + (t/is (str/starts-with? (:name updated-with-files) expected-start)) + (t/is (nil? (:deleted-at updated-with-files))) + + ;; Empty team is soft-deleted and a delete task is submitted. + (t/is (some? (:deleted-at updated-empty))) + (t/is (= 1 (count @submitted))) + + ;; A single organization-deleted event is published. + (t/is (= 1 (count @calls))) + (let [{:keys [topic message]} (first @calls)] + (t/is (= uuid/zero topic)) + (t/is (= :organization-deleted (:type message))) + (t/is (= organization-name (:organization-name message))) + (t/is (= #{(:id team-with-files) (:id empty-team)} + (set (:teams message)))) + (t/is (= #{(:id empty-team)} + (set (:deleted-teams message))))))) + +(t/deftest notify-user-organizations-deletion-renames-or-deletes-teams-and-publishes-per-org-events + (let [profile (th/create-profile* 1 {:is-active true}) + ;; org-1: one team with files, one empty + org-1-team-files (th/db-get :team {:id (:default-team-id profile)}) + org-1-proj (th/create-project* 1 {:profile-id (:id profile) + :team-id (:id org-1-team-files)}) + _ (th/create-file* 1 {:profile-id (:id profile) + :project-id (:id org-1-proj)}) + org-1-team-empty (th/create-team* 1 {:profile-id (:id profile)}) + + ;; org-2: one team with files, one empty + org-2-team-files (th/create-team* 2 {:profile-id (:id profile)}) + org-2-proj (th/create-project* 2 {:profile-id (:id profile) + :team-id (:id org-2-team-files)}) + _ (th/create-file* 2 {:profile-id (:id profile) + :project-id (:id org-2-proj)}) + org-2-team-empty (th/create-team* 3 {:profile-id (:id profile)}) + + org-1-id (uuid/random) + org-2-id (uuid/random) + org-1-name "Org One / Design" + org-2-name "Org Two" + org-1-prefix (str "[" (d/sanitize-string org-1-name) "] ") + org-2-prefix (str "[" (d/sanitize-string org-2-name) "] ") + owned-orgs [{:id org-1-id + :name org-1-name + :teams [{:id (:id org-1-team-files)} + {:id (:id org-1-team-empty)}]} + {:id org-2-id + :name org-2-name + :teams [{:id (:id org-2-team-files)} + {:id (:id org-2-team-empty)}]}] + calls (atom []) + submitted (atom []) + out (with-redefs [nitrate/call (fn [_cfg method params] + (case method + :get-owned-orgs + (do + (t/is (= {:profile-id (:id profile)} params)) + owned-orgs) + nil)) + wrk/submit! (fn [task] + (swap! submitted conj task) + nil) + mbus/pub! (fn [_cfg & {:keys [topic message]}] + (swap! calls conj {:topic topic + :message message}))] + (management-command-with-nitrate! {::th/type :notify-user-organizations-deletion + ::rpc/profile-id (:id profile) + :profile-id (:id profile)})) + org-1-updated-files (th/db-get :team {:id (:id org-1-team-files)} {::db/remove-deleted false}) + org-1-updated-empty (th/db-get :team {:id (:id org-1-team-empty)} {::db/remove-deleted false}) + org-2-updated-files (th/db-get :team {:id (:id org-2-team-files)} {::db/remove-deleted false}) + org-2-updated-empty (th/db-get :team {:id (:id org-2-team-empty)} {::db/remove-deleted false}) + msgs (->> @calls (map :message) vec) + org-msg (fn [org-name] + (first (filter #(= org-name (:organization-name %)) msgs)))] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; org-1: team with files renamed; empty team deleted + (t/is (false? (:is-default org-1-updated-files))) + (t/is (str/starts-with? (:name org-1-updated-files) org-1-prefix)) + (t/is (nil? (:deleted-at org-1-updated-files))) + (t/is (some? (:deleted-at org-1-updated-empty))) + + ;; org-2: team with files renamed; empty team deleted + (t/is (false? (:is-default org-2-updated-files))) + (t/is (str/starts-with? (:name org-2-updated-files) org-2-prefix)) + (t/is (nil? (:deleted-at org-2-updated-files))) + (t/is (some? (:deleted-at org-2-updated-empty))) + + ;; two delete tasks (one per empty team) + (t/is (= 2 (count @submitted))) + + ;; one organization-deleted event per org + (t/is (= 2 (count @calls))) + (t/is (every? #(= uuid/zero (:topic %)) @calls)) + (t/is (= #{:organization-deleted} + (set (map (comp :type :message) @calls)))) + + (let [m1 (org-msg org-1-name) + m2 (org-msg org-2-name)] + (t/is (some? m1)) + (t/is (some? m2)) + (t/is (= #{(:id org-1-team-files) (:id org-1-team-empty)} + (set (:teams m1)))) + (t/is (= #{(:id org-1-team-empty)} + (set (:deleted-teams m1)))) + (t/is (= #{(:id org-2-team-files) (:id org-2-team-empty)} + (set (:teams m2)))) + (t/is (= #{(:id org-2-team-empty)} + (set (:deleted-teams m2))))))) + +(t/deftest get-profile-by-email-success-and-not-found + (let [profile (th/create-profile* 1 {:is-active true + :fullname "Lookup by Email"}) + ok-out (management-command-with-nitrate! {::th/type :get-profile-by-email + ::rpc/profile-id (:id profile) + :email (:email profile)}) + ko-out (management-command-with-nitrate! {::th/type :get-profile-by-email + ::rpc/profile-id (:id profile) + :email "not-found@example.com"})] + (t/is (th/success? ok-out)) + (t/is (= (:id profile) (-> ok-out :result :id))) + (t/is (= "Lookup by Email" (-> ok-out :result :name))) + (t/is (nil? (-> ok-out :result :photo-url))) + + (t/is (not (th/success? ko-out))) + (t/is (= :not-found (th/ex-type (:error ko-out)))) + (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) + +(t/deftest get-profile-by-id-success-and-not-found + (let [profile (th/create-profile* 1 {:is-active true + :fullname "Lookup by Id"}) + ok-out (management-command-with-nitrate! {::th/type :get-profile-by-id + ::rpc/profile-id (:id profile) + :id (:id profile)}) + ko-out (management-command-with-nitrate! {::th/type :get-profile-by-id + ::rpc/profile-id (:id profile) + :id (uuid/random)})] + (t/is (th/success? ok-out)) + (t/is (= (:id profile) (-> ok-out :result :id))) + (t/is (= "Lookup by Id" (-> ok-out :result :name))) + (t/is (nil? (-> ok-out :result :photo-url))) + + (t/is (not (th/success? ko-out))) + (t/is (= :not-found (th/ex-type (:error ko-out)))) + (t/is (= :profile-not-found (th/ex-code (:error ko-out)))))) + +(t/deftest get-org-invitations-returns-valid-deduped-by-email + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + ;; Same email appears in org and team invitations; only one should be returned. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to "dup@example.com" + :created-by (:id profile) + :role "admin" + :valid-until (ct/in-future "72h")}) + + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "valid@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "48h")}) + + ;; Expired invitation should be ignored. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "expired@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + result (:result out) + emails (->> result (map :email) set) + dedup (->> result + (filter #(= "dup@example.com" (:email %))) + first)] + (t/is (th/success? out)) + (t/is (= #{"dup@example.com" "valid@example.com"} emails)) + (t/is (= 2 (count result))) + (t/is (some? (:id dedup))) + (t/is (some? (:sent-at dedup))) + (t/is (nil? (:organization-id dedup))) + (t/is (nil? (:team-id dedup))) + (t/is (nil? (:role dedup))) + (t/is (nil? (:valid-until dedup)))))) + +(t/deftest get-org-invitations-includes-org-level-invitations-when-no-teams + (let [profile (th/create-profile* 1 {:is-active true}) + org-id (uuid/random) + org-summary {:id org-id + :teams []} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to "org-only@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + result (:result out)] + (t/is (th/success? out)) + (t/is (= 1 (count result))) + (t/is (= "org-only@example.com" (-> result first :email))) + (t/is (some? (-> result first :sent-at)))))) + +(t/deftest get-org-invitations-returns-existing-profile-data + (let [profile (th/create-profile* 1 {:is-active true}) + invited (th/create-profile* 2 {:is-active true + :fullname "Invited User"}) + photo-id (uuid/random) + _ (th/db-insert! :storage-object {:id photo-id + :backend "assets-fs"}) + _ (th/db-update! :profile {:photo-id photo-id} {:id (:id invited)}) + org-id (uuid/random) + org-summary {:id org-id + :teams []} + params {::th/type :get-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id}] + + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to (:email invited) + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + invitation (-> out :result first)] + (t/is (th/success? out)) + (t/is (= "Invited User" (:name invitation))) + (t/is (some? (:sent-at invitation))) + (t/is (str/ends-with? (:photo-url invitation) + (str "/assets/by-id/" photo-id)))))) + +(t/deftest delete-org-invitations-removes-org-and-org-team-invitations-for-email + (let [profile (th/create-profile* 1 {:is-active true}) + team-1 (th/create-team* 1 {:profile-id (:id profile)}) + team-2 (th/create-team* 2 {:profile-id (:id profile)}) + outside-team (th/create-team* 3 {:profile-id (:id profile)}) + org-id (uuid/random) + org-summary {:id org-id + :teams [{:id (:id team-1)} + {:id (:id team-2)}]} + target-email "target@example.com" + params {::th/type :delete-org-invitations + ::rpc/profile-id (:id profile) + :organization-id org-id + :email "TARGET@example.com"}] + + ;; Should be deleted: org-level invitation for same org+email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :org-id org-id + :team-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should be deleted: team-level invitation for teams belonging to org summary. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-1) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-past "1h")}) + + ;; Should remain: different email. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id team-2) + :org-id nil + :email-to "other@example.com" + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + ;; Should remain: same email but outside org scope. + (th/db-insert! :team-invitation + {:id (uuid/random) + :team-id (:id outside-team) + :org-id nil + :email-to target-email + :created-by (:id profile) + :role "editor" + :valid-until (ct/in-future "24h")}) + + (let [out (with-redefs [nitrate/call (fn [_cfg method _params] + (case method + :get-org-summary org-summary + nil))] + (management-command-with-nitrate! params)) + remaining-target (th/db-query :team-invitation {:email-to target-email}) + remaining-other (th/db-query :team-invitation {:email-to "other@example.com"})] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + (t/is (= 1 (count remaining-target))) + (t/is (= (:id outside-team) (:team-id (first remaining-target)))) + (t/is (= 1 (count remaining-other)))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Tests: remove-from-org +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-org-summary + [& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams] + :or {your-penpot-teams [] org-teams []}}] + {:id organization-id + :name organization-name + :owner-id owner-id + :teams (into + (mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams) + (mapv (fn [id] {:id id :is-your-penpot false}) org-teams))}) + +(defn- nitrate-call-mock + [org-summary] + (fn [_cfg method _params] + (case method + :get-org-summary org-summary + :get-org-membership {:organization-id (:id org-summary) + :is-member true} + :remove-profile-from-org nil + nil))) + +(t/deftest remove-from-org-happy-path-no-extra-teams + ;; User is only in its default team (which has files); it should be + ;; kept, renamed and unset as default. A notification must be sent. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + org-team (th/create-team* 1 {:profile-id (:id user)}) + project (th/create-project* 1 {:profile-id (:id user) + :team-id (:id org-team)}) + _ (th/create-file* 1 {:profile-id (:id user) + :project-id (:id project)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams []) + calls (atom []) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [_bus & {:keys [topic message]}] + (swap! calls conj {:topic topic :message message}))] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; default team preserved, renamed and unset as default + (let [team (th/db-get :team {:id (:id org-team)})] + (t/is (false? (:is-default team))) + (t/is (str/starts-with? (:name team) "[Acme Org] "))) + + ;; exactly one notification sent to the user + (t/is (= 1 (count @calls))) + (let [msg (-> @calls first :message)] + (t/is (= :user-org-change (:type msg))) + (t/is (= (:id user) (:topic msg))) + (t/is (= organization-id (:organization-id msg))) + (t/is (= "Acme Org" (:organization-name msg))) + (t/is (= "dashboard.user-no-longer-belong-org" (:notification msg)))))) + +(t/deftest remove-from-org-deletes-empty-default-team + ;; When the default team has no files it should be soft-deleted. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + org-team (th/create-team* 2 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams []) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (let [team (th/db-get :team {:id (:id org-team)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))) + +(t/deftest remove-from-org-deletes-sole-owner-team + ;; When the user is the sole member of an org team it should be deleted. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 3 {:profile-id (:id user)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (let [team (th/db-get :team {:id (:id extra-team)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))) + +(t/deftest remove-from-org-transfers-ownership-of-multi-member-team + ;; When the user owns a team that has another non-owner member, ownership + ;; is transferred to that member by the endpoint automatically. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + candidate (th/create-profile* 3 {:is-active true}) + extra-team (th/create-team* 4 {:profile-id (:id user)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id candidate) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + ;; user no longer in extra-team + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (nil? rel))) + ;; candidate promoted to owner + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id candidate)})] + (t/is (true? (:is-owner rel)))))) + +(t/deftest remove-from-org-exits-non-owned-team + ;; When the user is a non-owner member of an org team, they simply leave. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 5 {:profile-id (:id org-owner)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id user) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + ;; user no longer a member of extra-team + (let [rel (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (nil? rel))) + ;; team still exists for the owner + (let [team (th/db-get :team {:id (:id extra-team)})] + (t/is (some? team))))) + +(t/deftest remove-from-org-error-nobody-to-reassign + ;; When the user owns a multi-member team but every other member is + ;; also an owner, the auto-selection query finds nobody and raises. + (let [other-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 6 {:profile-id (:id user)}) + ;; add other-owner to the team and make them co-owner directly in DB + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id other-owner) + :role :editor}) + _ (th/db-update! :team-profile-rel + {:is-owner true :is-admin false} + {:team-id (:id extra-team) :profile-id (:id other-owner)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id other-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary) + mbus/pub! (fn [& _] nil)] + (management-command-with-nitrate! + {::th/type :remove-from-org + ::rpc/profile-id (:id other-owner) + :profile-id (:id user) + :organization-id organization-id + :organization-name "Acme Org" + :default-team-id (:id org-team)}))] + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :nobody-to-reassign-team (th/ex-code (:error out)))))) + +;; Tests: get-remove-from-org-summary +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest get-remove-from-org-summary-no-extra-teams + ;; User only has a default team — nothing to delete/transfer/exit. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + org-team (th/create-team* 1 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams []) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 0 + :teams-to-exit 0} + (:result out))))) + +(t/deftest get-remove-from-org-summary-with-teams-to-delete + ;; User owns a sole-member extra org team → 1 to delete. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 3 {:profile-id (:id user)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 1 + :teams-to-transfer 0 + :teams-to-exit 0} + (:result out))))) + +(t/deftest get-remove-from-org-summary-with-teams-to-transfer + ;; User owns a multi-member extra org team → 1 to transfer. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + candidate (th/create-profile* 3 {:is-active true}) + extra-team (th/create-team* 4 {:profile-id (:id user)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id candidate) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 1 + :teams-to-exit 0} + (:result out))))) + +(t/deftest get-remove-from-org-summary-with-teams-to-exit + ;; User is a non-owner member of an org team → 1 to exit. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 5 {:profile-id (:id org-owner)}) + _ (th/create-team-role* {:team-id (:id extra-team) + :profile-id (:id user) + :role :editor}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + out (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id org-team)}))] + (t/is (th/success? out)) + (t/is (= {:teams-to-delete 0 + :teams-to-transfer 0 + :teams-to-exit 1} + (:result out))))) + +(t/deftest get-remove-from-org-summary-does-not-mutate + ;; Calling the summary endpoint must not modify any teams. + (let [org-owner (th/create-profile* 1 {:is-active true}) + user (th/create-profile* 2 {:is-active true}) + extra-team (th/create-team* 6 {:profile-id (:id user)}) + org-team (th/create-team* 99 {:profile-id (:id user)}) + organization-id (uuid/random) + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Acme Org" + :owner-id (:id org-owner) + :your-penpot-teams [(:id org-team)] + :org-teams [(:id extra-team)]) + _ (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (management-command-with-nitrate! + {::th/type :get-remove-from-org-summary + ::rpc/profile-id (:id org-owner) + :profile-id (:id user) + :organization-id organization-id + :default-team-id (:id org-team)}))] + ;; Both teams must still exist and be undeleted + (let [t1 (th/db-get :team {:id (:id org-team)})] + (t/is (some? t1)) + (t/is (nil? (:deleted-at t1)))) + (let [t2 (th/db-get :team {:id (:id extra-team)})] + (t/is (some? t2)) + (t/is (nil? (:deleted-at t2)))) + ;; User must still be a member of both teams + (let [rel1 (th/db-get :team-profile-rel {:team-id (:id org-team) :profile-id (:id user)})] + (t/is (some? rel1))) + (let [rel2 (th/db-get :team-profile-rel {:team-id (:id extra-team) :profile-id (:id user)})] + (t/is (some? rel2))))) diff --git a/backend/test/backend_tests/rpc_nitrate_test.clj b/backend/test/backend_tests/rpc_nitrate_test.clj new file mode 100644 index 0000000000..d8f4142a60 --- /dev/null +++ b/backend/test/backend_tests/rpc_nitrate_test.clj @@ -0,0 +1,686 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns backend-tests.rpc-nitrate-test + (:require + [app.common.uuid :as uuid] + [app.db :as-alias db] + [app.nitrate :as nitrate] + [app.rpc :as-alias rpc] + [app.rpc.commands.nitrate] + [backend-tests.helpers :as th] + [clojure.test :as t] + [cuerdas.core :as str])) + +(t/use-fixtures :once th/state-init) +(t/use-fixtures :each th/database-reset) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- make-org-summary + [& {:keys [organization-id organization-name owner-id your-penpot-teams org-teams] + :or {your-penpot-teams [] org-teams []}}] + {:id organization-id + :name organization-name + :owner-id owner-id + :teams (into + (mapv (fn [id] {:id id :is-your-penpot true}) your-penpot-teams) + (mapv (fn [id] {:id id :is-your-penpot false}) org-teams))}) + +(defn- nitrate-call-mock + "Creates a mock for nitrate/call that returns the given org-summary for + :get-org-summary, a valid membership for :get-org-membership, and nil for + any other method." + [org-summary] + (fn [_cfg method _params] + (case method + :get-org-summary org-summary + :get-org-membership {:is-member true + :organization-id (:id org-summary)} + nil))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Tests +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest leave-org-happy-path-no-extra-teams + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + project (th/create-project* 99 {:profile-id (:id profile-user) + :team-id (:id org-default-team)}) + _ (th/create-file* 99 {:profile-id (:id profile-user) + :project-id (:id project)}) + + organization-id (uuid/random) + ;; The user's personal penpot team in the org context + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + (t/is (nil? (:result out))) + + ;; The personal team must be renamed with the org prefix and + ;; unset as a default team. + (let [team (th/db-get :team {:id your-penpot-id})] + (t/is (str/starts-with? (:name team) "[Test Org] ")) + (t/is (false? (:is-default team)))))))) + +(t/deftest leave-org-deletes-org-default-team-when-empty + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + org-default-team (th/create-team* 98 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (th/success? out)) + + ;; Empty org default team should be soft-deleted. + (let [team (th/db-get :team {:id your-penpot-id} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))))) + +(t/deftest leave-org-keeps-and-renames-org-default-team-when-has-files + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + org-default-team (th/create-team* 97 {:profile-id (:id profile-user)}) + project (th/create-project* 97 {:profile-id (:id profile-user) + :team-id (:id org-default-team)}) + _ (th/create-file* 97 {:profile-id (:id profile-user) + :project-id (:id project)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (th/success? out)) + + ;; Non-empty org default team should remain and be renamed. + (let [team (th/db-get :team {:id your-penpot-id})] + (t/is (str/starts-with? (:name team) "[Test Org] ")) + (t/is (false? (:is-default team))) + (t/is (nil? (:deleted-at team)))))))) + +(t/deftest leave-org-with-teams-to-delete + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-user is the sole owner/member of team1 + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave []} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + + ;; team1 should be scheduled for deletion (deleted-at set) + (let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))))))) + +(t/deftest leave-org-with-ownership-transfer + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-user owns team1; profile-owner is also a member + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + + ;; profile-user should no longer be a member of team1 + (let [rel (th/db-get :team-profile-rel + {:team-id (:id team1) + :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; profile-owner should have been promoted to owner + (let [rel (th/db-get :team-profile-rel + {:team-id (:id team1) + :profile-id (:id profile-owner)})] + (t/is (true? (:is-owner rel)))))))) + +(t/deftest leave-org-exit-as-non-owner + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-owner owns team1; profile-user is a non-owner member + team1 (th/create-team* 1 {:profile-id (:id profile-owner)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-user) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1)}]} + out (th/command! data)] + + ;; (th/print-result! out) + (t/is (th/success? out)) + + ;; profile-user should no longer be a member of team1 + (let [rel (th/db-get :team-profile-rel + {:team-id (:id team1) + :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; The team itself should still exist + (let [team (th/db-get :team {:id (:id team1)})] + (t/is (nil? (:deleted-at team)))))))) + +(t/deftest leave-org-error-org-owner-cannot-leave + (let [profile-owner (th/create-profile* 1 {:is-active true}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-owner)}) + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + ;; profile-owner IS the org owner in the org-summary + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-owner) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :org-owner-cannot-leave (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-invalid-default-team-id + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; Pass a random UUID that is not in the your-penpot-teams list + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id (uuid/random) + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Unit Tests for calculate-valid-teams + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private calculate-valid-teams + (or (ns-resolve 'app.rpc.commands.nitrate 'calculate-valid-teams) + (throw (ex-info "Unable to resolve calculate-valid-teams" + {:ns 'app.rpc.commands.nitrate + :symbol 'calculate-valid-teams})))) + +(defn- make-team [id & {:keys [is-owner num-members member-ids] + :or {is-owner false num-members 1 member-ids []}}] + {:id id :is-owner is-owner :num-members num-members :member-ids member-ids}) + +(t/deftest calculate-valid-teams-no-org-teams + (let [default-id (uuid/random) + default-team (make-team default-id) + result (calculate-valid-teams [default-team] default-id)] + (t/is (= default-team (:valid-default-team result))) + (t/is (empty? (:valid-teams-to-delete-ids result))) + (t/is (empty? (:valid-teams-to-transfer result))) + (t/is (empty? (:valid-teams-to-exit result))))) + +(t/deftest calculate-valid-teams-default-not-found + (let [default-id (uuid/random) + other-id (uuid/random) + other-team (make-team other-id) + ;; default-id is not in org-teams at all + result (calculate-valid-teams [other-team] default-id)] + (t/is (nil? (:valid-default-team result))))) + +(t/deftest calculate-valid-teams-sole-owner-team + (let [default-id (uuid/random) + team-id (uuid/random) + default (make-team default-id) + solo-team (make-team team-id :is-owner true :num-members 1) + result (calculate-valid-teams [default solo-team] default-id)] + (t/is (contains? (:valid-teams-to-delete-ids result) team-id)) + (t/is (empty? (:valid-teams-to-transfer result))) + (t/is (empty? (:valid-teams-to-exit result))))) + +(t/deftest calculate-valid-teams-owned-multi-member-team + (let [default-id (uuid/random) + team-id (uuid/random) + default (make-team default-id) + ;; owner of a team with 3 members — must be transferred + multi-team (make-team team-id :is-owner true :num-members 3) + result (calculate-valid-teams [default multi-team] default-id)] + (t/is (empty? (:valid-teams-to-delete-ids result))) + (t/is (= [team-id] (map :id (:valid-teams-to-transfer result)))) + (t/is (empty? (:valid-teams-to-exit result))))) + +(t/deftest calculate-valid-teams-non-owner-multi-member-team + (let [default-id (uuid/random) + team-id (uuid/random) + default (make-team default-id) + ;; non-owner member of a team with 2 members — can just exit + exit-team (make-team team-id :is-owner false :num-members 2) + result (calculate-valid-teams [default exit-team] default-id)] + (t/is (empty? (:valid-teams-to-delete-ids result))) + (t/is (empty? (:valid-teams-to-transfer result))) + (t/is (= [team-id] (map :id (:valid-teams-to-exit result)))))) + +(t/deftest calculate-valid-teams-mixed + (let [default-id (uuid/random) + solo-id (uuid/random) + transfer-id (uuid/random) + exit-id (uuid/random) + default (make-team default-id) + solo-team (make-team solo-id :is-owner true :num-members 1) + transfer-team (make-team transfer-id :is-owner true :num-members 2) + exit-team (make-team exit-id :is-owner false :num-members 3) + result (calculate-valid-teams [default solo-team transfer-team exit-team] default-id)] + (t/is (= #{solo-id} (:valid-teams-to-delete-ids result))) + (t/is (= [transfer-id] (map :id (:valid-teams-to-transfer result)))) + (t/is (= [exit-id] (map :id (:valid-teams-to-exit result)))) + (t/is (= default-id (:id (:valid-default-team result)))))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Integration: combined delete + leave + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(t/deftest leave-org-combined-delete-and-leave + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; team1: profile-user is sole owner — must delete + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + ;; team2: profile-user owns it, profile-owner is also member — must transfer + team2 (th/create-team* 2 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team2) + :profile-id (:id profile-owner) + :role :editor}) + ;; team3: profile-owner owns it, profile-user is non-owner member — can exit + team3 (th/create-team* 3 {:profile-id (:id profile-owner)}) + _ (th/create-team-role* {:team-id (:id team3) + :profile-id (:id profile-user) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1) (:id team2) (:id team3)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave [{:id (:id team2) :reassign-to (:id profile-owner)} + {:id (:id team3)}]} + out (th/command! data)] + + (t/is (th/success? out)) + + ;; team1 should be soft-deleted + (let [team (th/db-get :team {:id (:id team1)} {::db/remove-deleted false})] + (t/is (some? (:deleted-at team)))) + + ;; profile-user should no longer be a member of team2 + (let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; profile-owner should now own team2 + (let [rel (th/db-get :team-profile-rel {:team-id (:id team2) :profile-id (:id profile-owner)})] + (t/is (true? (:is-owner rel)))) + + ;; profile-user should no longer be a member of team3 + (let [rel (th/db-get :team-profile-rel {:team-id (:id team3) :profile-id (:id profile-user)})] + (t/is (nil? rel))) + + ;; team3 itself should still exist (profile-owner is still there) + (let [team (th/db-get :team {:id (:id team3)})] + (t/is (some? team))))))) +(t/deftest leave-org-error-teams-to-delete-incomplete + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-user is the sole owner/member of both team1 and team2 + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + team2 (th/create-team* 2 {:profile-id (:id profile-user)}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1) (:id team2)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; Only team1 is listed; team2 is also a sole-owner team and must be included + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-cannot-delete-multi-member-team + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; team1 has two members: profile-user (owner) and profile-owner (editor) + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; team1 has 2 members so it is not a valid deletion candidate + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [(:id team1)] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-teams-to-leave-incomplete + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-user owns team1, which also has profile-owner as editor + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; team1 must be transferred (owner + multiple members) but is absent + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave []} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-reassign-to-self + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; reassign-to points to the profile that is leaving — not allowed + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-user)}]} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-reassign-to-non-member + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + profile-other (th/create-profile* 3 {:is-active true}) + ;; team1 has profile-user (owner) and profile-owner (editor) — NOT profile-other + team1 (th/create-team* 1 {:profile-id (:id profile-user)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-owner) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; profile-other is not a member of team1 + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-other)}]} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) + +(t/deftest leave-org-error-reassign-on-non-owned-team + (let [profile-owner (th/create-profile* 1 {:is-active true}) + profile-user (th/create-profile* 2 {:is-active true}) + ;; profile-owner owns team1; profile-user is just a non-owner member + team1 (th/create-team* 1 {:profile-id (:id profile-owner)}) + _ (th/create-team-role* {:team-id (:id team1) + :profile-id (:id profile-user) + :role :editor}) + org-default-team (th/create-team* 99 {:profile-id (:id profile-user)}) + + organization-id (uuid/random) + your-penpot-id (:id org-default-team) + + org-summary (make-org-summary + :organization-id organization-id + :organization-name "Test Org" + :owner-id (:id profile-owner) + :your-penpot-teams [your-penpot-id] + :org-teams [(:id team1)])] + + (with-redefs [nitrate/call (nitrate-call-mock org-summary)] + ;; profile-user is not the owner so providing reassign-to is invalid + (let [data {::th/type :leave-org + ::rpc/profile-id (:id profile-user) + :id organization-id + :name "Test Org" + :default-team-id your-penpot-id + :teams-to-delete [] + :teams-to-leave [{:id (:id team1) :reassign-to (:id profile-owner)}]} + out (th/command! data)] + + (t/is (not (th/success? out))) + (t/is (= :validation (th/ex-type (:error out)))) + (t/is (= :not-valid-teams (th/ex-code (:error out)))))))) diff --git a/backend/test/backend_tests/rpc_profile_test.clj b/backend/test/backend_tests/rpc_profile_test.clj index 1cdf16a99f..d4cfedf871 100644 --- a/backend/test/backend_tests/rpc_profile_test.clj +++ b/backend/test/backend_tests/rpc_profile_test.clj @@ -125,7 +125,20 @@ out (th/command! data)] ;; (th/print-result! out) - (t/is (nil? (:error out))))))) + (t/is (nil? (:error out))))) + + (t/testing "delete photo clears photo-id" + (let [data {::th/type :delete-profile-photo + ::rpc/profile-id (:id profile)} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:result out)))) + + (let [data {::th/type :get-profile + ::rpc/profile-id (:id profile)} + out (th/command! data)] + (t/is (nil? (:error out))) + (t/is (nil? (:photo-id (:result out)))))))) (t/deftest profile-deletion-1 (let [prof (th/create-profile* 1) diff --git a/backend/test/backend_tests/rpc_viewer_test.clj b/backend/test/backend_tests/rpc_viewer_test.clj index 6c68c12e34..1e69ed87af 100644 --- a/backend/test/backend_tests/rpc_viewer_test.clj +++ b/backend/test/backend_tests/rpc_viewer_test.clj @@ -9,6 +9,7 @@ [app.common.uuid :as uuid] [app.db :as db] [app.rpc :as-alias rpc] + [app.rpc.commands.viewer :as viewer] [backend-tests.helpers :as th] [clojure.test :as t] [datoteka.fs :as fs])) @@ -16,6 +17,28 @@ (t/use-fixtures :once th/state-init) (t/use-fixtures :each th/database-reset) +(t/deftest obfuscate-email-happy-path + (t/is (= "a****@****.com" (viewer/obfuscate-email "alice@example.com"))) + (t/is (= "a****@****.example.com" (viewer/obfuscate-email "alice@sub.example.com"))) + (t/is (= "****@****.com" (viewer/obfuscate-email "bob@bar.com")))) + +(t/deftest obfuscate-email-handles-domain-without-dot + ;; `localhost`-style domains have no `.`; the previous implementation produced + ;; a dangling-dot output like "a****@****." — now the trailing `.` is only + ;; emitted when there actually is a TLD segment to append. + (t/is (= "a****@****" (viewer/obfuscate-email "alice@localhost"))) + (t/is (= "****@****" (viewer/obfuscate-email "x@y")))) + +(t/deftest obfuscate-email-handles-malformed-input + ;; These shapes must not throw — `obfuscate-email` runs while building the + ;; view-only bundle for share-link viewers and an NPE here aborts the whole + ;; RPC response. The previous implementation called `clojure.string/split` + ;; on `nil` for the `no-@` case, raising NullPointerException. + (t/is (= "****@****" (viewer/obfuscate-email nil))) + (t/is (= "****@****" (viewer/obfuscate-email ""))) + (t/is (= "r***@****" (viewer/obfuscate-email "root"))) ; no `@`, count > 3 + (t/is (= "****@****" (viewer/obfuscate-email "bob")))) ; no `@`, count <= 3 + (t/deftest retrieve-bundle (let [prof (th/create-profile* 1 {:is-active true}) prof2 (th/create-profile* 2 {:is-active true}) diff --git a/common/src/app/common/data.cljc b/common/src/app/common/data.cljc index 2d7ad94ef2..cc1247dd8e 100644 --- a/common/src/app/common/data.cljc +++ b/common/src/app/common/data.cljc @@ -1120,6 +1120,71 @@ (when (num? value) (format-precision value precision))))) +(defn- natural-sort-key + "Splits a string into a sequence of alternating string and number segments, + converting numeric segments to longs/ints so they compare by value rather + than lexicographically. e.g. \"size10b\" => (\"size\" 10 \"b\")" + [s] + (map (fn [part] + (if (re-matches #"\d+" part) + #?(:clj (Long/parseLong part) + :cljs (js/parseInt part)) + part)) + (re-seq #"\d+|\D+" s))) + +(defn- natural-compare + "Comparator that orders strings naturally, sorting numeric segments by value + rather than lexicographically. Returns a negative number, zero, or positive + number when a is before, equal to, or after b respectively. + e.g. \"size2\" < \"size10\" instead of \"size10\" < \"size2\"." + [a b] + (loop [ka (natural-sort-key a) + kb (natural-sort-key b)] + (cond + (and (empty? ka) (empty? kb)) 0 + (empty? ka) -1 + (empty? kb) 1 + :else + (let [pa (first ka) + pb (first kb) + result (cond + (and (number? pa) (number? pb)) (compare pa pb) + (and (string? pa) (string? pb)) (compare pa pb) + (number? pa) -1 + :else 1)] + (if (zero? result) + (recur (rest ka) (rest kb)) + result))))) + +(defn natural-sort-by + "Sorts coll by extracting a string key with keyfn and ordering elements + using natural sort order, where embedded numbers are compared by value + rather than lexicographically. + e.g. (natural-sort-by :name [{:name \"size10\"} {:name \"size2\"}]) + => [{:name \"size2\"} {:name \"size10\"}]" + [key coll] + (sort-by key natural-compare coll)) + +(defn sanitize-string [s] + (if s + (-> s + str + str/trim + (str/replace #"[^\w\s\-_()]+" "") + (str/replace #"\s+" " ") + str/trim) + "")) + +(defn get-initials + "Returns up to two uppercase initials extracted from a string. + Non-letter prefixes in each token are ignored." + [name] + (->> (str/split (str/trim (or name "")) #"\s+") + (keep #(first (re-seq #"[a-zA-Z]" %))) + (take 2) + (map str/upper) + (apply str))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Util protocols ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/src/app/common/features.cljc b/common/src/app/common/features.cljc index 516789428b..abe66aaab5 100644 --- a/common/src/app/common/features.cljc +++ b/common/src/app/common/features.cljc @@ -68,6 +68,7 @@ "components/v2" "plugins/runtime" "design-tokens/v1" + "tokens/numeric-input" "variants/v1"}) ;; A set of features which only affects on frontend and can be enabled diff --git a/common/src/app/common/files/changes.cljc b/common/src/app/common/files/changes.cljc index 5b45a7ecd4..c9aa3d3faa 100644 --- a/common/src/app/common/files/changes.cljc +++ b/common/src/app/common/files/changes.cljc @@ -261,7 +261,11 @@ ;; All props are optional, background can be nil because is the ;; way to remove already set background [:background {:optional true} [:maybe ctc/schema:hex-color]] - [:name {:optional true} :string]]] + [:name {:optional true} :string] + ;; Pixel grid display controls — nil removes the per-page override + ;; and falls back to the default hardcoded grid color/opacity. + [:pixel-grid-color {:optional true} [:maybe ctc/schema:hex-color]] + [:pixel-grid-opacity {:optional true} [:maybe ::sm/safe-number]]]] [:set-plugin-data schema:set-plugin-data-change] @@ -853,8 +857,10 @@ [data {:keys [id] :as params}] (d/update-in-when data [:pages-index id] (fn [page] - (let [name (get params :name) - bg (get params :background :not-found)] + (let [name (get params :name) + bg (get params :background :not-found) + grid-color (get params :pixel-grid-color :not-found) + grid-op (get params :pixel-grid-opacity :not-found)] (cond-> page (string? name) (assoc :name name) @@ -863,7 +869,19 @@ (assoc :background bg) (nil? bg) - (dissoc :background)))))) + (dissoc :background) + + (string? grid-color) + (assoc :pixel-grid-color grid-color) + + (and (not= grid-color :not-found) (nil? grid-color)) + (dissoc :pixel-grid-color) + + (number? grid-op) + (assoc :pixel-grid-opacity grid-op) + + (and (not= grid-op :not-found) (nil? grid-op)) + (dissoc :pixel-grid-opacity)))))) (defmethod process-change :set-plugin-data [data {:keys [object-type object-id page-id namespace key value]}] diff --git a/common/src/app/common/files/changes_builder.cljc b/common/src/app/common/files/changes_builder.cljc index d778c60b36..2520a3fe76 100644 --- a/common/src/app/common/files/changes_builder.cljc +++ b/common/src/app/common/files/changes_builder.cljc @@ -219,21 +219,33 @@ (let [page (::page (meta changes))] (mod-page changes page options))) - ([changes page {:keys [name background]}] + ([changes page {:keys [name background pixel-grid-color pixel-grid-opacity]}] (let [change {:type :mod-page :id (:id page)} redo (cond-> change (some? name) (assoc :name name) (some? background) - (assoc :background background)) + (assoc :background background) + + (some? pixel-grid-color) + (assoc :pixel-grid-color pixel-grid-color) + + (some? pixel-grid-opacity) + (assoc :pixel-grid-opacity pixel-grid-opacity)) undo (cond-> change (some? name) (assoc :name (:name page)) (some? background) - (assoc :background (:background page)))] + (assoc :background (:background page)) + + (some? pixel-grid-color) + (assoc :pixel-grid-color (:pixel-grid-color page)) + + (some? pixel-grid-opacity) + (assoc :pixel-grid-opacity (:pixel-grid-opacity page)))] (-> changes (update :redo-changes conj redo) diff --git a/common/src/app/common/files/comp_processors.cljc b/common/src/app/common/files/comp_processors.cljc new file mode 100644 index 0000000000..80e782e7cc --- /dev/null +++ b/common/src/app/common/files/comp_processors.cljc @@ -0,0 +1,115 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.files.comp-processors + "Repair, migration or transformation utilities for components." + (:require + [app.common.logging :as log] + [app.common.types.component :as ctk] + [app.common.types.file :as ctf])) + +(log/set-level! :warn) + +(defn remove-unneeded-objects-in-components + "Some components have an :objects attribute, despite not being deleted. This removes it. + It also adds an empty :objects if it's deleted and does not have it." + [file-data] + (ctf/update-components + file-data + (fn [component] + (if (:deleted component) + (if (nil? (:objects component)) + (do + (log/warn :msg "Adding empty :objects to deleted component" + :component-id (:id component) + :component-name (:name component) + :file-id (:id file-data)) + (assoc component :objects {})) + component) + (if (contains? component :objects) + (do + (log/warn :msg "Removing :objects from non-deleted component" + :component-id (:id component) + :component-name (:name component) + :file-id (:id file-data)) + (dissoc component :objects)) + component))))) + +(defn fix-missing-swap-slots + "Locate shapes that have been swapped (i.e. their shape-ref does not point to the near match) but + they don't have a swap slot. In this case, add one pointing to the near match." + [file-data libraries] + (ctf/update-all-shapes + file-data + (fn [shape] + (if (ctk/subcopy-head? shape) + (let [container (:container (meta shape)) + file {:id (:id file-data) :data file-data} + near-match (ctf/find-near-match file container libraries shape :include-deleted? true :with-context? false)] + (if (and (some? near-match) + (not= (:shape-ref shape) (:id near-match)) + (nil? (ctk/get-swap-slot shape))) + (let [updated-shape (ctk/set-swap-slot shape (:id near-match))] + (log/warn :msg "Adding missing swap slot to shape" + :shape-id (:id shape) + :shape-name (:name shape) + :swap-slot (:id near-match) + :file-id (:id file) + :container-id (:id container) + :container-type (:type container)) + {:result :update :updated-shape updated-shape}) + {:result :keep})) + {:result :keep})))) + +(defn sync-component-id-with-ref-shape + "Ensure that all copies heads have the same component id and file as the referenced shape. + There may be bugs that cause them to get out of sync." + [file-data libraries] + (letfn [(sync-one-iteration + [file-data libraries] + (ctf/update-all-shapes + file-data + (fn [shape] + (if (and (ctk/subcopy-head? shape) (nil? (ctk/get-swap-slot shape))) + (let [container (:container (meta shape)) + file {:id (:id file-data) :data file-data} + ref-shape (ctf/find-ref-shape file container libraries shape {:include-deleted? true :with-context? true})] + (if (and (some? ref-shape) + (or (not= (:component-id shape) (:component-id ref-shape)) + (not= (:component-file shape) (:component-file ref-shape)))) + (let [shape' (cond-> shape + (some? (:component-id ref-shape)) + (assoc :component-id (:component-id ref-shape)) + + (nil? (:component-id ref-shape)) + (dissoc :component-id) + + (some? (:component-file ref-shape)) + (assoc :component-file (:component-file ref-shape)) + + (nil? (:component-file ref-shape)) + (dissoc :component-file))] + (log/warn :msg "Syncing component id and file with ref shape" + :shape-id (:id shape) + :shape-name (:name shape) + :component-id (:component-id shape') + :component-file (:component-file shape') + :ref-shape-id (:id ref-shape) + :file-id (:id file) + :container-id (:id container) + :container-type (:type container)) + {:result :update :updated-shape shape'}) + {:result :keep})) + {:result :keep}))))] + ;; If a copy inside a main is updated, we need to repeat the process for the change to be + ;; propagated to all copies. + (loop [current-data file-data + iteration 0] + (let [next-data (sync-one-iteration current-data libraries)] + (if (or (= current-data next-data) + (> iteration 20)) ;; safety bound + next-data + (recur next-data (inc iteration))))))) diff --git a/common/src/app/common/files/migrations.cljc b/common/src/app/common/files/migrations.cljc index 3655f3ece5..eeb11e9067 100644 --- a/common/src/app/common/files/migrations.cljc +++ b/common/src/app/common/files/migrations.cljc @@ -10,6 +10,7 @@ [app.common.data.macros :as dm] [app.common.features :as cfeat] [app.common.files.changes :as cpc] + [app.common.files.comp-processors :as cfcp] [app.common.files.defaults :as cfd] [app.common.files.helpers :as cfh] [app.common.geom.matrix :as gmt] @@ -1786,6 +1787,24 @@ (update :pages-index d/update-vals update-container) (d/update-when :components d/update-vals update-container)))) +(defmethod migrate-data "0018-remove-unneeded-objects-from-components" + [data _] + (cfcp/remove-unneeded-objects-in-components data)) + +(defmethod migrate-data "0019-fix-missing-swap-slots" + [data _] + (let [libraries (if (:libs data) + (deref (:libs data)) + {})] + (cfcp/fix-missing-swap-slots data libraries))) + +(defmethod migrate-data "0020-sync-component-id-with-near-main" + [data _] + (let [libraries (if (:libs data) + (deref (:libs data)) + {})] + (cfcp/sync-component-id-with-ref-shape data libraries))) + (def available-migrations (into (d/ordered-set) ["legacy-2" @@ -1860,4 +1879,7 @@ "0015-fix-text-attrs-blank-strings" "0015-clean-shadow-color" "0016-copy-fills-from-position-data-to-text-node" - "0017-fix-layout-flex-dir"])) + "0017-fix-layout-flex-dir" + "0018-remove-unneeded-objects-from-components" + "0019-fix-missing-swap-slots" + "0020-sync-component-id-with-near-main"])) diff --git a/common/src/app/common/files/repair.cljc b/common/src/app/common/files/repair.cljc index 454cc78e0a..29e6d4fdf5 100644 --- a/common/src/app/common/files/repair.cljc +++ b/common/src/app/common/files/repair.cljc @@ -334,6 +334,31 @@ (pcb/with-file-data file-data) (pcb/update-shapes [(:id shape)] repair-shape)))) +(defmethod repair-error :component-id-mismatch + [_ {:keys [shape page-id args] :as error} file-data _] + (let [repair-shape + (fn [shape] + ; Set the component-id and component-file to the ones of the near main + (log/debug :hint (str " -> set component-id to " (:component-id args))) + (log/debug :hint (str " -> set component-file to " (:component-file args))) + (cond-> shape + (some? (:component-id args)) + (assoc :component-id (:component-id args)) + + (nil? (:component-id args)) + (dissoc :component-id) + + (some? (:component-file args)) + (assoc :component-file (:component-file args)) + + (nil? (:component-file args)) + (dissoc :component-file)))] + + (log/dbg :hint "repairing shape :component-id-mismatch" :id (:id shape) :name (:name shape) :page-id page-id) + (-> (pcb/empty-changes nil page-id) + (pcb/with-file-data file-data) + (pcb/update-shapes [(:id shape)] repair-shape)))) + (defmethod repair-error :ref-shape-is-head [_ {:keys [shape page-id args] :as error} file-data _] (let [repair-shape @@ -501,7 +526,7 @@ (pcb/update-shapes [(:id shape)] repair-shape)))) (defmethod repair-error :component-nil-objects-not-allowed - [_ {:keys [shape] :as error} file-data _] + [_ {component :shape} file-data _] ; in this error the :shape argument is the component (let [repair-component (fn [component] ;; Remove the objects key, or set it to {} if the component is deleted @@ -513,10 +538,26 @@ (log/debug :hint " -> remove :objects") (dissoc component :objects))))] - (log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id shape) :name (:name shape)) + (log/dbg :hint "repairing component :component-nil-objects-not-allowed" :id (:id component) :name (:name component)) (-> (pcb/empty-changes nil) (pcb/with-library-data file-data) - (pcb/update-component (:id shape) repair-component)))) + (pcb/update-component (:id component) repair-component)))) + +(defmethod repair-error :non-deleted-component-cannot-have-objects + [_ {component :shape} file-data _] ; in this error the :shape argument is the component + (let [repair-component + (fn [component] + ; Remove the :objects field + (if-not (:deleted component) + (do + (log/debug :hint " -> remove :objects") + (dissoc component :objects)) + component))] + + (log/dbg :hint "repairing component :non-deleted-component-cannot-have-objects" :id (:id component) :name (:name component)) + (-> (pcb/empty-changes nil) + (pcb/with-library-data file-data) + (pcb/update-component (:id component) repair-component)))) (defmethod repair-error :invalid-text-touched [_ {:keys [shape page-id] :as error} file-data _] diff --git a/common/src/app/common/files/shapes_builder.cljc b/common/src/app/common/files/shapes_builder.cljc index 76b6ef4c04..a70d6eef9b 100644 --- a/common/src/app/common/files/shapes_builder.cljc +++ b/common/src/app/common/files/shapes_builder.cljc @@ -340,12 +340,26 @@ :svg-viewbox vbox :svg-defs defs}))) +(defn- stroke-only-svg-path? + "Returns true when the SVG element renders only a stroke (fill=none). + Stroke-only paths can have their consecutive touching subpaths safely + merged into a continuous polyline so that `stroke-linejoin` applies at + shared endpoints, without affecting any fill-rule semantics." + [attrs] + (let [attr-fill (some-> (:fill attrs) str/trim) + style-fill (some-> (get-in attrs [:style :fill]) str/trim)] + (= "none" (or attr-fill style-fill)))) + (defn create-path-shape [name frame-id svg-data {:keys [attrs] :as data}] (when (and (contains? attrs :d) (seq (:d attrs))) - (let [transform (csvg/parse-transform (:transform attrs)) - content (cond-> (path/from-string (:d attrs)) - (some? transform) - (path.segm/transform-content transform)) + (let [transform (csvg/parse-transform (:transform attrs)) + stroke-only? (stroke-only-svg-path? attrs) + content (cond-> (path/from-string (:d attrs)) + stroke-only? + (path/merge-touching-subpaths) + + (some? transform) + (path.segm/transform-content transform)) selrect (path.segm/content->selrect content) points (grc/rect->points selrect) @@ -663,6 +677,22 @@ (remove is-style-fragment?) ;; Filter style fragments and hex colors (filter #(contains? defs %))))) ;; Only existing defs +(defn resolve-element-name + "Pick the most user-meaningful name for an SVG element. + + Inkscape (and editors following the same convention) write the + operator-given label to ``inkscape:label``/``sodipodi:label`` while + ``id`` holds an auto-generated technical id like ``path1234``. + Preferring the namespaced label keeps the layer/group/element names + the operator sees in their source editor across a paste/import + (#7869); the existing ``id`` and ``(tag->name tag)`` fallbacks keep + legacy SVGs that don't carry a label working unchanged." + [tag attrs] + (or (:inkscape:label attrs) + (:sodipodi:label attrs) + (:id attrs) + (tag->name tag))) + (defn parse-svg-element [frame-id svg-data {:keys [tag attrs hidden] :as element} unames] @@ -670,7 +700,7 @@ ;; think we should handle this case early and avoid some code ;; execution - (let [name (or (:id attrs) (tag->name tag)) + (let [name (resolve-element-name tag attrs) att-refs (csvg/find-attr-references attrs) defs (get svg-data :defs) valid-refs (filter-valid-def-references att-refs defs) diff --git a/common/src/app/common/files/stats.cljc b/common/src/app/common/files/stats.cljc new file mode 100644 index 0000000000..99a2315243 --- /dev/null +++ b/common/src/app/common/files/stats.cljc @@ -0,0 +1,74 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.files.stats + "Pure helpers that compute aggregate statistics for a file data map. + + Given a decoded file data structure (the value stored under `:data` + on a file row), produces a small map with page/shape/library counts. + Intended to be cheap — a single pass over each page's `:objects` + map, no database access, no side effects." + (:require + [app.common.uuid :as uuid])) + +(def empty-shape-counts + {:total 0 :by-type {}}) + +(defn- inc-type + [by-type shape-type] + (if (nil? shape-type) + by-type + (update by-type shape-type (fnil inc 0)))) + +(defn count-shapes-by-type + "Walk an `:objects` map of a single page and return + `{:total N :by-type {:rect N :frame N ...}}`. The synthetic root + shape at `uuid/zero` is skipped so it never contributes to totals." + [objects] + (if (empty? objects) + empty-shape-counts + (reduce-kv + (fn [acc id shape] + (if (= id uuid/zero) + acc + (-> acc + (update :total inc) + (update :by-type inc-type (:type shape))))) + empty-shape-counts + objects))) + +(defn- merge-shape-counts + [a b] + {:total (+ (:total a) (:total b)) + :by-type (merge-with + (:by-type a) (:by-type b))}) + +(defn- aggregate-shape-counts + [pages-index] + (transduce + (map (comp count-shapes-by-type :objects)) + (completing merge-shape-counts) + empty-shape-counts + (vals pages-index))) + +(defn calc-file-stats + "Given a decoded file data map with the standard keys + `:pages-index`, `:components`, `:deleted-components`, `:colors` + and `:typographies`, return per-file aggregates. + + The result is a plain map suitable for serialization; it never + contains any pointer-map or objects-map instances." + [fdata] + (let [pages-index (get fdata :pages-index) + components (get fdata :components) + deleted-components (get fdata :deleted-components) + colors (get fdata :colors) + typographies (get fdata :typographies)] + {:page-count (count pages-index) + :shape-counts (aggregate-shape-counts pages-index) + :component-count (count components) + :deleted-component-count (count deleted-components) + :color-count (count colors) + :typography-count (count typographies)})) diff --git a/common/src/app/common/files/tokens.cljc b/common/src/app/common/files/tokens.cljc index e8f1208058..ff5853069b 100644 --- a/common/src/app/common/files/tokens.cljc +++ b/common/src/app/common/files/tokens.cljc @@ -26,7 +26,7 @@ [{:keys [value]}] (when (or (str/empty? value) (str/blank? value)) - (tr "workspace.tokens.empty-input"))) + (tr "errors.tokens.empty-input"))) (def schema:token-value-generic [::sm/text {:error/fn token-value-empty-fn}]) @@ -34,7 +34,7 @@ (def schema:token-value-numeric [:and [::sm/text {:error/fn token-value-empty-fn}] - [:fn {:error/fn #(tr "workspace.tokens.invalid-value" (:value %))} + [:fn {:error/fn #(tr "errors.tokens.invalid-value" (:value %))} (fn [value] (if (str/numeric? value) (let [n (d/parse-double value)] @@ -44,7 +44,7 @@ (def schema:token-value-percent [:and [::sm/text {:error/fn token-value-empty-fn}] - [:fn {:error/fn #(tr "workspace.tokens.value-with-percent" (:value %))} + [:fn {:error/fn #(tr "errors.tokens.value-with-percent" (:value %))} (fn [value] (if (d/percent? value) (let [v (d/parse-percent value)] @@ -57,7 +57,7 @@ (def schema:token-value-opacity [:and [::sm/text {:error/fn token-value-empty-fn}] - [:fn {:error/fn #(tr "workspace.tokens.opacity-range")} + [:fn {:error/fn #(tr "errors.tokens.opacity-range")} (fn [opacity] (if (str/numeric? opacity) (let [n (d/parse-percent opacity)] @@ -71,7 +71,7 @@ (def schema:token-value-font-weight [:or - [:fn {:error/fn #(tr "workspace.tokens.invalid-font-weight-token-value")} + [:fn {:error/fn #(tr "errors.tokens.invalid-font-weight-token-value")} cto/valid-font-weight-variant] ::sm/text]) ;; Leave references or formulas to be checked by the resolver @@ -147,6 +147,27 @@ #(and (some? tokens-tree) (not (ctob/token-name-path-exists? % tokens-tree)))]]) +(defn make-node-token-name-schema + "Dynamically generates a schema to check a token node name, adding translated error messages + and two additional validations: + - Min and max length. + - Checks if other token with a path derived from the name already exists at `tokens-tree`. + e.g. it's not allowed to create a token `foo.bar` if a token `foo` already exists." + [active-tokens tokens-tree node] + [:and + [:string {:min 1 :max 255 :error/fn #(str (:value %) (tr "workspace.tokens.token-name-length-validation-error"))}] + (-> cto/schema:token-node-name + (sm/update-properties assoc :error/fn #(str (:value %) (tr "workspace.tokens.token-name-validation-error")))) + [:fn {:error/fn #(tr "workspace.tokens.token-name-duplication-validation-error" (:value %))} + (fn [name] + (let [current-path (:path node) + current-name (:name node) + new-tokens (ctob/update-tokens-group active-tokens current-path current-name name)] + (and (some? new-tokens) + (some (fn [[token-name _]] + (not (ctob/token-name-path-exists? token-name tokens-tree))) + new-tokens))))]]) + (def schema:token-description [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]) @@ -160,11 +181,16 @@ [:value (make-token-value-schema token-type)] [:description {:optional true} schema:token-description]]) [:fn {:error/field :value - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} (fn [{:keys [name value]}] (when (and name value) (not (cto/token-value-self-reference? name value))))]]) +(defn make-node-token-schema + [active-tokens tokens-tree node] + [:map + [:name (make-node-token-name-schema active-tokens tokens-tree node)]]) + (defn convert-dtcg-token "Convert token attributes as they come from a decoded json, with DTCG types, to internal types. Eg. From this: @@ -288,16 +314,12 @@ {:value parsed-value :unit unit})))) -;; FIXME: looks very redundant function -(defn token-identifier - [{:keys [name] :as _token}] - name) - (defn attributes-map - "Creats an attributes map using collection of `attributes` for `id`." + "Creates an attributes map using collection of `attributes` for `id`." [attributes token] - (->> (map (fn [attr] [attr (token-identifier token)]) attributes) - (into {}))) + (into {} + (map (fn [attr] [attr (:name token)])) + attributes)) (defn remove-attributes-for-token "Removes applied tokens with `token-name` for the given `attributes` set from `applied-tokens`." @@ -313,7 +335,7 @@ "Test if `token` is applied to a `shape` on single `token-attribute`." [token shape token-attribute] (when-let [id (dm/get-in shape [:applied-tokens token-attribute])] - (= (token-identifier token) id))) + (= (:name token) id))) (defn token-applied? "Test if `token` is applied to a `shape` with at least one of the given `token-attributes`." diff --git a/common/src/app/common/files/validate.cljc b/common/src/app/common/files/validate.cljc index 5b0e1d74d4..1c16c4dcbc 100644 --- a/common/src/app/common/files/validate.cljc +++ b/common/src/app/common/files/validate.cljc @@ -51,6 +51,7 @@ :ref-shape-is-head :ref-shape-is-not-head :shape-ref-in-main + :component-id-mismatch :root-main-not-allowed :nested-main-not-allowed :root-copy-not-allowed @@ -59,6 +60,7 @@ :not-head-copy-not-allowed :not-component-not-allowed :component-nil-objects-not-allowed + :non-deleted-component-cannot-have-objects :instance-head-not-frame :invalid-text-touched :misplaced-slot @@ -326,6 +328,20 @@ :component-file (:component-file ref-shape) :component-id (:component-id ref-shape))))) +(defn- check-ref-component-id + "Validate that if the copy has not been swapped, the component-id and component-file are + the same as in the referenced shape in the near main." + [shape file page libraries] + (when (nil? (ctk/get-swap-slot shape)) + (when-let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true)] + (when (or (not= (:component-id shape) (:component-id ref-shape)) + (not= (:component-file shape) (:component-file ref-shape))) + (report-error :component-id-mismatch + "Nested copy component-id and component-file must be the same as the near main" + shape file page + :component-id (:component-id ref-shape) + :component-file (:component-file ref-shape)))))) + (defn- check-empty-swap-slot "Validate that this shape does not have any swap slot." [shape file page] @@ -350,6 +366,19 @@ "This shape has children with the same swap slot" shape file page))) +(defn- check-required-swap-slot + "Validate that the shape has swap-slot if it's a subinstance head and the ref shape is not the + matching shape by position in the near main." + [shape file page libraries] + (let [near-match (ctf/find-near-match file page libraries shape :include-deleted? true :with-context? false)] + (when (and (some? near-match) + (not= (:shape-ref shape) (:id near-match)) + (nil? (ctk/get-swap-slot shape))) + (report-error :missing-slot + "Shape has been swapped, should have swap slot" + shape file page + :swap-slot (or (ctk/get-swap-slot near-match) (:id near-match)))))) + (defn- check-valid-touched "Validate that the text touched flags are coherent." [shape file page] @@ -418,6 +447,8 @@ (check-component-not-main-head shape file page libraries) (check-component-not-root shape file page) (check-valid-touched shape file page) + (check-ref-component-id shape file page libraries) + (check-required-swap-slot shape file page libraries) ;; We can have situations where the nested copy and the ancestor copy come from different libraries and some of them have been dettached ;; so we only validate the shape-ref if the ancestor is from a valid library (when library-exists @@ -458,8 +489,7 @@ (defn- check-variant-container "Shape is a variant container, so: -all its children should be variants with variant-id equals to the shape-id - -all the components should have the same properties - " + -all the components should have the same properties" [shape file page] (let [shape-id (:id shape) shapes (:shapes shape) @@ -648,6 +678,13 @@ "Component main not allowed inside other component" main-instance file component-page)))) +(defn- check-not-objects + [component file] + (when (d/not-empty? (:objects component)) + (report-error :non-deleted-component-cannot-have-objects + "A non-deleted component cannot have shapes inside" + component file nil))) + (defn- check-component "Validate semantic coherence of a component. Report all errors found." [component file] @@ -656,7 +693,8 @@ "Objects list cannot be nil" component file nil)) (when-not (:deleted component) - (check-main-inside-main component file)) + (check-main-inside-main component file) + (check-not-objects component file)) (when (:deleted component) (check-component-duplicate-swap-slot component file) (check-ref-cycles component file)) @@ -674,8 +712,6 @@ ;; PUBLIC API: VALIDATION FUNCTIONS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(declare check-swap-slots) - (defn validate-file "Validate full referential integrity and semantic coherence on file data. @@ -686,8 +722,6 @@ (doseq [page (filter :id (ctpl/pages-seq data))] (check-shape uuid/zero file page libraries) - (when (str/includes? (:name file) "check-swap-slot") - (check-swap-slots uuid/zero file page libraries)) (->> (get-orphan-shapes page) (run! #(check-shape % file page libraries)))) @@ -728,40 +762,3 @@ :hint "error on validating file referential integrity" :file-id (:id file) :details errors))) - -(declare compare-slots) - -;; Optional check to look for missing swap slots. -;; Search for copies that do not point the shape-ref to the near component but don't have swap slot -;; (looking for position relative to the parent, in the copy and the main). -;; -;; This check cannot be generally enabled, because files that have been migrated from components v1 -;; may have copies with shapes that do not match by position, but have not been swapped. So we enable -;; it for specific files only. To activate the check, you need to add the string "check-swap-slot" to -;; the name of the file. -(defn- check-swap-slots - [shape-id file page libraries] - (let [shape (ctst/get-shape page shape-id)] - (if (and (ctk/instance-root? shape) (ctk/in-component-copy? shape)) - (let [ref-shape (ctf/find-ref-shape file page libraries shape :include-deleted? true :with-context? true) - container (:container (meta ref-shape))] - (when (some? ref-shape) - (compare-slots shape ref-shape file page container))) - (doall (for [child-id (:shapes shape)] - (check-swap-slots child-id file page libraries)))))) - -(defn- compare-slots - [shape-copy shape-main file container-copy container-main] - (if (and (not= (:shape-ref shape-copy) (:id shape-main)) - (nil? (ctk/get-swap-slot shape-copy))) - (report-error :missing-slot - "Shape has been swapped, should have swap slot" - shape-copy file container-copy - :swap-slot (or (ctk/get-swap-slot shape-main) (:id shape-main))) - (when (nil? (ctk/get-swap-slot shape-copy)) - (let [children-id-pairs (d/zip-all (:shapes shape-copy) (:shapes shape-main))] - (doall (for [[child-copy-id child-main-id] children-id-pairs] - (let [child-copy (ctst/get-shape container-copy child-copy-id) - child-main (ctst/get-shape container-main child-main-id)] - (when (and (some? child-copy) (some? child-main)) - (compare-slots child-copy child-main file container-copy container-main))))))))) diff --git a/common/src/app/common/flags.cljc b/common/src/app/common/flags.cljc index db4bb2731e..7a6dc625f9 100644 --- a/common/src/app/common/flags.cljc +++ b/common/src/app/common/flags.cljc @@ -128,6 +128,8 @@ :token-shadow :token-tokenscript :token-import-from-library + :token-typography-row + ;; Only for developtment. :transit-readable-response :user-feedback @@ -195,8 +197,7 @@ :enable-inspect-styles :enable-feature-fdata-objects-map :enable-feature-render-wasm - ;; Temporary deactivated - #_:enable-token-import-from-library]) + :enable-token-import-from-library]) (defn parse [& flags] diff --git a/common/src/app/common/logic/libraries.cljc b/common/src/app/common/logic/libraries.cljc index 7e703385e7..d1d03f68aa 100644 --- a/common/src/app/common/logic/libraries.cljc +++ b/common/src/app/common/logic/libraries.cljc @@ -118,8 +118,9 @@ (defn- duplicate-component "Clone the root shape of the component and all children. Generate new - ids from all of them." - [component new-component-id library-data force-id delta variant-id] + ids from all of them. Optionally set the component-file if the file where the + new component will reside is different than the origin one." + [component new-component-id new-component-file library-data force-id delta variant-id] (let [main-instance-page (ctf/get-component-page library-data component) main-instance-shape (ctf/get-component-root library-data component) delta (or delta (gpt/point (+ (:width main-instance-shape) 50) 0)) @@ -141,10 +142,18 @@ update-new-shape (fn [new-shape _] (cond-> new-shape - ; Link the new main to the new component + ;; Link the new main to the new component, and re-root it + ;; to the destination file when duplicating across files. + ;; Only the outer main matches `(:id component)`, so + ;; nested main-instances are not touched here. (= (:component-id new-shape) (:id component)) (assoc :component-id new-component-id) + (and (= (:component-id new-shape) (:id component)) + (some? new-component-file) + (not= new-component-file (:component-file new-shape))) + (assoc :component-file new-component-file) + ; If it is the instance root, add it the variant-id (and (ctk/instance-root? new-shape) (some? variant-id)) (assoc :variant-id variant-id) @@ -188,7 +197,7 @@ (defn generate-duplicate-component "Create a new component copied from the one with the given id." - [changes library component-id new-component-id & {:keys [new-shape-id apply-changes-local-library? delta new-variant-id page-id]}] + [changes library component-id new-component-id & {:keys [new-component-file new-shape-id apply-changes-local-library? delta new-variant-id page-id]}] (let [component (ctkl/get-component (:data library) component-id) new-name (:name component) @@ -197,7 +206,7 @@ target-page-id (or page-id (:id main-instance-page)) [new-main-instance-shape new-main-instance-shapes] - (duplicate-component component new-component-id (:data library) new-shape-id delta new-variant-id)] + (duplicate-component component new-component-id new-component-file (:data library) new-shape-id delta new-variant-id)] [new-main-instance-shape (-> changes @@ -333,7 +342,7 @@ (pcb/update-shapes [shape-id] #(do (log/trace :msg " -> promote to root") (assoc % :component-root true))) - :always + (some? (ctk/get-swap-slot shape)) ; First level subinstances of a detached component can't have swap-slot (pcb/update-shapes [shape-id] #(do (log/trace :msg " -> remove swap-slot") (ctk/remove-swap-slot %))) @@ -364,7 +373,7 @@ (let [ref-shape (ctf/find-ref-shape file container libraries shape {:include-deleted? true})] (cond-> changes (some? (:shape-ref ref-shape)) - (pcb/update-shapes [(:id shape)] #(do (log/trace :msg " (advanced)") + (pcb/update-shapes [(:id shape)] #(do (log/trace :msg (str " (advanced to " (:shape-ref ref-shape) ")")) (assoc % :shape-ref (:shape-ref ref-shape)))) ;; When advancing level, the normal touched groups (not swap slots) of the @@ -374,16 +383,18 @@ (pcb/update-shapes [(:id shape)] #(do (log/trace :msg " (merge touched)") + (log/trace :msg (str " (ref-shape: " (:id ref-shape) ")")) + (log/trace :msg (str " (ref touched: " (:touched ref-shape) ")")) (assoc % :touched - (clojure.set/union (:touched shape) - (ctk/normal-touched-groups ref-shape))))) + (set/union (:touched shape) + (ctk/normal-touched-groups ref-shape))))) ;; Swap slot must also be copied if the current shape has not any, ;; except if this is the first level subcopy. (and (some? (ctk/get-swap-slot ref-shape)) (nil? (ctk/get-swap-slot shape)) (not= (:id shape) shape-id)) - (pcb/update-shapes [(:id shape)] #(do (log/trace :msg " (got swap-slot)") + (pcb/update-shapes [(:id shape)] #(do (log/trace :msg (str " (got swap-slot " (ctk/get-swap-slot ref-shape) ")")) (ctk/set-swap-slot % (ctk/get-swap-slot ref-shape)))) ;; If we can't get the ref-shape (e.g. it's in an external library not linked), @@ -771,14 +782,6 @@ ;; is different than the one in the near component (Shape-2-2-1) ;; but it's not touched. -(defn- redirect-shaperef ;;Set the :shape-ref of a shape pointing to the :id of its remote-shape - ([container libraries shape] - (redirect-shaperef nil nil shape (ctf/find-remote-shape container libraries shape))) - ([_ _ shape remote-shape] - (if (some? (:shape-ref shape)) - (assoc shape :shape-ref (:id remote-shape)) - shape))) - (defn generate-sync-shape-direct "Generate changes to synchronize one shape that is the root of a component instance, and all its children, from the given component." @@ -790,18 +793,12 @@ component (ctkl/get-component library (:component-id shape-inst) true)] (if (and (ctk/in-component-copy? shape-inst) (or (ctf/direct-copy? shape-inst component container nil libraries) reset?)) ; In a normal sync, we don't want to sync remote mains, only direct/near - (let [redirect-shaperef (partial redirect-shaperef container libraries) - - shape-main (when component + (let [shape-main (when component (if reset? ;; the reset is against the ref-shape, not against the original shape of the component (ctf/find-ref-shape file container libraries shape-inst) (ctf/get-ref-shape library component shape-inst))) - shape-inst (if reset? - (redirect-shaperef shape-inst shape-main) - shape-inst) - initial-root? (:component-root shape-inst) root-inst shape-inst @@ -819,8 +816,8 @@ root-inst root-main reset? - initial-root? - redirect-shaperef) + initial-root?) + ;; If the component is not found, because the master component has been ;; deleted or the library unlinked, do nothing. changes)) @@ -844,7 +841,7 @@ nil)))))) (defn- generate-sync-shape-direct-recursive - [changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root? redirect-shaperef] + [changes container shape-inst component library file libraries shape-main root-inst root-main reset? initial-root?] (shape-log :debug (:id shape-inst) container :msg "Sync shape direct recursive" :shape-inst (str (:name shape-inst) " " (pretty-uuid (:id shape-inst))) @@ -891,9 +888,6 @@ children-inst (vec (ctn/get-direct-children container shape-inst)) children-main (vec (ctn/get-direct-children component-container shape-main)) - children-inst (if reset? - (map #(redirect-shaperef %) children-inst) children-inst) - only-inst (fn [changes child-inst] (shape-log :trace (:id child-inst) container :msg "Only inst" @@ -942,8 +936,7 @@ root-inst root-main reset? - initial-root? - redirect-shaperef)) + initial-root?)) swapped (fn [changes child-inst child-main] (shape-log :trace (:id child-inst) container @@ -1008,16 +1001,13 @@ the values in the shape and all its children." [changes file libraries container shape-id] (shape-log :debug shape-id container :msg "Sync shape inverse" :shape (str shape-id)) - (let [redirect-shaperef (partial redirect-shaperef container libraries) - shape-inst (ctn/get-shape container shape-id) + (let [shape-inst (ctn/get-shape container shape-id) library (dm/get-in libraries [(:component-file shape-inst) :data]) component (ctkl/get-component library (:component-id shape-inst)) shape-main (when component (ctf/find-remote-shape container libraries shape-inst)) - shape-inst (redirect-shaperef shape-inst shape-main) - initial-root? (:component-root shape-inst) root-inst shape-inst @@ -1038,12 +1028,11 @@ shape-main root-inst root-main - initial-root? - redirect-shaperef) + initial-root?) changes))) (defn- generate-sync-shape-inverse-recursive - [changes container shape-inst component library file libraries shape-main root-inst root-main initial-root? redirect-shaperef] + [changes container shape-inst component library file libraries shape-main root-inst root-main initial-root?] (shape-log :trace (:id shape-inst) container :msg "Sync shape inverse recursive" :shape (str (:name shape-inst)) @@ -1100,8 +1089,6 @@ children-main (mapv #(ctn/get-shape component-container %) (:shapes shape-main)) - children-inst (map #(redirect-shaperef %) children-inst) - only-inst (fn [changes child-inst] (add-shape-to-main changes child-inst @@ -1130,8 +1117,7 @@ child-main root-inst root-main - initial-root? - redirect-shaperef)) + initial-root?)) swapped (fn [changes child-inst child-main] (shape-log :trace (:id child-inst) container @@ -1773,6 +1759,23 @@ (pcb/update-shapes changes [(:id dest-shape)] ctk/unhead-shape {:ignore-touched true}) changes)) +(defn- check-swapped-main + [changes dest-shape origin-shape] + ;; Only for direct updates (from main to copy). Check if the main shape + ;; has been swapped. If so, the new component-id and component-file must + ;; be put into the copy. + (if (and (= (:shape-ref dest-shape) (:id origin-shape)) + (ctk/instance-head? dest-shape) + (ctk/instance-head? origin-shape) + (or (not= (:component-id dest-shape) (:component-id origin-shape)) + (not= (:component-file dest-shape) (:component-file origin-shape)))) + (pcb/update-shapes changes [(:id dest-shape)] + #(assoc % + :component-id (:component-id origin-shape) + :component-file (:component-file origin-shape)) + {:ignore-touched true}) + changes)) + (defn- update-attrs "The main function that implements the attribute sync algorithm. Copy attributes that have changed in the origin shape to the dest shape. @@ -1816,6 +1819,8 @@ :always (check-detached-main dest-shape origin-shape) :always + (check-swapped-main dest-shape origin-shape) + :always (generate-update-tokens container dest-shape origin-shape touched omit-touched? nil)) (let [sync-group @@ -2731,7 +2736,7 @@ frames))) (defn- duplicate-variant - [changes library component base-pos parent page-id into-new-variant?] + [changes library component base-pos parent page-id into-new-variant? new-component-file] (let [component-page (ctpl/get-page (:data library) (:main-instance-page component)) objects (:objects component-page) component-shape (get objects (:main-instance-id component)) @@ -2745,7 +2750,8 @@ {:apply-changes-local-library? true :delta delta :new-variant-id (if into-new-variant? nil (:id parent)) - :page-id page-id}) + :page-id page-id + :new-component-file new-component-file}) value (when into-new-variant? (str ctv/value-prefix (-> (cfv/extract-properties-values (:data library) objects (:id parent)) @@ -2768,15 +2774,18 @@ (defn generate-duplicate-component-change - [changes objects page main parent-id frame-id delta libraries library-data ids-map] - (let [main-id (:id main) - component-id (:component-id main) - file-id (:component-file main) - component (ctf/get-component libraries file-id component-id) - pos (as-> (gsh/move main delta) $ - (gpt/point (:x $) (:y $))) + [changes objects page main parent-id frame-id delta libraries library-data ids-map & {:keys [new-component-file]}] + (let [main-id (:id main) + component-id (:component-id main) + ;; Source library file id (where the component was originally + ;; defined). Renamed from `file-id` to make the contrast with + ;; `new-component-file` explicit when duplicating across files. + source-file-id (:component-file main) + component (ctf/get-component libraries source-file-id component-id) + pos (as-> (gsh/move main delta) $ + (gpt/point (:x $) (:y $))) - parent (get objects parent-id) + parent (get objects parent-id) ;; When we duplicate a variant alone, we will instanciate it @@ -2803,25 +2812,27 @@ (and (ctk/is-variant? main) in-variant-container?) (duplicate-variant changes - (get libraries file-id) + (get libraries source-file-id) component pos parent (:id page) - false) + false + new-component-file) (ctk/is-variant-container? parent) (duplicate-variant changes - (get libraries file-id) + (get libraries source-file-id) component pos parent (:id page) - true) + true + new-component-file) :else (generate-instantiate-component changes objects - file-id + source-file-id component-id pos page @@ -2845,7 +2856,7 @@ changes (ctf/is-main-of-known-component? obj libraries) - (generate-duplicate-component-change changes objects page obj parent-id frame-id delta libraries library-data ids-map) + (generate-duplicate-component-change changes objects page obj parent-id frame-id delta libraries library-data ids-map {:new-component-file file-id}) :else (let [frame? (cfh/frame-shape? obj) diff --git a/common/src/app/common/media.cljc b/common/src/app/common/media.cljc index fc349765a2..87d7b2f401 100644 --- a/common/src/app/common/media.cljc +++ b/common/src/app/common/media.cljc @@ -114,3 +114,15 @@ 800 "Extra Bold" 900 "Black" 950 "Extra Black")) + +(defn font-display-variant + [variant-name weight style] + (cond + (and (string? variant-name) (not (str/blank? variant-name))) + (str/trim variant-name) + + :else + (let [base (font-weight->name weight) + italic? (= "italic" style)] + (cond-> base + italic? (str " Italic"))))) diff --git a/common/src/app/common/path_names.cljc b/common/src/app/common/path_names.cljc index 00038cdf6c..658ffe0349 100644 --- a/common/src/app/common/path_names.cljc +++ b/common/src/app/common/path_names.cljc @@ -148,16 +148,16 @@ Some naming conventions: :path 'one' :depth 0 :leaf nil - :children-fn (fn [] [{:name 'two' - :path 'one.two' - :depth 1 - :leaf nil - :children-fn (fn [] [{... :name 'three'} {... :name 'four'}])} - {:name 'five' - :path 'one.five' - :depth 1 - :leaf {... :name 'five'} - ...}])}]" + :children [{:name 'two' + :path 'one.two' + :depth 1 + :leaf nil + :children [{... :name 'three'} {... :name 'four'}]} + {:name 'five' + :path 'one.five' + :depth 1 + :leaf {... :name 'five'} + :children nil}]}]" (defn- sort-by-children "Sorts segments so that those with children come first." @@ -191,7 +191,7 @@ Some naming conventions: (into (sorted-map) grouped))) (defn- build-tree-node - "Builds a single tree node with lazy children." + "Builds a single tree node with computed children." [segment-name remaining-segments separator parent-path depth] (let [current-path (if parent-path (str parent-path "." segment-name) @@ -208,12 +208,11 @@ Some naming conventions: :path current-path :depth depth :leaf leaf-segment - :children-fn (when-not is-leaf? - (fn [] - (let [grouped-elements (sort-and-group-segments remaining-segments separator)] - (mapv (fn [[child-segment-name remaining-child-segments]] - (build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth))) - grouped-elements))))}] + :children (when-not is-leaf? + (let [grouped-elements (sort-and-group-segments remaining-segments separator)] + (mapv (fn [[child-segment-name remaining-child-segments]] + (build-tree-node child-segment-name remaining-child-segments separator current-path (inc depth))) + grouped-elements)))}] node)) (defn build-tree-root diff --git a/common/src/app/common/test_helpers/compositions.cljc b/common/src/app/common/test_helpers/compositions.cljc index f5c9b5a1ca..83f12fa084 100644 --- a/common/src/app/common/test_helpers/compositions.cljc +++ b/common/src/app/common/test_helpers/compositions.cljc @@ -177,8 +177,11 @@ (thc/instantiate-component component-label copy-root-label copy-root-params))) (defn add-nested-component - [file component1-label main1-root-label main1-child-label component2-label main2-root-label nested-head-label - & {:keys [component1-params root1-params main1-child-params component2-params main2-root-params nested-head-params]}] + [file + component1-label main1-root-label main1-child-label + component2-label main2-root-label nested-head-label + & {:keys [component1-params root1-params main1-child-params + component2-params main2-root-params nested-head-params]}] ;; Generated shape tree: ;; {:main1-root-label} [:name Frame1] # [Component :component1-label] ;; :main1-child-label [:name Rect1] @@ -204,8 +207,13 @@ component2-params))) (defn add-nested-component-with-copy - [file component1-label main1-root-label main1-child-label component2-label main2-root-label nested-head-label copy2-root-label - & {:keys [component1-params root1-params main1-child-params component2-params main2-root-params nested-head-params copy2-root-params]}] + [file + component1-label main1-root-label main1-child-label + component2-label main2-root-label nested-head-label + copy2-root-label + & {:keys [component1-params root1-params main1-child-params + component2-params main2-root-params nested-head-params + copy2-root-params]}] ;; Generated shape tree: ;; {:main1-root-label} [:name Frame1] # [Component :component1-label] ;; :main1-child-label [:name Rect1] @@ -232,6 +240,102 @@ :nested-head-params nested-head-params) (thc/instantiate-component component2-label copy2-root-label copy2-root-params))) +(defn add-two-levels-nested-component + [file + component1-label main1-root-label main1-child-label + component2-label main2-root-label nested-head1-label + component3-label main3-root-label nested-head2-label nested-subhead2-label + & {:keys [component1-params root1-params main1-child-params + component2-params main2-root-params nested-head1-params + component3-params main3-root-params nested-head2-params]}] + ;; Generated shape tree: + ;; {:main1-root-label} [:name Frame1] # [Component :component1-label] + ;; :main1-child-label [:name Rect1] + ;; + ;; {:main2-root-label} [:name Frame2] # [Component :component2-label] + ;; :nested-head1-label [:name Frame1] @--> [Component :component1-label] :main1-root-label + ;; [:name Rect1] ---> :main1-child-label + ;; + ;; {:main3-root-label} [:name Frame3] # [Component :component3-label] + ;; :nested-head2-label [:name Frame2] @--> [Component :component2-label] :main2-root-label + ;; :nested-subhead2-label [:name Frame1] @--> [Component :component1-label] :main1-root-label + ;; [:name Rect1] ---> :main1-child-label + (-> file + (add-simple-component component1-label + main1-root-label + main1-child-label + :component-params component1-params + :root-params root1-params + :child-params main1-child-params) + (add-frame main2-root-label (merge {:name "Frame2"} + main2-root-params)) + (thc/instantiate-component component1-label + nested-head1-label + (assoc nested-head1-params + :parent-label main2-root-label)) + (thc/make-component component2-label + main2-root-label + component2-params) + (add-frame main3-root-label (merge {:name "Frame3"} + main3-root-params)) + (thc/instantiate-component component2-label + nested-head2-label + (assoc nested-head2-params + :parent-label main3-root-label + :children-labels [nested-subhead2-label])) + (thc/make-component component3-label + main3-root-label + component3-params))) + +(defn add-two-levels-nested-component-with-copy + [file + component1-label main1-root-label main1-child-label + component2-label main2-root-label nested-head1-label + component3-label main3-root-label nested-head2-label nested-subhead2-label + copy2-root-label + & {:keys [component1-params root1-params main1-child-params + component2-params main2-root-params nested-head1-params + component3-params main3-root-params nested-head2-params + copy2-root-params]}] + ;; Generated shape tree: + ;; {:main1-root-label} [:name Frame1] # [Component :component1-label] + ;; :main1-child-label [:name Rect1] + ;; + ;; {:main2-root-label} [:name Frame2] # [Component :component2-label] + ;; :nested-head1-label [:name Frame1] @--> [Component :component1-label] :main1-root-label + ;; [:name Rect1] ---> :main1-child-label + ;; + ;; {:main3-root-label} [:name Frame3] # [Component :component3-label] + ;; :nested-head2-label [:name Frame2] @--> [Component :component2-label] :main2-root-label + ;; :nested-subhead2-label [:name Frame1] @--> [Component :component1-label] :main1-root-label + ;; [:name Rect1] ---> :main1-child-label + ;; + ;; :copy2-label [:name Frame3] #--> [Component :component3-label] :main3-root-label + ;; [:name Frame2] @--> [Component :component2-label] :nested-head2-label + ;; [:name Frame1] @--> [Component :component1-label] :nested-subhead2-label + ;; [:name Rect1] ---> + (-> file + (add-two-levels-nested-component component1-label + main1-root-label + main1-child-label + component2-label + main2-root-label + nested-head1-label + component3-label + main3-root-label + nested-head2-label + nested-subhead2-label + :component1-params component1-params + :root1-params root1-params + :main1-child-params main1-child-params + :component2-params component2-params + :main2-root-params main2-root-params + :nested-head1-params nested-head1-params + :component3-params component3-params + :main3-root-params main3-root-params + :nested-head2-params nested-head2-params) + (thc/instantiate-component component3-label copy2-root-label copy2-root-params))) + ;; ----- Getters (defn bottom-shape-by-id @@ -274,15 +378,18 @@ file-id {file-id file} file-id))] - (thf/apply-changes file changes))) + (thf/apply-changes file changes :validate? false))) -(defn swap-component +(defn swap-component- "Swap the specified shape by the component specified by component-tag" - [file shape component-tag & {:keys [page-label propagate-fn keep-touched? new-shape-label]}] + [file shape component-tag & {:keys [page-label propagate-fn keep-touched? new-shape-label library]}] (let [page (if page-label (thf/get-page file page-label) (thf/current-page file)) - libraries {(:id file) file} + libraries (cond-> {(:id file) file} + (some? library) + (assoc (:id library) library)) + library (or library file) orig-shapes (when keep-touched? (cfh/get-children-with-self (:objects page) (:id shape))) @@ -290,10 +397,10 @@ (cll/generate-component-swap (pcb/empty-changes) (:objects page) shape - (:data file) + (:data library) page libraries - (-> (thc/get-component file component-tag) + (-> (thc/get-component library component-tag) :id) 0 nil @@ -305,26 +412,36 @@ [changes nil]) - file' (thf/apply-changes file changes)] + file' (thf/apply-changes file changes :validate? (not propagate-fn))] (when new-shape-label (thi/rm-id! (:id new-shape)) (thi/set-id! new-shape-label (:id new-shape))) (if propagate-fn - (propagate-fn file') + (-> (propagate-fn file') + (thf/validate-file!)) file'))) -(defn swap-component-in-shape [file shape-tag component-tag & {:keys [page-label propagate-fn]}] - (swap-component file (ths/get-shape file shape-tag :page-label page-label) component-tag :page-label page-label :propagate-fn propagate-fn)) +(defn swap-component-in-shape + [file shape-tag component-tag & {:keys [page-label propagate-fn keep-touched? new-shape-label library]}] + (swap-component- file (ths/get-shape file shape-tag :page-label page-label) + component-tag + :page-label page-label + :propagate-fn propagate-fn + :keep-touched? keep-touched? + :new-shape-label new-shape-label + :library library)) -(defn swap-component-in-first-child [file shape-tag component-tag & {:keys [page-label propagate-fn]}] +(defn swap-component-in-first-child + [file shape-tag component-tag & {:keys [page-label propagate-fn library]}] (let [first-child-id (->> (ths/get-shape file shape-tag :page-label page-label) :shapes first)] - (swap-component file - (ths/get-shape-by-id file first-child-id :page-label page-label) - component-tag - :page-label page-label - :propagate-fn propagate-fn))) + (swap-component- file + (ths/get-shape-by-id file first-child-id :page-label page-label) + component-tag + :page-label page-label + :propagate-fn propagate-fn + :library library))) (defn update-color "Update the first fill color for the shape identified by shape-tag" @@ -339,9 +456,10 @@ (assoc shape :fills (ths/sample-fills-color :fill-color color))) (:objects page) {}) - file' (thf/apply-changes file changes)] + file' (thf/apply-changes file changes :validate? (not propagate-fn))] (if propagate-fn - (propagate-fn file') + (-> (propagate-fn file') + (thf/validate-file!)) file'))) (defn update-bottom-color @@ -357,9 +475,10 @@ (assoc shape :fills (ths/sample-fills-color :fill-color color))) (:objects page) {}) - file' (thf/apply-changes file changes)] + file' (thf/apply-changes file changes :validate? (not propagate-fn))] (if propagate-fn - (propagate-fn file') + (-> (propagate-fn file') + (thf/validate-file!)) file'))) (defn reset-overrides [file shape & {:keys [page-label propagate-fn]}] @@ -374,9 +493,10 @@ {file-id file} (ctn/make-container container :page) (:id shape))) - file' (thf/apply-changes file changes)] + file' (thf/apply-changes file changes :validate? (not propagate-fn))] (if propagate-fn - (propagate-fn file') + (-> (propagate-fn file') + (thf/validate-file!)) file'))) (defn reset-overrides-in-first-child [file shape-tag & {:keys [page-label propagate-fn]}] @@ -398,9 +518,10 @@ #{(-> (ths/get-shape file shape-tag :page-label page-label) :id)} {}) - file' (thf/apply-changes file changes)] + file' (thf/apply-changes file changes :validate? (not propagate-fn))] (if propagate-fn - (propagate-fn file') + (-> (propagate-fn file') + (thf/validate-file!)) file'))) (defn duplicate-shape [file shape-tag & {:keys [page-label propagate-fn]}] @@ -419,8 +540,9 @@ (:id file)) ;; file-id (cll/generate-duplicate-changes-update-indices (:objects page) ;; objects #{(:id shape)})) - file' (thf/apply-changes file changes)] + file' (thf/apply-changes file changes :validate? (not propagate-fn))] (if propagate-fn - (propagate-fn file') + (-> (propagate-fn file') + (thf/validate-file!)) file'))) diff --git a/common/src/app/common/test_helpers/files.cljc b/common/src/app/common/test_helpers/files.cljc index a80675b65a..6357ab555b 100644 --- a/common/src/app/common/test_helpers/files.cljc +++ b/common/src/app/common/test_helpers/files.cljc @@ -54,12 +54,14 @@ ([file] (validate-file! file {})) ([file libraries] (cfv/validate-file-schema! file) - (cfv/validate-file! file libraries))) + (cfv/validate-file! file libraries) + file)) (defn apply-changes - [file changes] + [file changes & {:keys [validate?] :or {validate? true}}] (let [file' (ctf/update-file-data file #(cfc/process-changes % (:redo-changes changes) true))] - (validate-file! file') + (when validate? + (validate-file! file')) file')) (defn apply-undo-changes diff --git a/common/src/app/common/test_helpers/shapes.cljc b/common/src/app/common/test_helpers/shapes.cljc index b212984e06..d557c0501f 100644 --- a/common/src/app/common/test_helpers/shapes.cljc +++ b/common/src/app/common/test_helpers/shapes.cljc @@ -82,6 +82,18 @@ (:id page) #(ctst/set-shape % (ctn/set-shape-attr shape attr val))))))) +(defn update-shape-by-id + [file shape-id attr val & {:keys [page-label]}] + (let [page (if page-label + (thf/get-page file page-label) + (thf/current-page file)) + shape (ctst/get-shape page shape-id)] + (update file :data + (fn [file-data] + (ctpl/update-page file-data + (:id page) + #(ctst/set-shape % (ctn/set-shape-attr shape attr val))))))) + (defn update-shape-text [file shape-label attr val & {:keys [page-label]}] (let [page (if page-label diff --git a/common/src/app/common/types/color.cljc b/common/src/app/common/types/color.cljc index ae56250d96..f626e48ae3 100644 --- a/common/src/app/common/types/color.cljc +++ b/common/src/app/common/types/color.cljc @@ -610,6 +610,36 @@ [hsv] (-> hsv hsv->hex hex->hsl)) +;; HSB (Hue, Saturation, Brightness) — same color model as HSV but with +;; the brightness component normalized to a 0-100 range, matching Figma, +;; Sketch, and Adobe XD conventions. Internally we reuse the HSV math and +;; only rescale the brightness axis. + +(defn rgb->hsb + [rgb] + (let [[h s v] (rgb->hsv rgb)] + [h s (* (/ v 255.0) 100.0)])) + +(defn hsb->rgb + [[h s b]] + (hsv->rgb [h s (int (* (/ b 100.0) 255.0))])) + +(defn hex->hsb + [v] + (-> v hex->rgb rgb->hsb)) + +(defn hsb->hex + [hsb] + (-> hsb hsb->rgb rgb->hex)) + +(defn hsv->hsb + [[h s v]] + [h s (* (/ v 255.0) 100.0)]) + +(defn hsb->hsv + [[h s b]] + [h s (int (* (/ b 100.0) 255.0))]) + (defn expand-hex [v] (cond diff --git a/common/src/app/common/types/component.cljc b/common/src/app/common/types/component.cljc index d07ffaeb50..ecc6e30c65 100644 --- a/common/src/app/common/types/component.cljc +++ b/common/src/app/common/types/component.cljc @@ -163,11 +163,15 @@ Note that design tokens also are involved, although they go by an alternate route and thus they are not part of :sync-attrs. Also when detaching a nested copy it also needs to trigger a synchronization, - even though :shape-ref is not a synced attribute per se" + even though :shape-ref, :component-id or :component-file are not synced + attributes per se." [attr] (or (contains? sync-attrs attr) (= :shape-ref attr) - (= :applied-tokens attr))) + (= :applied-tokens attr) + (= :component-id attr) + (= :component-file attr) + (= :component-root attr))) (defn instance-root? "Check if this shape is the head of a top instance." diff --git a/common/src/app/common/types/components_list.cljc b/common/src/app/common/types/components_list.cljc index c4f3a66063..be92b16999 100644 --- a/common/src/app/common/types/components_list.cljc +++ b/common/src/app/common/types/components_list.cljc @@ -60,6 +60,9 @@ (some? objects) (assoc :objects objects) + (nil? objects) + (dissoc :objects) + (some? modified-at) (assoc :modified-at modified-at) diff --git a/common/src/app/common/types/container.cljc b/common/src/app/common/types/container.cljc index 20e7ed3614..c3aab0df4e 100644 --- a/common/src/app/common/types/container.cljc +++ b/common/src/app/common/types/container.cljc @@ -55,6 +55,10 @@ [page-or-component type] (assoc page-or-component :type type)) +(defn unmake-container + [container] + (dissoc container :type)) + (defn page? [container] (= (:type container) :page)) diff --git a/common/src/app/common/types/file.cljc b/common/src/app/common/types/file.cljc index 3733359a6c..974db477b3 100644 --- a/common/src/app/common/types/file.cljc +++ b/common/src/app/common/types/file.cljc @@ -204,7 +204,8 @@ (defn update-file-data [file f] - (update file :data f)) + (when file + (update file :data f))) (defn containers-seq "Generate a sequence of all pages and all components, wrapped as containers" @@ -225,6 +226,85 @@ (ctpl/update-page file-data (:id container) f) (ctkl/update-component file-data (:id container) f))) +(defn update-pages + "Update all pages inside the file" + [file-data f] + (update file-data :pages-index d/update-vals + (fn [page] + (-> page + (ctn/make-container :page) + (f) + (ctn/unmake-container))))) + +(defn update-components + "Update all components inside the file" + [file-data f] + (d/update-when file-data :components d/update-vals + (fn [component] + (-> component + (ctn/make-container :component) + (f) + (ctn/unmake-container))))) + +(defn update-containers + "Update all pages and components inside the file" + [file-data f] + (-> file-data + (update-pages f) + (update-components f))) + +(defn update-objects-tree + "Do a depth-first traversal of the shapes in a container, doing different kinds of updates. + The function f receives a shape with a context metadata with the container. + It must return a map with the following keys: + - :result -> :keep, :update or :remove + - :updated-shape -> the updated shape if result is :update" + [container f] + (letfn [(update-shape-recursive + [container shape-id] + (let [shape (ctst/get-shape container shape-id)] + (when (not shape) + (throw (ex-info "Shape not found" {:shape-id shape-id}))) + (let [shape (with-meta shape {:container container}) + + {:keys [result updated-shape]} (f shape) + + container' + (case result + :keep + container + + :update + (ctst/set-shape container updated-shape) + + :remove + (ctst/delete-shape container shape-id true) + + (throw (ex-info "Invalid result from update function" {:result result})))] + + (if (= result :remove) + container' + (reduce update-shape-recursive + container' + (:shapes shape))))))] + + (let [root-id (if (ctn/page? container) + uuid/zero + (:main-instance-id container))] + + (if-not (empty? (:objects container)) + (update-shape-recursive container root-id) + container)))) + +(defn update-all-shapes + "Update all shapes in the file data, using the update-objects-tree function for each container" + [file-data f] + (when file-data + (update-containers + file-data + (fn [container] + (update-objects-tree container f))))) + ;; Asset helpers (defn find-component-file [file libraries component-file] @@ -328,6 +408,27 @@ (get-ref-shape (:data component-file) component shape :with-context? with-context?))))] (some find-ref-shape-in-head (ctn/get-parent-heads (:objects container) shape)))) +(defn find-near-match + "Locate the shape that occupies the same position in the near main component. + This will be the ref-shape except if the shape is a copy subhead that has been + swapped. In this case, the near match will be the ref-shape that was before + the swap." + [file container libraries shape & {:keys [include-deleted? with-context?] :or {include-deleted? false with-context? false}}] + (let [parent-shape (ctst/get-shape container (:parent-id shape)) + parent-ref-shape (when parent-shape + (find-ref-shape file container libraries parent-shape :include-deleted? include-deleted? :with-context? true)) + ref-container (when parent-ref-shape + (:container (meta parent-ref-shape))) + shape-index (when parent-shape + (d/index-of (:shapes parent-shape) (:id shape))) + near-match-id (when (and parent-ref-shape shape-index) + (get (:shapes parent-ref-shape) shape-index)) + near-match (when near-match-id + (cond-> (ctst/get-shape ref-container near-match-id) + with-context? + (with-meta (meta parent-ref-shape))))] + near-match)) + (defn advance-shape-ref "Get the shape-ref of the near main of the shape, recursively repeated as many times as the given levels." diff --git a/common/src/app/common/types/organization.cljc b/common/src/app/common/types/organization.cljc new file mode 100644 index 0000000000..f19833b585 --- /dev/null +++ b/common/src/app/common/types/organization.cljc @@ -0,0 +1,50 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.common.types.organization + (:require + [app.common.schema :as sm])) + +(def schema:organization + [:map + [:id ::sm/uuid] + [:name ::sm/text] + [:slug ::sm/text] + [:owner-id ::sm/uuid] + [:avatar-bg-url ::sm/uri] + [:logo-id {:optional true} [:maybe ::sm/uuid]]]) + + +(def schema:team-with-organization + [:map + [:id ::sm/uuid] + [:is-your-penpot :boolean] + [:organization schema:organization]]) + +(def organization->team-keys + "Mapping from organization field keys to their corresponding :organization-* team keys." + [[:id :organization-id] + [:name :organization-name] + [:custom-photo :organization-custom-photo] + [:slug :organization-slug] + [:avatar-bg-url :organization-avatar-bg-url] + [:owner-id :organization-owner-id]]) + +(defn apply-organization + "Updates a team map with organization fields sourced from org. + Associates each org field to the corresponding :organization-* team key when + the value is non-nil; dissociates the key otherwise. This correctly handles + both attaching an org (all values present) and detaching one (org is nil or + all fields absent)." + [team organization] + (let [id (:id organization)] + (reduce (fn [acc [org-k team-k]] + (let [v (get organization org-k)] + (if (and id (some? v)) + (assoc acc team-k v) + (dissoc acc team-k)))) + team + organization->team-keys))) \ No newline at end of file diff --git a/common/src/app/common/types/page.cljc b/common/src/app/common/types/page.cljc index 0d4041aaa0..e6fa26a006 100644 --- a/common/src/app/common/types/page.cljc +++ b/common/src/app/common/types/page.cljc @@ -34,7 +34,8 @@ [:id ::sm/uuid] [:axis [::sm/one-of #{:x :y}]] [:position ::sm/safe-number] - [:frame-id {:optional true} [:maybe ::sm/uuid]]]) + [:frame-id {:optional true} [:maybe ::sm/uuid]] + [:color {:optional true} [:maybe ctc/schema:hex-color]]]) (def schema:guides [:map-of {:gen/max 2} ::sm/uuid schema:guide]) @@ -58,6 +59,10 @@ [:guides {:optional true} schema:guides] [:plugin-data {:optional true} ctpg/schema:plugin-data] [:background {:optional true} ctc/schema:hex-color] + ;; Per-page pixel grid color. Falls back to a hardcoded default when + ;; unset so existing files render identically to before. + [:pixel-grid-color {:optional true} ctc/schema:hex-color] + [:pixel-grid-opacity {:optional true} ::sm/safe-number] [:comment-thread-positions {:optional true} [:map-of ::sm/uuid schema:comment-thread-position]]]) diff --git a/common/src/app/common/types/path.cljc b/common/src/app/common/types/path.cljc index f3b7c635ab..601de4c36d 100644 --- a/common/src/app/common/types/path.cljc +++ b/common/src/app/common/types/path.cljc @@ -84,6 +84,19 @@ (-> (subpath/close-subpaths content) (impl/from-plain))) +(defn merge-touching-subpaths + "Given a content, fold consecutive subpaths whose endpoints coincide + into a single continuous subpath, returning a PathData instance. + + Conservative counterpart of `close-subpaths`: only adjacent subpaths + are merged and none are reversed, so fill rules and stroke-dasharray + semantics are preserved. Used at SVG-import time on stroke-only paths + to recover the `stroke-linejoin` rendering when authoring tools split + a continuous polyline into adjacent `M..L M..L` subpaths." + [content] + (-> (subpath/merge-touching-subpaths content) + (impl/from-plain))) + (defn apply-content-modifiers "Apply delta modifiers over the path content" [content modifiers] diff --git a/common/src/app/common/types/path/subpath.cljc b/common/src/app/common/types/path/subpath.cljc index b7f13a0aea..12065891e6 100644 --- a/common/src/app/common/types/path/subpath.cljc +++ b/common/src/app/common/types/path/subpath.cljc @@ -128,6 +128,36 @@ (def ^:private xf-mapcat-data (mapcat :data)) +(defn- join-adjacent + "Fold neighbouring subpaths into the accumulator only when the + current accumulator's end-point matches the next subpath's start-point. + Unlike `merge-paths` this does not reverse subpaths nor reorder them; + the original draw order is preserved so stroke-dasharray and animation + semantics stay intact." + [acc subpath] + (if-let [prev (peek acc)] + (if (and (not (is-closed? prev)) + (not (is-closed? subpath)) + (pt= (:to prev) (:from subpath))) + (conj (pop acc) (subpaths-join prev subpath)) + (conj acc subpath)) + (conj acc subpath))) + +(defn merge-touching-subpaths + "Merge consecutive subpaths whose endpoints coincide into a single + continuous subpath, preserving the original drawing order. + + This is a conservative variant of `close-subpaths`: it never reverses + a subpath and only merges immediate neighbours, so closed regions and + fill semantics are left untouched. The intent is to recover the + `stroke-linejoin` rendering for SVG paths whose authoring tools split + a continuous polyline into adjacent `M..L M..L` subpaths (e.g. the + `m0 0` markers Figma emits when exporting Heroicons-like icons)." + [content] + (let [subpaths (get-subpaths content) + merged (reduce join-adjacent [] subpaths)] + (into [] xf-mapcat-data merged))) + (defn close-subpaths "Searches a path for possible subpaths that can create closed loops and merge them" [content] diff --git a/common/src/app/common/types/shape.cljc b/common/src/app/common/types/shape.cljc index 20935a1723..5f6ac22ad3 100644 --- a/common/src/app/common/types/shape.cljc +++ b/common/src/app/common/types/shape.cljc @@ -145,7 +145,8 @@ [::sm/one-of stroke-caps]] [:stroke-color {:optional true} clr/schema:hex-color] [:stroke-color-gradient {:optional true} clr/schema:gradient] - [:stroke-image {:optional true} clr/schema:image]]) + [:stroke-image {:optional true} clr/schema:image] + [:hidden {:optional true} :boolean]]) (def stroke-attrs "A set of attrs that corresponds to stroke data type" diff --git a/common/src/app/common/types/shape/layout.cljc b/common/src/app/common/types/shape/layout.cljc index caea9d5f91..a3c9e31ed6 100644 --- a/common/src/app/common/types/shape/layout.cljc +++ b/common/src/app/common/types/shape/layout.cljc @@ -874,6 +874,42 @@ (duplicate-cells :column index (inc index) ids-map) (assign-cells objects)))) +(defn duplicate-row-at + "Duplicate source row and insert the copy at target-index (0-indexed). + Like `duplicate-row` but inserts at an arbitrary position. + Note: after add-grid-row, if target <= source the source cells shift + by +1, so we must adjust the from-index for duplicate-cells." + [shape objects source-index target-index ids-map] + (let [value (dm/get-in shape [:layout-grid-rows source-index]) + ;; After inserting at target-index, cells at rows >= (inc target-index) + ;; get shifted +1. If target <= source, the source row shifts. + adjusted-source (if (<= target-index source-index) + (inc source-index) + source-index)] + (-> shape + (remove-cell-areas-after :row source-index) + (add-grid-row value target-index) + (duplicate-cells :row adjusted-source target-index ids-map) + (assign-cells objects)))) + +(defn duplicate-column-at + "Duplicate source column and insert the copy at target-index (0-indexed). + Like `duplicate-column` but inserts at an arbitrary position. + Note: after add-grid-column, if target <= source the source cells shift + by +1, so we must adjust the from-index for duplicate-cells." + [shape objects source-index target-index ids-map] + (let [value (dm/get-in shape [:layout-grid-columns source-index]) + ;; After inserting at target-index, cells at columns >= (inc target-index) + ;; get shifted +1. If target <= source, the source column shifts. + adjusted-source (if (<= target-index source-index) + (inc source-index) + source-index)] + (-> shape + (remove-cell-areas-after :column source-index) + (add-grid-column value target-index) + (duplicate-cells :column adjusted-source target-index ids-map) + (assign-cells objects)))) + (defn make-remove-cell [attr span-attr track-num] (fn [[_ cell]] diff --git a/common/src/app/common/types/shape_tree.cljc b/common/src/app/common/types/shape_tree.cljc index 92732e18a1..3944d96afb 100644 --- a/common/src/app/common/types/shape_tree.cljc +++ b/common/src/app/common/types/shape_tree.cljc @@ -16,8 +16,6 @@ [app.common.types.shape.layout :as ctl] [app.common.uuid :as uuid])) - -;; FIXME: the order of arguments seems arbitrary, container should be a first artgument (defn add-shape "Insert a shape in the tree, at the given index below the given parent or frame. Update the parent as needed." diff --git a/common/src/app/common/types/team.cljc b/common/src/app/common/types/team.cljc index 73a4085819..bd33ab14a0 100644 --- a/common/src/app/common/types/team.cljc +++ b/common/src/app/common/types/team.cljc @@ -32,3 +32,4 @@ [:id ::sm/uuid] [:name :string]]) + diff --git a/common/src/app/common/types/text.cljc b/common/src/app/common/types/text.cljc index 0a629a8379..cde27c19c4 100644 --- a/common/src/app/common/types/text.cljc +++ b/common/src/app/common/types/text.cljc @@ -356,6 +356,32 @@ [k (get attrs k v)])))) +(defn content-has-text? + [content search] + (let [search-lower (str/lower search)] + (->> (node-seq is-text-node? content) + (some #(str/includes? (str/lower (:text %)) search-lower)) + (boolean)))) + +(defn replace-all-case-insensitive + [text search replacement] + (let [text-lower (str/lower text) + search-lower (str/lower search) + search-len (count search)] + (loop [result "" idx 0] + (let [found (str/index-of text-lower search-lower idx)] + (if (nil? found) + (str result (subs text idx)) + (recur (str result (subs text idx found) replacement) + (+ found search-len))))))) + +(defn replace-text-in-content + [content search replacement] + (transform-nodes + is-text-node? + (fn [node] (update node :text replace-all-case-insensitive search replacement)) + content)) + (defn content->text "Given a root node of a text content extracts the texts with its associated styles" [content] diff --git a/common/src/app/common/types/token.cljc b/common/src/app/common/types/token.cljc index a64a15dd45..1ece712296 100644 --- a/common/src/app/common/types/token.cljc +++ b/common/src/app/common/types/token.cljc @@ -136,6 +136,9 @@ (def token-name-validation-regex #"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$") +(def token-node-name-validation-regex + #"^[a-zA-Z0-9_-][a-zA-Z0-9$_-]*(\.[a-zA-Z0-9$_-]+)*$") + (def schema:token-name "A token name can contains letters, numbers, underscores the character $ and dots, but not start with $ or end with a dot. The $ character does not have any special meaning, @@ -153,6 +156,14 @@ :gen/gen sg/text} token-ref-validation-regex]) +(def schema:token-node-name + "A token node name can contains letters, numbers, underscores and the character $, but + not start with $ or a dot, or end with a dot. The $ character does not have any special meaning, + but dots separate token groups (e.g. color.primary.background)." + [:re {:title "TokenNodeName" + :gen/gen sg/text} + token-node-name-validation-regex]) + (def schema:token-type [::sm/one-of {:decode/json (fn [type] (if (string? type) @@ -521,31 +532,32 @@ (def tokens-by-input "A map from input name to applicable token for that input." - {:width #{:sizing :dimensions} - :height #{:sizing :dimensions} - :max-width #{:sizing :dimensions} - :max-height #{:sizing :dimensions} - :min-width #{:sizing :dimensions} - :min-height #{:sizing :dimensions} - :x #{:dimensions} - :y #{:dimensions} - :rotation #{:number :rotation} - :border-radius #{:border-radius :dimensions} - :row-gap #{:spacing :dimensions} - :column-gap #{:spacing :dimensions} - :horizontal-padding #{:spacing :dimensions} - :vertical-padding #{:spacing :dimensions} - :sided-paddings #{:spacing :dimensions} - :horizontal-margin #{:spacing :dimensions} - :vertical-margin #{:spacing :dimensions} - :sided-margins #{:spacing :dimensions} - :line-height #{:line-height :number} - :opacity #{:opacity} - :stroke-width #{:stroke-width :dimensions} - :font-size #{:font-size} - :letter-spacing #{:letter-spacing} - :fill #{:color} - :stroke-color #{:color}}) + {:width [:sizing :dimensions] + :height [:sizing :dimensions] + :max-width [:sizing :dimensions] + :max-height [:sizing :dimensions] + :min-width [:sizing :dimensions] + :min-height [:sizing :dimensions] + :x [:dimensions] + :y [:dimensions] + :rotation [:rotation :number] + :border-radius [:border-radius :dimensions] + :row-gap [:spacing :dimensions] + :column-gap [:spacing :dimensions] + :horizontal-padding [:spacing :dimensions] + :vertical-padding [:spacing :dimensions] + :sided-paddings [:spacing :dimensions] + :horizontal-margin [:spacing :dimensions] + :vertical-margin [:spacing :dimensions] + :sided-margins [:spacing :dimensions] + :line-height [:line-height :number] + :opacity [:opacity] + :stroke-width [:stroke-width :dimensions] + :font-size [:font-size] + :letter-spacing [:letter-spacing] + :fill [:color] + :stroke-color [:color] + :typography [:typography]}) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; HELPERS for tokens application diff --git a/common/src/app/common/types/tokens_lib.cljc b/common/src/app/common/types/tokens_lib.cljc index 050de69c02..a54a243bab 100644 --- a/common/src/app/common/types/tokens_lib.cljc +++ b/common/src/app/common/types/tokens_lib.cljc @@ -153,6 +153,18 @@ tokens)] (group-by :type tokens'))) +(defn rename-path + "Renames a node or token path segment with a new name. + If token is provided, it renames a token path, otherwise it renames a node path." + ([node new-name] + (rename-path node nil new-name)) + ([node token new-name] + (let [element (if token (:name token) (:path node)) + split-path (cpn/split-path element :separator ".") + updated-split-element-name (assoc split-path (:depth node) new-name) + new-element-path (cpn/join-path updated-split-element-name :separator "." :with-spaces? false)] + new-element-path))) + ;; === Token Set (defprotocol ITokenSet @@ -920,6 +932,7 @@ Will return a value that matches this schema: `:all` All of the nested sets are active `:partial` Mixed active state of nested sets") (get-tokens-in-active-sets [_] "set of set names that are active in the the active themes") + (get-tokens-in-active-sets-force [_ force-set-id] "same as above but forcing a set to be active, even if it's not in the active themes") (get-all-tokens [_] "all tokens in the lib, as a sequence") (get-all-tokens-map [_] "all tokens in the lib, as a map name -> token") (get-tokens [_ set-id] "return a map of tokens in the set, indexed by token-name")) @@ -1317,6 +1330,21 @@ Will return a value that matches this schema: active-set-names)] tokens)) + (get-tokens-in-active-sets-force [this force-set-id] + (let [theme-set-names (get-active-themes-set-names this) + all-set-names (get-set-names this) + force-set (get-set this force-set-id) + active-set-names (cond-> (filter theme-set-names all-set-names) + (some? force-set) + (conj (get-name force-set))) + + tokens (reduce (fn [tokens set-name] + (let [set (get-set-by-name this set-name)] + (merge tokens (get-tokens- set)))) + (d/ordered-map) + active-set-names)] + tokens)) + (get-all-tokens [this] (mapcat #(vals (get-tokens- %)) (get-sets this))) @@ -1493,6 +1521,30 @@ Will return a value that matches this schema: (seq) (boolean))))) +(defn update-tokens-group + "Updates the active tokens path when renaming a group node. + - Filters tokens whose path matches the current path prefix + - Replaces the token name with the new name + - Updates the :path value in the token object + + active-tokens: map of token-name to token-object for all active tokens in the set + current-path: the path of the group being renamed, e.g. \"foo.bar\" + current-name: the current name of the group being renamed, e.g. \"bar\" + new-name: the new name for the group being renamed, e.g. \"baz\"" + + [active-tokens current-path current-name new-name] + (let [path-prefix (str/replace current-path current-name "")] + (mapv (fn [[token-path token-obj]] + (if (str/starts-with? token-path path-prefix) + (let [new-token-path (str/replace token-path current-name new-name) + new-token-obj (-> token-obj + (assoc :name new-token-path) + (cond-> (:path token-obj) + (assoc :path (str/replace (:path token-obj) current-name new-name))))] + [new-token-path new-token-obj]) + [token-path token-obj])) + active-tokens))) + ;; === Import / Export from JSON format ;; Supported formats: diff --git a/common/src/app/common/uuid.cljc b/common/src/app/common/uuid.cljc index 9b21f8f796..ed542d868b 100644 --- a/common/src/app/common/uuid.cljc +++ b/common/src/app/common/uuid.cljc @@ -60,8 +60,9 @@ :cljs (uuid (impl/v4)))) (defn custom - ([a] #?(:clj (UUID. 0 a) :cljs (uuid (impl/custom 0 a)))) - ([b a] #?(:clj (UUID. b a) :cljs (uuid (impl/custom b a))))) + "Generate a uuid using directly the given number (specified as one or two long integers)" + ([low] #?(:clj (UUID. 0 low) :cljs (uuid (impl/custom 0 low)))) + ([high low] #?(:clj (UUID. high low) :cljs (uuid (impl/custom high low))))) (def zero (uuid "00000000-0000-0000-0000-000000000000")) @@ -137,6 +138,22 @@ (+ (clojure.lang.Murmur3/hashLong a) (clojure.lang.Murmur3/hashLong b))))) +;; Fake uuids generator +(def ^:private fake-ids (atom 0)) + +(defn reset-fake! + "Reset the fake uuid counter to 0, for reproducible results across tests." + [] + (reset! fake-ids 0)) + +(defn next-fake + "When you need predictable uuids, for example when debugging a failing test, wrap the code with + (with-redefs [uuid/next uuid/next-fake] + ...tested code...)" + [] + (-> (swap! fake-ids inc) + (custom))) + ;; Commented code used for debug ;; #?(:cljs ;; (defn ^:export test-uuid diff --git a/common/test/common_tests/data_test.cljc b/common/test/common_tests/data_test.cljc index 44cd24a237..dbe015cf89 100644 --- a/common/test/common_tests/data_test.cljc +++ b/common/test/common_tests/data_test.cljc @@ -28,6 +28,14 @@ (t/is (not (d/in-range? 5 -1))) (t/is (not (d/in-range? 0 0)))) +(t/deftest get-initials-test + (t/is (= "JD" (d/get-initials "John Doe"))) + (t/is (= "A" (d/get-initials "acme"))) + (t/is (= "AB" (d/get-initials "123 Alpha ## beta"))) + (t/is (= "PD" (d/get-initials " penpot design tool "))) + (t/is (= "" (d/get-initials nil))) + (t/is (= "" (d/get-initials "!!! ???")))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Ordered Data Structures ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -276,6 +284,48 @@ (t/is (= (d/nth-index-of "abc*def*ghi" "*" 2) 7)) (t/is (= (d/nth-index-of "abc*def*ghi" "*" 3) nil))) +(t/deftest natural-sort-by-test + (t/is (= (d/natural-sort-by identity ["10" "2" "1" "11" "3" "30"]) + ["1" "2" "3" "10" "11" "30"])) + (t/is (= (d/natural-sort-by identity ["banana" "apple" "cherry"]) + ["apple" "banana" "cherry"])) + (t/is (= (d/natural-sort-by identity ["size10" "size2" "size1" "size20" "size3"]) + ["size1" "size2" "size3" "size10" "size20"])) + (t/is (= (d/natural-sort-by identity ["b1" "a2" "a10" "a1"]) + ["a1" "a2" "a10" "b1"])) + (t/is (= (d/natural-sort-by identity []) [])) + (t/is (= (d/natural-sort-by identity ["solo"]) ["solo"])) + (t/is (= (d/natural-sort-by identity ["b" "a" "a" "c"]) + ["a" "a" "b" "c"])) + (t/is (= (d/natural-sort-by :name + [{:name "big"} {:name "small"} {:name "medium"}]) + [{:name "big"} {:name "medium"} {:name "small"}])) + (t/is (= (d/natural-sort-by :name + [{:name "size10"} {:name "size2"} {:name "size1"}]) + [{:name "size1"} {:name "size2"} {:name "size10"}])) + (t/is (= (d/natural-sort-by :name + [{:name "border-radius-10"} + {:name "border-radius-2"} + {:name "border-radius-1"}]) + [{:name "border-radius-1"} + {:name "border-radius-2"} + {:name "border-radius-10"}])) + (t/is (= (d/natural-sort-by :name + [{:name "border-10-radius"} + {:name "border-2-radius"} + {:name "border-1-radius"}]) + [{:name "border-1-radius"} + {:name "border-2-radius"} + {:name "border-10-radius"}])) + (t/is (= (d/natural-sort-by :name + [{:name "border-10-radius"} + {:name "border-2-extra"} + {:name "border-2-radius"} + {:name "border-1-radius"}]) + [{:name "border-1-radius"} + {:name "border-2-extra"} + {:name "border-2-radius"} + {:name "border-10-radius"}]))) ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Lazy / sequence helpers ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/common/test/common_tests/files/comp_processors_test.cljc b/common/test/common_tests/files/comp_processors_test.cljc new file mode 100644 index 0000000000..c1cabbbb72 --- /dev/null +++ b/common/test/common_tests/files/comp_processors_test.cljc @@ -0,0 +1,787 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.files.comp-processors-test + (:require + [app.common.data :as d] + [app.common.files.comp-processors :as cfcp] + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] + [app.common.test-helpers.ids-map :as thi] + [app.common.test-helpers.shapes :as ths] + [app.common.types.component :as ctk] + [app.common.types.components-list :as ctkl] + [app.common.types.file :as ctf] + [clojure.test :as t])) + +(t/deftest test-remove-unneeded-objects-in-components + + (t/testing "nil file should return nil" + (let [file nil + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)] + (t/is (nil? file')))) + + (t/testing "empty file should not need any action" + (let [file (thf/sample-file :file1) + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)] + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file without components should not need any action" + (let [file + (-> (thf/sample-file :file1) + (tho/add-frame-with-child :frame1 :shape1)) + + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with non deleted components should not need any action" + (let [file + (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :frame1 :shape1)) + + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with deleted components should not need any action" + (let [file + (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :frame1 :shape1) + (tho/delete-shape :frame1)) + + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components)] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with non deleted components with :objects nil should remove it" + (let [file + (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :frame1 :shape1) + (thc/update-component :component1 {:objects nil})) + + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components) + + diff (d/map-diff file file') + + expected-diff {:data + {:components + {(thi/id :component1) + {}}}}] + + (t/is (= expected-diff diff)))) + + (t/testing "file with non deleted components with :objects should remove it" + (let [file + (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :frame1 :shape1) + (thc/update-component :component1 {:objects {:sample 777}})) + + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components) + + diff (d/map-diff file file') + + expected-diff {:data + {:components + {(thi/id :component1) + {:objects + [{:sample 777} nil]}}}}] + + (t/is (= expected-diff diff)))) + + (t/testing "file with deleted components without :objects should add an empty one" + (let [file + (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :frame1 :shape1) + (tho/delete-shape :frame1) + (ctf/update-file-data + (fn [file-data] + (ctkl/update-component file-data (thi/id :component1) #(dissoc % :objects))))) + + file' (ctf/update-file-data file cfcp/remove-unneeded-objects-in-components) + + diff (d/map-diff file file') + + expected-diff {:data + {:components + {(thi/id :component1) + {:objects + [nil {}]}}}}] + + (t/is (= expected-diff diff))))) + +(t/deftest test-fix-missing-swap-slots + + (t/testing "nil file should return nil" + (let [file nil + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))] + (t/is (nil? file')))) + + (t/testing "empty file should not need any action" + (let [file (thf/sample-file :file1) + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))] + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file without components should not need any action" + (let [file + ;; :frame1 [:name Frame1] + ;; :child1 [:name Rect1] + (-> (thf/sample-file :file1) + (tho/add-frame-with-child :frame1 :shape1)) + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with nested not swapped components should not need any action" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; [:name Frame1] @--> [Component :component1] :nested-head + ;; [:name Rect1] ---> + (-> (thf/sample-file :file1) + (tho/add-nested-component-with-copy :component1 :main1-root :main1-child + :component2 :main2-root :nested-head + :copy2-root)) + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with a normally swapped copy should not need any action" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :main3-root + ;; {swap-slot :nested-head} + ;; [:name Rect3] ---> :main3-child + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head) + (thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head]) + (tho/add-simple-component :component3 :main3-root :main3-child + :root-params {:name "Frame3"} + :child-params {:name "Rect3"}) + (tho/swap-component-in-first-child :copy2-root :component3)) + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with a swapped nested copy in a main should not need any action" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame3] @--> [Component :component3] :main3-root + ;; {swap-slot :nested-head} + ;; [:name Rect3] ---> :main3-child + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :nested-head + ;; [:name Rect3] ---> + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head) + (thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head]) + (tho/add-simple-component :component3 :main3-root :main3-child + :root-params {:name "Frame3"} + :child-params {:name "Rect3"}) + (tho/swap-component-in-shape :nested-head :component3 + :propagate-fn #(tho/propagate-component-changes % :component2))) + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with a swapped copy with broken slot should have it repaired" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :main3-root + ;; NO SWAP SLOT + ;; [:name Rect3] ---> :main3-child + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head) + (thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head]) + (tho/add-simple-component :component3 :main3-root :main3-child + :root-params {:name "Frame3"} + :child-params {:name "Rect3"}) + (tho/swap-component-in-first-child :copy2-root :component3) + (ths/update-shape :copy2-nested-head :touched nil)) + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {})) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :copy2-nested-head) + {:touched + [nil + #{(ctk/build-swap-slot-group (str (thi/id :nested-head)))}]}}}}}}] + + (t/is (= expected-diff diff)))) + + (t/testing "file with a swapped copy inside a main with broken slot has no effect since it cannot be distinguished" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame3] @--> [Component :component3] :main3-root + ;; NO SWAP SLOT + ;; [:name Rect3] ---> :main3-child + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :nested-head + ;; [:name Rect3] ---> + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head) + (thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head]) + (tho/add-simple-component :component3 :main3-root :main3-child + :root-params {:name "Frame3"} + :child-params {:name "Rect3"}) + (tho/swap-component-in-shape :nested-head :component3 + :propagate-fn #(tho/propagate-component-changes % :component2)) + (ths/update-shape :nested-head :touched nil)) + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {}))] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with a two levels nested copy in a main swapped with broken slot should have it repaired" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main4-root} [:name Frame4] # [Component :component4] + ;; :main4-child [:name Rect4] + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :nested-head2 [:name Frame2] @--> [Component :component2] :main2-root + ;; :nested-subhead2 [:name Frame4] @--> [Component :component4] :main4-root + ;; NO SWAP SLOT + ;; [:name Rect4] ---> :main4-child + ;; + ;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root + ;; [:name Frame2] @--> [Component :component2] :nested-head2 + ;; [:name Frame4] @--> [Component :component4] :nested-subhead2 + ;; [:name Rect4] ---> + (-> (thf/sample-file :file1) + (tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child + :component2 :main2-root :nested-head1 + :component3 :main3-root :nested-head2 :nested-subhead2 + :copy2-root) + (tho/add-simple-component :component4 :main4-root :main4-child + :root-params {:name "Frame4"} + :child-params {:name "Rect4"}) + (tho/swap-component-in-shape :nested-subhead2 :component4 + :propagate-fn #(tho/propagate-component-changes % :component3)) + (ths/update-shape :nested-subhead2 :touched nil)) + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % {})) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :nested-subhead2) + {:touched + [nil + #{(ctk/build-swap-slot-group (str (thi/id :nested-head1)))}]}}}}}}] + + (t/is (= expected-diff diff)))) + + (t/testing "when components are in external libraries, the fix still works well" + (let [library1 + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested2-head [:name Frame1] @--> [Component :component1] :main1-root + ;; :nested2-child [:name Rect1] ---> :main1-child + (-> (thf/sample-file :library1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested2-head + :nested-head-params {:children-labels [:nested2-child]})) + library2 + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; {:main4-root} [:name Frame4] # [Component :component4] + ;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root + ;; :nested4-child [:name Rect3] ---> :main3-child + (-> (thf/sample-file :library2) + (tho/add-nested-component :component3 :main3-root :main3-child + :component4 :main4-root :nested4-head + :root1-params {:name "Frame3"} + :main1-child-params {:name "Rect3"} + :main2-root-params {:name "Frame4"} + :nested-head-params {:children-labels [:nested4-child]})) + + file + ;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame4] @--> [Component :component4] :main4-root + ;; NO SWAP SLOT + ;; [:name Frame3] @--> :nested4-head + ;; [:name Rect3] ---> :nested4-child + (-> (thf/sample-file :file1) + (thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head] + :library library1) + (tho/swap-component-in-first-child :copy2 :component4 :library library2) + (ths/update-shape :copy2-nested-head :touched nil)) + + libraries {(:id library1) library1 + (:id library2) library2} + + file' (ctf/update-file-data file #(cfcp/fix-missing-swap-slots % libraries)) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :copy2-nested-head) + {:touched + [nil + #{(ctk/build-swap-slot-group (str (thi/id :nested2-head)))}]}}}}}}] + + (t/is (= expected-diff diff))))) + +(t/deftest test-sync-component-id-with-ref-shape + + (t/testing "nil file should return nil" + (let [file nil + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))] + (t/is (nil? file')))) + + (t/testing "empty file should not need any action" + (let [file (thf/sample-file :file1) + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))] + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file without components should not need any action" + (let [file + ;; :frame1 [:name Frame1] + ;; :child1 [:name Rect1] + (-> (thf/sample-file :file1) + (tho/add-frame-with-child :frame1 :shape1)) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))] + + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with valid normal components should not need any action" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :nested-head2 [:name Frame2] @--> [Component :component2] :main2-root + ;; :nested-subhead2 [:name Frame1] @--> [Component :component1] :nested-head1 + ;; [:name Rect1] ---> + ;; + ;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root + ;; [:name Frame2] @--> [Component :component2] :nested-head2 + ;; [:name Frame1] @--> [Component :component1] :nested-subhead2 + ;; [:name Rect1] ---> + (-> (thf/sample-file :file1) + (tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child + :component2 :main2-root :nested-head1 + :component3 :main3-root :nested-head2 :nested-subhead2 + :copy2-root)) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))] + + #_(thf/dump-file file') ;; Uncomment to debug + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with valid swapped components should not need any action" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; [:name Frame1] @--> [Component :component1] :nested-head + ;; [:name Rect1] ---> + ;; + ;; :copy3-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy3-nested-head [:name Frame3] @--> [Component :component3] :main3-root + ;; {swap-slot :nested-head} + ;; [:name Rect3] ---> :main3-child + (-> (thf/sample-file :file1) + (tho/add-nested-component-with-copy :component1 :main1-root :main1-child + :component2 :main2-root :nested-head + :copy2-root) + (tho/add-simple-component :component3 :main3-root :main3-child + :root-params {:name "Frame3"} + :child-params {:name "Rect3"}) + (thc/instantiate-component :component2 :copy3-root :children-labels [:copy3-nested-head]) + (tho/swap-component-in-first-child :copy3-root :component3)) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {}))] + + #_(thf/dump-file file') ;; Uncomment to debug + (t/is (empty? (d/map-diff file file'))))) + + (t/testing "file with a non swapped copy with broken component id/file should have it repaired" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame1] @--> [Component ] :nested-head ## <- BAD component-id + ;; [:name Rect1] ---> + ;; + ;; :copy3-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy3-nested-head [:name Frame1] @--> [Component ] :nested-head ## <- BAD component-file + ;; [:name Rect1] ---> + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head) + (thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head]) + (thc/instantiate-component :component2 :copy3-root :children-labels [:copy3-nested-head]) + (ths/update-shape :copy2-nested-head :component-id (thi/new-id! :some-other-id)) + (ths/update-shape :copy3-nested-head :component-file (thi/new-id! :some-other-file))) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {})) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :copy2-nested-head) + {:component-id + [(thi/id :some-other-id) (thi/id :component1)]} + (thi/id :copy3-nested-head) + {:component-file + [(thi/id :some-other-file) (thi/id :file1)]}}}}}}] + + #_(ctf/dump-tree file' (thf/current-page-id file') {(:id file') file'} {:show-ids true}) ;; Uncomment to debug + (t/is (= expected-diff diff)))) + + (t/testing "file with a copy of a swapped main with broken component id/file should have it repaired" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame3] @--> [Component :component3] :main3-root + ;; {swap-slot :nested-head} + ;; [:name Rect3] ---> :main3-child + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame3] @--> [Component: ] :nested-head ## <- BAD component-id/file + ;; [:name Rect3] ---> + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head) + (thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head]) + (tho/add-simple-component :component3 :main3-root :main3-child + :root-params {:name "Frame3"} + :child-params {:name "Rect3"}) + (tho/swap-component-in-shape :nested-head :component3 + :propagate-fn #(tho/propagate-component-changes % :component2)) + (ths/update-shape :copy2-nested-head :component-id (thi/new-id! :some-other-id)) + (ths/update-shape :copy2-nested-head :component-file (thi/new-id! :some-other-file))) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {})) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :copy2-nested-head) + {:component-id + [(thi/id :some-other-id) (thi/id :component3)] + :component-file + [(thi/id :some-other-file) (thi/id :file1)]}}}}}}] + + #_(ctf/dump-tree file' (thf/current-page-id file') {(:id file') file'} {:show-ids true}) ;; Uncomment to debug + (t/is (= expected-diff diff)))) + + (t/testing "file with multiple copies of same component should sync all" + (let [file + (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 :frame1 :shape1) + (thc/instantiate-component :component1 :copy1-root :children-labels [:copy1-child]) + (thc/instantiate-component :component1 :copy2-root :children-labels [:copy2-child]) + (ths/update-shape :copy1-child :component-id (thi/new-id! :wrong-id1)) + (ths/update-shape :copy2-child :component-id (thi/new-id! :wrong-id2))) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {})) + + diff (d/map-diff file file')] + + ;; Both copies should be corrected + (t/is (contains? diff :data)) + (t/is (contains? (get-in diff [:data :pages-index]) (thf/current-page-id file))))) + + (t/testing "file with a copy root with broken component id/file cannot be repaired. But it's propagated to copies." + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component ] :main1-root ## <- BAD component-id/file + ;; [:name Rect1] ---> :main1-child + ;; + ;; :copy2-root [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame1] @--> [Component :component1] :nested-head + ;; [:name Rect1] ---> + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head) + (thc/instantiate-component :component2 :copy2-root :children-labels [:copy2-nested-head]) + (ths/update-shape :nested-head :component-id (thi/new-id! :some-other-id)) + (ths/update-shape :nested-head :component-file (thi/new-id! :some-other-file))) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {})) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :copy2-nested-head) + {:component-id + [(thi/id :component1) (thi/id :some-other-id)] + :component-file + [(thi/id :file1) (thi/id :some-other-file)]}}}}}}] + + (t/is (= expected-diff diff)))) + + (t/testing "file with a 2nd nested copy inside a main with broken component/id should have it repaired, and propagated to copies" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :nested-head2 [:name Frame2] @--> [Component :component2] :main2-root + ;; :nested-subhead2 [:name Frame1] @--> [Component ] :nested-head1 ## <- BAD component-id/file + ;; [:name Rect1] ---> + ;; + ;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root + ;; [:name Frame2] @--> [Component :component2] :nested-head2 + ;; [:name Frame1] @--> [Component :component1] :nested-subhead2 + ;; [:name Rect1] ---> + (-> (thf/sample-file :file1) + (tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child + :component2 :main2-root :nested-head1 + :component3 :main3-root :nested-head2 :nested-subhead2 + :copy2-root) + (ths/update-shape :nested-subhead2 :component-id (thi/new-id! :some-other-id)) + (ths/update-shape :nested-subhead2 :component-file (thi/new-id! :some-other-file))) + + copy2-root (ths/get-shape file :copy2-root) + copy2-root-child1 (ths/get-shape-by-id file (first (:shapes copy2-root))) + copy2-root-child2 (ths/get-shape-by-id file (first (:shapes copy2-root-child1))) + file (-> file + (ths/update-shape-by-id (:id copy2-root-child2) :component-id (thi/id :some-other-id)) + (ths/update-shape-by-id (:id copy2-root-child2) :component-file (thi/id :some-other-file))) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {})) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :nested-subhead2) + {:component-id + [(thi/id :some-other-id) (thi/id :component1)] + :component-file + [(thi/id :some-other-file) (thi/id :file1)]} + (:id copy2-root-child2) + {:component-id + [(thi/id :some-other-id) (thi/id :component1)] + :component-file + [(thi/id :some-other-file) (thi/id :file1)]}}}}}}] + + #_(ctf/dump-tree file' (thf/current-page-id file') {(:id file') file'} {:show-ids true}) ;; Uncomment to debug + (t/is (= expected-diff diff)))) + + (t/testing "when components are in external libraries, the fix still works well" + (let [library1 + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested2-head [:name Frame4] @--> [Component :component4] :main4-root + ;; {swap-slot :nested2-head} + ;; :nested4-head [:name Frame3] @--> [Component: component3] :main3-root + ;; :nested4-child [:name Rect3] ---> :nested4-child + (-> (thf/sample-file :library1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested2-head + :nested-head-params {:children-labels [:nested2-child]})) + library2 + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; {:main4-root} [:name Frame4] # [Component :component4] + ;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root + ;; :nested4-child [:name Rect3] ---> :main3-child + (-> (thf/sample-file :library2) + (tho/add-nested-component :component3 :main3-root :main3-child + :component4 :main4-root :nested4-head + :root1-params {:name "Frame3"} + :main1-child-params {:name "Rect3"} + :main2-root-params {:name "Frame4"} + :nested-head-params {:children-labels [:nested4-child]})) + + library1 + (tho/swap-component-in-shape library1 :nested2-head :component4 :library library2) + + file + ;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame4] @--> [Component ] :main4-root ## <- BAD component-id/file + ;; [:name Frame3] @--> :nested4-head + ;; [:name Rect3] ---> :nested4-child + (-> (thf/sample-file :file1) + (thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head] + :library library1) + (ths/update-shape :copy2-nested-head :component-id (thi/new-id! :some-other-id)) + (ths/update-shape :copy2-nested-head :component-file (thi/new-id! :some-other-file))) + + libraries {(:id library1) library1 + (:id library2) library2} + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % libraries)) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(thi/id :copy2-nested-head) + {:component-id + [(thi/id :some-other-id) (thi/id :component4)] + :component-file + [(thi/id :some-other-file) (thi/id :library2)]}}}}}}] + + #_(thf/dump-file library2) ;; Uncomment to debug + (t/is (= expected-diff diff)))) + + (t/testing "file with several broken ids should propagate to all copies" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head1 [:name Frame1] @--> [Component :component1] :main1-root + ;; [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :nested-head2 [:name Frame2] @--> [Component ] :main2-root ## <- BAD component-id + ;; :nested-subhead2 [:name Frame1] @--> [Component ] :nested-head1 ## <- BAD component-id + ;; [:name Rect1] ---> + ;; + ;; :copy2-root [:name Frame3] #--> [Component :component3] :main3-root + ;; [:name Frame2] @--> [Component :component2] :nested-head2 + ;; [:name Frame1] @--> [Component :component1] :nested-subhead2 + ;; [:name Rect1] ---> + (-> (thf/sample-file :file1) + (tho/add-two-levels-nested-component-with-copy :component1 :main1-root :main1-child + :component2 :main2-root :nested-head1 + :component3 :main3-root :nested-head2 :nested-subhead2 + :copy2-root) + ;; Corrupt both levels + (ths/update-shape :nested-head2 :component-id (thi/new-id! :wrong-comp2)) + (ths/update-shape :nested-subhead2 :component-id (thi/new-id! :wrong-comp3))) + + file' (ctf/update-file-data file #(cfcp/sync-component-id-with-ref-shape % {})) + copy2-root (ths/get-shape file' :copy2-root) + copy2-root-child1 (ths/get-shape-by-id file' (first (:shapes copy2-root))) + copy2-root-child2 (ths/get-shape-by-id file' (first (:shapes copy2-root-child1))) + + diff (d/map-diff file file') + + expected-diff {:data + {:pages-index + {(thf/current-page-id file) + {:objects + {(:id copy2-root-child1) + {:component-id [(thi/id :component2) (thi/id :wrong-comp2)]} + (:id copy2-root-child2) + {:component-id [(thi/id :component1) (thi/id :wrong-comp3)]}}}}}}] + + (thf/dump-file file') ;; Uncomment to debug + (t/is (= expected-diff diff))))) + diff --git a/common/test/common_tests/files/shapes_builder_test.cljc b/common/test/common_tests/files/shapes_builder_test.cljc new file mode 100644 index 0000000000..05956918b2 --- /dev/null +++ b/common/test/common_tests/files/shapes_builder_test.cljc @@ -0,0 +1,52 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns common-tests.files.shapes-builder-test + (:require + [app.common.files.shapes-builder :as sb] + [clojure.test :as t])) + +;; Regression for https://github.com/penpot/penpot/issues/7869. +;; ``parse-svg-element`` used to derive the shape name from +;; ``(or (:id attrs) (tag->name tag))`` which dropped Inkscape-authored +;; labels. ``tubax/xml->clj`` (the SVG parser the rest of the import +;; pipeline already feeds these maps to) keeps namespaced attributes as +;; ``:prefix:name`` keywords — same shape the codebase already reads +;; ``:xlink:href`` from in this file (line 134) and in +;; ``app.common.svg``. + +(t/deftest resolve-element-name-prefers-inkscape-label + (t/is (= "Layer 1" + (sb/resolve-element-name :g {:inkscape:label "Layer 1" + :id "g1234"})))) + +(t/deftest resolve-element-name-prefers-sodipodi-label-when-no-inkscape-label + (t/is (= "phone-icon" + (sb/resolve-element-name :path {:sodipodi:label "phone-icon" + :id "path5678"})))) + +(t/deftest resolve-element-name-falls-back-to-id-when-no-label-namespace + (t/is (= "manual-id" + (sb/resolve-element-name :rect {:id "manual-id"})))) + +(t/deftest resolve-element-name-falls-back-to-tag-name-when-no-id-and-no-label + ;; The tag->name mapping returns generic names for known SVG element + ;; tags. Asserting on the call result here (rather than a hardcoded + ;; string) keeps the test stable if the tag->name mapping is updated. + (t/is (some? (sb/resolve-element-name :rect {}))) + (t/is (string? (sb/resolve-element-name :rect {})))) + +(t/deftest resolve-element-name-inkscape-label-wins-over-sodipodi-and-id + ;; Both label conventions and an id present together; the priority is + ;; inkscape > sodipodi > id > tag, matching the order operators expect + ;; (Inkscape's own UI shows ``inkscape:label`` as the canonical name). + (t/is (= "user-name" + (sb/resolve-element-name :g {:inkscape:label "user-name" + :sodipodi:label "stale-label" + :id "g1"})))) + +(t/deftest resolve-element-name-empty-attrs-uses-tag-fallback + (t/is (some? (sb/resolve-element-name :path {})))) diff --git a/common/test/common_tests/logic/comp_creation_test.cljc b/common/test/common_tests/logic/comp_creation_test.cljc index 462734d6ee..ad1277879b 100644 --- a/common/test/common_tests/logic/comp_creation_test.cljc +++ b/common/test/common_tests/logic/comp_creation_test.cljc @@ -304,6 +304,62 @@ (t/is (= (thi/id :main1-child) (:id child1'))) (t/is (not= (thi/id :main1-child) (:id child2'))))) +(t/deftest test-duplicate-component-rewrites-component-file-to-destination + ;; Regression test for Issue #8144. When a component is duplicated + ;; into a different file via `:apply-changes-local-library? true` + ;; and `:new-component-file` is provided, the returned main-instance + ;; shape must carry `:component-file` equal to the destination file + ;; id so the referential-integrity validator + ;; (:component-main-external) is satisfied. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 + :main1-root + :main1-child)) + + component (thc/get-component file :component1) + new-component-file (uuid/next) + + ;; ==== Action + [new-shape _] + (cll/generate-duplicate-component (pcb/empty-changes) + file + (:id component) + (uuid/next) + {:apply-changes-local-library? true + :new-component-file new-component-file})] + + ;; ==== Check + (t/is (some? new-shape)) + (t/is (ctk/main-instance? new-shape)) + (t/is (= new-component-file (:component-file new-shape))))) + +(t/deftest test-duplicate-component-keeps-component-file-without-dest + ;; Baseline: when no `:new-component-file` is passed (same-file + ;; duplication), the main-instance's `:component-file` is left + ;; untouched, matching pre-existing behavior. + (let [;; ==== Setup + file (-> (thf/sample-file :file1) + (tho/add-simple-component :component1 + :main1-root + :main1-child)) + + component (thc/get-component file :component1) + original-source (:component-file + (ths/get-shape-by-id file (:main-instance-id component))) + + ;; ==== Action + [new-shape _] + (cll/generate-duplicate-component (pcb/empty-changes) + file + (:id component) + (uuid/next) + {:apply-changes-local-library? true})] + + ;; ==== Check + (t/is (some? new-shape)) + (t/is (= original-source (:component-file new-shape))))) + (t/deftest test-delete-component (let [;; ==== Setup file (-> (thf/sample-file :file1) diff --git a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc index 9460a3b91c..143221a4d3 100644 --- a/common/test/common_tests/logic/comp_detach_with_nested_test.cljc +++ b/common/test/common_tests/logic/comp_detach_with_nested_test.cljc @@ -465,9 +465,10 @@ page {(:id file) file} (thi/id :nested-h-ellipse)) - file' (-> (thf/apply-changes file changes) + file' (-> (thf/apply-changes file changes :validate? false) (tho/propagate-component-changes :c-board-with-ellipse) - (tho/propagate-component-changes :c-big-board)) + (tho/propagate-component-changes :c-big-board) + (thf/validate-file!)) ;; ==== Get nested2-h-ellipse (ths/get-shape file' :nested-h-ellipse) diff --git a/common/test/common_tests/logic/comp_reset_test.cljc b/common/test/common_tests/logic/comp_reset_test.cljc index 23894cc398..649b25e757 100644 --- a/common/test/common_tests/logic/comp_reset_test.cljc +++ b/common/test/common_tests/logic/comp_reset_test.cljc @@ -349,4 +349,73 @@ (t/is (= (:fill-color fill') "#FFFFFF")) (t/is (= (:fill-opacity fill') 1)) (t/is (= (:touched copy2-root') nil)) - (t/is (= (:touched copy2-child') nil)))) \ No newline at end of file + (t/is (= (:touched copy2-child') nil)))) + +(t/deftest test-reset-with-propagation-updates-copies + ;; When a nested copy inside a main component has an override and we + ;; reset it passing a propagate-fn, the reset must be propagated to + ;; all copies of that component so they reflect the canonical color. + (let [;; ==== Setup + file + (-> (thf/sample-file :file1) + ;; component1: main1-root / main1-child (fill "#aabbcc") + ;; component2: main2-root contains nested-head (instance of component1) + ;; copy2-root: copy of component2 + (tho/add-nested-component-with-copy + :component1 :main1-root :main1-child + :component2 :main2-root :nested-head + :copy2-root + :main1-child-params {:fills (ths/sample-fills-color :fill-color "#aabbcc")} + :copy2-root-params {:children-labels [:copy2-nested-head]})) + + propagate-fn (fn [f] + (-> f + (tho/propagate-component-changes :component1) + (tho/propagate-component-changes :component2))) + + ;; ==== Action – override the nested-head color, then reset it with propagation + file' + (-> file + (tho/update-bottom-color :nested-head "#fabada" :propagate-fn propagate-fn) + (tho/reset-overrides (ths/get-shape file :nested-head) :propagate-fn propagate-fn)) + + ;; ==== Get + copy2-bottom-color (tho/bottom-fill-color file' :copy2-root)] + + ;; ==== Check + ;; After reset + propagation the copy should mirror the canonical color + (t/is (= copy2-bottom-color "#aabbcc")))) + +(t/deftest test-reset-without-propagation-does-not-update-copies + ;; This is the regression test for the misplaced-parenthesis bug: when + ;; propagate-fn is NOT passed to reset-overrides the copies of the component + ;; must still hold the overridden value because the component sync never ran. + (let [;; ==== Setup + file + (-> (thf/sample-file :file1) + (tho/add-nested-component-with-copy + :component1 :main1-root :main1-child + :component2 :main2-root :nested-head + :copy2-root + :main1-child-params {:fills (ths/sample-fills-color :fill-color "#aabbcc")} + :copy2-root-params {:children-labels [:copy2-nested-head]})) + + propagate-fn (fn [f] + (-> f + (tho/propagate-component-changes :component1) + (tho/propagate-component-changes :component2))) + + ;; ==== Action – override the nested-head color, then reset WITHOUT propagation + file' + (-> file + (tho/update-bottom-color :nested-head "#fabada" :propagate-fn propagate-fn) + ;; Reset without propagate-fn: the component definition is updated but + ;; the change is never pushed to the copy. + (tho/reset-overrides (ths/get-shape file :nested-head))) + + ;; ==== Get + copy2-bottom-color (tho/bottom-fill-color file' :copy2-root)] + + ;; ==== Check + ;; Without propagation the copy still reflects the overridden color + (t/is (= copy2-bottom-color "#fabada")))) \ No newline at end of file diff --git a/common/test/common_tests/logic/duplicated_pages_test.cljc b/common/test/common_tests/logic/duplicated_pages_test.cljc index d1bafb88d7..57dd490143 100644 --- a/common/test/common_tests/logic/duplicated_pages_test.cljc +++ b/common/test/common_tests/logic/duplicated_pages_test.cljc @@ -64,9 +64,8 @@ (reset-all-overrides [file] (-> file - (tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1) - (tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2) - (propagate-all-component-changes))) + (tho/reset-overrides-in-first-child :frame-board-1 :page-label :page-1 :propagate-fn propagate-all-component-changes) + (tho/reset-overrides-in-first-child :copy-board-1 :page-label :page-2 :propagate-fn propagate-all-component-changes))) (fill-colors [file] [(tho/bottom-fill-color file :frame-ellipse-1 :page-label :page-1) diff --git a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc index 43b7c7ef0e..11276ceb85 100644 --- a/common/test/common_tests/logic/multiple_nesting_levels_test.cljc +++ b/common/test/common_tests/logic/multiple_nesting_levels_test.cljc @@ -6,20 +6,11 @@ (ns common-tests.logic.multiple-nesting-levels-test (:require - [app.common.files.changes :as ch] - [app.common.files.changes-builder :as pcb] - [app.common.logic.libraries :as cll] - [app.common.logic.shapes :as cls] - [app.common.pprint :as pp] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] - [app.common.types.component :as ctk] - [app.common.types.container :as ctn] - [app.common.types.file :as ctf] - [app.common.uuid :as uuid] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -56,10 +47,9 @@ (reset-all-overrides [file] (-> file - (tho/reset-overrides (ths/get-shape file :copy-simple-1)) - (tho/reset-overrides (ths/get-shape file :copy-frame-composed-1)) - (tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy)) - (propagate-all-component-changes))) + (tho/reset-overrides (ths/get-shape file :copy-simple-1) :propagate-fn propagate-all-component-changes) + (tho/reset-overrides (ths/get-shape file :copy-frame-composed-1) :propagate-fn propagate-all-component-changes) + (tho/reset-overrides (ths/get-shape file :composed-1-composed-2-copy) :propagate-fn propagate-all-component-changes))) (fill-colors [file] [(tho/bottom-fill-color file :frame-simple-1) diff --git a/common/test/common_tests/logic/swap_as_override_test.cljc b/common/test/common_tests/logic/swap_as_override_test.cljc index a4a1b5a632..519b24e48f 100644 --- a/common/test/common_tests/logic/swap_as_override_test.cljc +++ b/common/test/common_tests/logic/swap_as_override_test.cljc @@ -6,20 +6,12 @@ (ns common-tests.logic.swap-as-override-test (:require - [app.common.files.changes :as ch] - [app.common.files.changes-builder :as pcb] - [app.common.logic.libraries :as cll] - [app.common.logic.shapes :as cls] - [app.common.pprint :as pp] + [app.common.data :as d] [app.common.test-helpers.components :as thc] [app.common.test-helpers.compositions :as tho] [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] - [app.common.types.component :as ctk] - [app.common.types.container :as ctn] - [app.common.types.file :as ctf] - [app.common.uuid :as uuid] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -27,23 +19,40 @@ (defn- setup [] (-> (thf/sample-file :file1) - (tho/add-simple-component :component-1 :frame-component-1 :child-component-1 :child-params {:name "child-component-1" :type :rect :fills (ths/sample-fills-color :fill-color "#111111")}) - (tho/add-simple-component :component-2 :frame-component-2 :child-component-2 :child-params {:name "child-component-2" :type :rect :fills (ths/sample-fills-color :fill-color "#222222")}) - (tho/add-simple-component :component-3 :frame-component-3 :child-component-3 :child-params {:name "child-component-3" :type :rect :fills (ths/sample-fills-color :fill-color "#333333")}) + (tho/add-simple-component :component-1 :frame-component-1 :child-component-1 + :root-params {:name "component-1"} + :child-params {:name "child-component-1" + :type :rect + :fills (ths/sample-fills-color :fill-color "#111111")}) + (tho/add-simple-component :component-2 :frame-component-2 :child-component-2 + :root-params {:name "component-2"} + :child-params {:name "child-component-2" + :type :rect + :fills (ths/sample-fills-color :fill-color "#222222")}) + (tho/add-simple-component :component-3 :frame-component-3 :child-component-3 + :root-params {:name "component-3"} + :child-params {:name "child-component-3" + :type :rect + :fills (ths/sample-fills-color :fill-color "#333333")}) - (tho/add-frame :frame-icon-and-text) - (thc/instantiate-component :component-1 :copy-component-1 :parent-label :frame-icon-and-text :children-labels [:component-1-icon-and-text]) + (tho/add-frame :frame-icon-and-text :name "copy-component-1") + (thc/instantiate-component :component-1 :copy-component-1 + :parent-label :frame-icon-and-text + :children-labels [:component-1-icon-and-text]) (ths/add-sample-shape :text {:type :text :name "icon+text" :parent-label :frame-icon-and-text}) (thc/make-component :icon-and-text :frame-icon-and-text) - (tho/add-frame :frame-panel) - (thc/instantiate-component :icon-and-text :copy-icon-and-text :parent-label :frame-panel :children-labels [:icon-and-text-panel]) + (tho/add-frame :frame-panel :name "icon-and-text") + (thc/instantiate-component :icon-and-text :copy-icon-and-text + :parent-label :frame-panel + :children-labels [:icon-and-text-panel]) (thc/make-component :panel :frame-panel) - (thc/instantiate-component :panel :copy-panel :children-labels [:copy-icon-and-text-panel]))) + (thc/instantiate-component :panel :copy-panel + :children-labels [:copy-icon-and-text-panel]))) (defn- propagate-all-component-changes [file] (-> file diff --git a/common/test/common_tests/logic/swap_keeps_id_test.cljc b/common/test/common_tests/logic/swap_keeps_id_test.cljc index 6ecc3583b2..e6478beeb1 100644 --- a/common/test/common_tests/logic/swap_keeps_id_test.cljc +++ b/common/test/common_tests/logic/swap_keeps_id_test.cljc @@ -30,7 +30,7 @@ copy (ths/get-shape file :copy01) ;; ==== Action - file' (tho/swap-component file copy :circle {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :circle {:new-shape-label :copy02 :keep-touched? true}) copy' (ths/get-shape file' :copy02)] ;; Both copies have the same id diff --git a/common/test/common_tests/logic/variants_switch_test.cljc b/common/test/common_tests/logic/variants_switch_test.cljc index f01da5f268..c991f35ab6 100644 --- a/common/test/common_tests/logic/variants_switch_test.cljc +++ b/common/test/common_tests/logic/variants_switch_test.cljc @@ -35,7 +35,7 @@ copy01 (ths/get-shape file :copy01) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) copy01' (ths/get-shape file' :copy02)] (thf/dump-file file :keys [:width]) @@ -61,7 +61,7 @@ rect01 (get-in page [:objects (-> copy01 :shapes first)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -100,7 +100,7 @@ copy01 (ths/get-shape file :copy01) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) copy01' (ths/get-shape file' :copy02)] (thf/dump-file file :keys [:width]) @@ -137,7 +137,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -180,7 +180,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -257,25 +257,19 @@ ;; The copy clean has no overrides - - - copy-clean (ths/get-shape file :copy-clean) copy-clean-t (ths/get-shape file :copy-clean-t) ;; Override font size on copy-font-size file (update-attr file :copy-font-size-t font-size-path-0 "25") - copy-font-size (ths/get-shape file :copy-font-size) copy-font-size-t (ths/get-shape file :copy-font-size-t) ;; Override text on copy-text file (update-attr file :copy-text-t text-path-0 "text overriden") - copy-text (ths/get-shape file :copy-text) copy-text-t (ths/get-shape file :copy-text-t) ;; Override both on copy-both file (update-attr file :copy-both-t font-size-path-0 "25") file (update-attr file :copy-both-t text-path-0 "text overriden") - copy-both (ths/get-shape file :copy-both) copy-both-t (ths/get-shape file :copy-both-t) @@ -283,10 +277,10 @@ file' (-> file - (tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) - (tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) - (tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) - (tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) page' (thf/current-page file') copy-clean' (ths/get-shape file' :copy-clean-2) copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)]) @@ -387,25 +381,19 @@ ;; The copy clean has no overrides - - - copy-clean (ths/get-shape file :copy-clean) copy-clean-t (ths/get-shape file :copy-clean-t) ;; Override font size on copy-font-size file (update-attr file :copy-font-size-t font-size-path-0 "25") - copy-font-size (ths/get-shape file :copy-font-size) copy-font-size-t (ths/get-shape file :copy-font-size-t) ;; Override text on copy-text file (update-attr file :copy-text-t text-path-0 "text overriden") - copy-text (ths/get-shape file :copy-text) copy-text-t (ths/get-shape file :copy-text-t) ;; Override both on copy-both file (update-attr file :copy-both-t font-size-path-0 "25") file (update-attr file :copy-both-t text-path-0 "text overriden") - copy-both (ths/get-shape file :copy-both) copy-both-t (ths/get-shape file :copy-both-t) @@ -413,10 +401,10 @@ file' (-> file - (tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) - (tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) - (tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) - (tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) page' (thf/current-page file') copy-clean' (ths/get-shape file' :copy-clean-2) copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)]) @@ -515,25 +503,19 @@ ;; The copy clean has no overrides - - - copy-clean (ths/get-shape file :copy-clean) copy-clean-t (ths/get-shape file :copy-clean-t) ;; Override font size on copy-font-size file (update-attr file :copy-font-size-t font-size-path-0 "25") - copy-font-size (ths/get-shape file :copy-font-size) copy-font-size-t (ths/get-shape file :copy-font-size-t) ;; Override text on copy-text file (update-attr file :copy-text-t text-path-0 "text overriden") - copy-text (ths/get-shape file :copy-text) copy-text-t (ths/get-shape file :copy-text-t) ;; Override both on copy-both file (update-attr file :copy-both-t font-size-path-0 "25") file (update-attr file :copy-both-t text-path-0 "text overriden") - copy-both (ths/get-shape file :copy-both) copy-both-t (ths/get-shape file :copy-both-t) @@ -541,10 +523,10 @@ file' (-> file - (tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) - (tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) - (tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) - (tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) page' (thf/current-page file') copy-clean' (ths/get-shape file' :copy-clean-2) copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)]) @@ -645,25 +627,19 @@ ;; The copy clean has no overrides - - - copy-clean (ths/get-shape file :copy-clean) copy-clean-t (ths/get-shape file :copy-clean-t) ;; Override font size on copy-font-size file (update-attr file :copy-font-size-t font-size-path-0 "25") - copy-font-size (ths/get-shape file :copy-font-size) copy-font-size-t (ths/get-shape file :copy-font-size-t) ;; Override text on copy-text file (update-attr file :copy-text-t text-path-0 "text overriden") - copy-text (ths/get-shape file :copy-text) copy-text-t (ths/get-shape file :copy-text-t) ;; Override both on copy-both file (update-attr file :copy-both-t font-size-path-0 "25") file (update-attr file :copy-both-t text-path-0 "text overriden") - copy-both (ths/get-shape file :copy-both) copy-both-t (ths/get-shape file :copy-both-t) @@ -671,10 +647,10 @@ file' (-> file - (tho/swap-component copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) - (tho/swap-component copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) - (tho/swap-component copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) - (tho/swap-component copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-clean :c02 {:new-shape-label :copy-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-font-size :c02 {:new-shape-label :copy-font-size-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-text :c02 {:new-shape-label :copy-text-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-both :c02 {:new-shape-label :copy-both-2 :keep-touched? true})) page' (thf/current-page file') copy-clean' (ths/get-shape file' :copy-clean-2) copy-clean-t' (get-in page' [:objects (-> copy-clean' :shapes first)]) @@ -774,14 +750,12 @@ file (change-structure file :copy-structure-clean-t) - copy-structure-clean (ths/get-shape file :copy-structure-clean) copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t) ;; Duplicate a text line in copy-structure-clean, updating ;; both lines with the same attrs file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25") (change-structure :copy-structure-unif-t)) - copy-structure-unif (ths/get-shape file :copy-structure-unif) copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t) ;; Duplicate a text line in copy-structure-clean, updating @@ -789,7 +763,6 @@ file (-> (change-structure file :copy-structure-mixed-t) (update-attr :copy-structure-mixed-t font-size-path-0 "35") (update-attr :copy-structure-mixed-t font-size-path-1 "40")) - copy-structure-mixed (ths/get-shape file :copy-structure-mixed) copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t) @@ -797,9 +770,9 @@ file' (-> file - (tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) - (tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) - (tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) page' (thf/current-page file') copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2) copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)]) @@ -908,14 +881,12 @@ file (change-structure file :copy-structure-clean-t) - copy-structure-clean (ths/get-shape file :copy-structure-clean) copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t) ;; Duplicate a text line in copy-structure-clean, updating ;; both lines with the same attrs file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25") (change-structure :copy-structure-unif-t)) - copy-structure-unif (ths/get-shape file :copy-structure-unif) copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t) ;; Duplicate a text line in copy-structure-clean, updating @@ -923,7 +894,6 @@ file (-> (change-structure file :copy-structure-mixed-t) (update-attr :copy-structure-mixed-t font-size-path-0 "35") (update-attr :copy-structure-mixed-t font-size-path-1 "40")) - copy-structure-mixed (ths/get-shape file :copy-structure-mixed) copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t) @@ -931,9 +901,9 @@ file' (-> file - (tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) - (tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) - (tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) page' (thf/current-page file') copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2) copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)]) @@ -1038,14 +1008,12 @@ file (change-structure file :copy-structure-clean-t) - copy-structure-clean (ths/get-shape file :copy-structure-clean) copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t) ;; Duplicate a text line in copy-structure-clean, updating ;; both lines with the same attrs file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25") (change-structure :copy-structure-unif-t)) - copy-structure-unif (ths/get-shape file :copy-structure-unif) copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t) ;; Duplicate a text line in copy-structure-clean, updating @@ -1053,7 +1021,6 @@ file (-> (change-structure file :copy-structure-mixed-t) (update-attr :copy-structure-mixed-t font-size-path-0 "35") (update-attr :copy-structure-mixed-t font-size-path-1 "40")) - copy-structure-mixed (ths/get-shape file :copy-structure-mixed) copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t) @@ -1061,9 +1028,9 @@ file' (-> file - (tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) - (tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) - (tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) page' (thf/current-page file') copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2) copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)]) @@ -1169,14 +1136,12 @@ file (change-structure file :copy-structure-clean-t) - copy-structure-clean (ths/get-shape file :copy-structure-clean) copy-structure-clean-t (ths/get-shape file :copy-structure-clean-t) ;; Duplicate a text line in copy-structure-clean, updating ;; both lines with the same attrs file (-> (update-attr file :copy-structure-unif-t font-size-path-0 "25") (change-structure :copy-structure-unif-t)) - copy-structure-unif (ths/get-shape file :copy-structure-unif) copy-structure-unif-t (ths/get-shape file :copy-structure-unif-t) ;; Duplicate a text line in copy-structure-clean, updating @@ -1184,7 +1149,6 @@ file (-> (change-structure file :copy-structure-mixed-t) (update-attr :copy-structure-mixed-t font-size-path-0 "35") (update-attr :copy-structure-mixed-t font-size-path-1 "40")) - copy-structure-mixed (ths/get-shape file :copy-structure-mixed) copy-structure-mixed-t (ths/get-shape file :copy-structure-mixed-t) @@ -1192,9 +1156,9 @@ file' (-> file - (tho/swap-component copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) - (tho/swap-component copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) - (tho/swap-component copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) + (tho/swap-component-in-shape :copy-structure-clean :c02 {:new-shape-label :copy-structure-clean-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-unif :c02 {:new-shape-label :copy-structure-unif-2 :keep-touched? true}) + (tho/swap-component-in-shape :copy-structure-mixed :c02 {:new-shape-label :copy-structure-mixed-2 :keep-touched? true})) page' (thf/current-page file') copy-structure-clean' (ths/get-shape file' :copy-structure-clean-2) copy-structure-clean-t' (get-in page' [:objects (-> copy-structure-clean' :shapes first)]) @@ -1290,7 +1254,6 @@ :children-labels [:copy-cp01])) page (thf/current-page file) - copy01 (ths/get-shape file :copy01) copy-cp01 (ths/get-shape file :copy-cp01) copy-cp01-rect-id (-> copy-cp01 :shapes first) @@ -1309,7 +1272,7 @@ ;; ==== Action ;; Switch :c01 for :c02 - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) copy02 (ths/get-shape file' :copy02) copy-cp02' (ths/get-shape-by-id file' (-> copy02 :shapes first)) copy-cp02-rect' (ths/get-shape-by-id file' (-> copy-cp02' :shapes first))] @@ -1337,17 +1300,16 @@ :children-labels [:copy-cp01])) copy01 (ths/get-shape file :copy01) - copy-cp01 (ths/get-shape file :copy-cp01) external02 (thc/get-component file :external02) ;; On :c01, swap the copy of :external01 for a copy of :external02 file (-> file - (tho/swap-component copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false})) + (tho/swap-component-in-shape :copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false})) copy-cp02 (ths/get-shape file :copy-cp02) ;; ==== Action ;; Switch :c01 for :c02 - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) copy02' (ths/get-shape file' :copy02) copy-cp02' (ths/get-shape file' :copy-cp02)] @@ -1376,12 +1338,11 @@ page (thf/current-page file) copy01 (ths/get-shape file :copy01) - copy-cp01 (ths/get-shape file :copy-cp01) external02 (thc/get-component file :external02) ;; On :c01, swap the copy of :external01 for a copy of :external02 file (-> file - (tho/swap-component copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false})) + (tho/swap-component-in-shape :copy-cp01 :external02 {:new-shape-label :copy-cp02 :keep-touched? false})) copy-cp02 (ths/get-shape file :copy-cp02) copy-cp02-rect-id (-> copy-cp02 :shapes first) @@ -1396,7 +1357,7 @@ ;; ==== Action ;; Switch :c01 for :c02 - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) copy02' (ths/get-shape file' :copy02) copy-cp02' (ths/get-shape file' :copy-cp02) @@ -1463,7 +1424,7 @@ ;; ==== Action - file' (tho/swap-component file c01-in-copy :c02 {:new-shape-label :c02-in-copy :keep-touched? true}) + file' (tho/swap-component-in-shape file :c01-in-copy :c02 {:new-shape-label :c02-in-copy :keep-touched? true}) page' (thf/current-page file') c02-in-copy' (ths/get-shape file' :c02-in-copy) @@ -1515,7 +1476,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1564,7 +1525,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1613,7 +1574,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1660,7 +1621,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1714,7 +1675,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1763,7 +1724,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1812,7 +1773,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1859,7 +1820,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1910,7 +1871,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -1956,7 +1917,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2023,7 +1984,7 @@ text01 (get-in page [:objects (:id text01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2055,7 +2016,7 @@ rect01 (get-in page [:objects (-> copy01 :shapes first)]) ;; ==== Action - Try to switch to a component with different shape type - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2098,7 +2059,7 @@ path01 (get-in page [:objects (:id path01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2146,7 +2107,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2190,7 +2151,7 @@ rect01 (get-in page [:objects (-> copy01 :shapes first)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2243,7 +2204,7 @@ old-position-data (:position-data text01) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2306,7 +2267,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2357,7 +2318,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2411,7 +2372,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2468,7 +2429,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2532,7 +2493,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2588,7 +2549,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2653,7 +2614,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) @@ -2710,7 +2671,7 @@ rect01 (get-in page [:objects (:id rect01)]) ;; ==== Action - file' (tho/swap-component file copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) + file' (tho/swap-component-in-shape file :copy01 :c02 {:new-shape-label :copy02 :keep-touched? true}) page' (thf/current-page file') copy02' (ths/get-shape file' :copy02) diff --git a/common/test/common_tests/media_test.cljc b/common/test/common_tests/media_test.cljc index b6c18aab2d..a41d2466fa 100644 --- a/common/test/common_tests/media_test.cljc +++ b/common/test/common_tests/media_test.cljc @@ -57,3 +57,38 @@ (t/testing "leaves filename intact when it has no extension" (t/is (= (media/strip-image-extension "README") "README")))) + +(t/deftest test-font-display-variant + (t/testing "preserves the foundry-supplied variant string verbatim" + (t/is (= "Thin" (media/font-display-variant "Thin" 100 "normal"))) + (t/is (= "SemiBold" (media/font-display-variant "SemiBold" 600 "normal"))) + (t/is (= "Medium Oblique" (media/font-display-variant "Medium Oblique" 500 "italic"))) + (t/is (= "Ultra" (media/font-display-variant "Ultra" 900 "normal")))) + + (t/testing "trims surrounding whitespace from upstream variant strings" + (t/is (= "Bold" (media/font-display-variant " Bold " 700 "normal")))) + + (t/testing "ignores blank or nil variant strings" + (t/is (= "Hairline" (media/font-display-variant nil 100 "normal"))) + (t/is (= "Regular" (media/font-display-variant "" 400 "normal"))) + (t/is (= "Bold" (media/font-display-variant " " 700 "normal"))) + (t/is (= "Bold Italic" (media/font-display-variant nil 700 "italic")))) + + (t/testing "fallback covers every supported numeric weight" + (t/is (= "Hairline" (media/font-display-variant nil 100 "normal"))) + (t/is (= "Extra Light" (media/font-display-variant nil 200 "normal"))) + (t/is (= "Light" (media/font-display-variant nil 300 "normal"))) + (t/is (= "Regular" (media/font-display-variant nil 400 "normal"))) + (t/is (= "Medium" (media/font-display-variant nil 500 "normal"))) + (t/is (= "Semi Bold" (media/font-display-variant nil 600 "normal"))) + (t/is (= "Bold" (media/font-display-variant nil 700 "normal"))) + (t/is (= "Extra Bold" (media/font-display-variant nil 800 "normal"))) + (t/is (= "Black" (media/font-display-variant nil 900 "normal"))) + (t/is (= "Extra Black" (media/font-display-variant nil 950 "normal")))) + + (t/testing "italic suffix only applied via the fallback path" + (t/is (= "Italic" (media/font-display-variant "Italic" 400 "italic"))) + (t/is (= "Regular Italic" (media/font-display-variant nil 400 "italic")))) + + (t/testing "stored variant survives even when its derived weight disagrees" + (t/is (= "Ultra" (media/font-display-variant "Ultra" 400 "normal"))))) diff --git a/common/test/common_tests/runner.cljc b/common/test/common_tests/runner.cljc index 06f7926c47..29540525db 100644 --- a/common/test/common_tests/runner.cljc +++ b/common/test/common_tests/runner.cljc @@ -15,6 +15,7 @@ [common-tests.files-builder-test] [common-tests.files-changes-test] [common-tests.files-migrations-test] + [common-tests.files.shapes-builder-test] [common-tests.geom-align-test] [common-tests.geom-bounds-map-test] [common-tests.geom-flex-layout-test] diff --git a/common/test/common_tests/types/color_test.cljc b/common/test/common_tests/types/color_test.cljc index 9a3ab00ac9..deb0f24346 100644 --- a/common/test/common_tests/types/color_test.cljc +++ b/common/test/common_tests/types/color_test.cljc @@ -164,3 +164,78 @@ {:color "#ffffff" :opacity 1.0 :offset 0.5}] result (colors/interpolate-gradient stops 1.0)] (t/is (= "#ffffff" (:color result)))))) + +(t/deftest rgb-to-hsb + ;; Achromatic black: brightness 0 + (let [[h s b] (colors/rgb->hsb [0 0 0])] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (mth/close? b 0.0))) + ;; Pure red: hue 0, full saturation, brightness 100 + (let [[h s b] (colors/rgb->hsb [255 0 0])] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (mth/close? b 100.0))) + ;; Pure white: brightness 100 + (let [[_ _ b] (colors/rgb->hsb [255 255 255])] + (t/is (mth/close? b 100.0))) + ;; Mid gray: brightness ~50.2 + (let [[_ _ b] (colors/rgb->hsb [128 128 128])] + (t/is (mth/close? b (* (/ 128.0 255.0) 100.0))))) + +(t/deftest hsb-to-rgb + (t/is (= [0 0 0] (colors/hsb->rgb [0 0 0]))) + (t/is (= [255 255 255] (colors/hsb->rgb [0 0 100]))) + ;; Pure red from HSB + (let [[r g b] (colors/hsb->rgb [0 1 100])] + (t/is (= 255 r)) + (t/is (= 0 g)) + (t/is (= 0 b)))) + +(t/deftest hex-to-hsb + ;; Black + (let [[h s b] (colors/hex->hsb "#000000")] + (t/is (= 0 h)) + (t/is (= 0 s)) + (t/is (mth/close? b 0.0))) + ;; White: brightness 100 + (let [[_ _ b] (colors/hex->hsb "#ffffff")] + (t/is (mth/close? b 100.0))) + ;; Red + (let [[h s b] (colors/hex->hsb "#ff0000")] + (t/is (mth/close? h 0.0)) + (t/is (mth/close? s 1.0)) + (t/is (mth/close? b 100.0)))) + +(t/deftest hsb-to-hex + (t/is (= "#000000" (colors/hsb->hex [0 0 0]))) + (t/is (= "#ffffff" (colors/hsb->hex [0 0 100])))) + +(t/deftest hsv-hsb-roundtrip + ;; HSV brightness is 0-255, HSB brightness is 0-100. Round-trip + ;; should reach the same triple within ±1 (integer rounding). + (let [orig [210.0 0.5 128] + hsb (colors/hsv->hsb orig) + result (colors/hsb->hsv hsb)] + (t/is (mth/close? (nth orig 0) (nth result 0))) + (t/is (mth/close? (nth orig 1) (nth result 1))) + (t/is (< (mth/abs (- (nth orig 2) (nth result 2))) 2)))) + +(t/deftest rgb-hsb-roundtrip + ;; RGB → HSB → RGB should land within ±1 per channel + (let [orig [100 150 200] + hsb (colors/rgb->hsb orig) + result (colors/hsb->rgb hsb)] + (t/is (every? true? (map #(< (mth/abs (- %1 %2)) 2) orig result))))) + +(t/deftest hex-hsb-roundtrip + ;; HEX → HSB → HEX should preserve the color across the model swap + (let [orig "#fabada" + hsb (colors/hex->hsb orig) + result (colors/hsb->hex hsb)] + ;; Allow ±1 per channel after the round-trip due to integer rounding + (let [[r1 g1 b1] (colors/hex->rgb orig) + [r2 g2 b2] (colors/hex->rgb result)] + (t/is (< (mth/abs (- r1 r2)) 2)) + (t/is (< (mth/abs (- g1 g2)) 2)) + (t/is (< (mth/abs (- b1 b2)) 2))))) diff --git a/common/test/common_tests/types/components_test.cljc b/common/test/common_tests/types/components_test.cljc index 36394f29a2..684d45db99 100644 --- a/common/test/common_tests/types/components_test.cljc +++ b/common/test/common_tests/types/components_test.cljc @@ -6,9 +6,13 @@ (ns common-tests.types.components-test (:require + [app.common.test-helpers.components :as thc] + [app.common.test-helpers.compositions :as tho] + [app.common.test-helpers.files :as thf] [app.common.test-helpers.ids-map :as thi] [app.common.test-helpers.shapes :as ths] [app.common.types.component :as ctk] + [app.common.types.file :as ctf] [clojure.test :as t])) (t/use-fixtures :each thi/test-fixture) @@ -39,3 +43,357 @@ (t/is (= (ctk/get-swap-slot s4) #uuid "9cc181fa-5eef-8084-8004-7bb2ab45fd1f")) (t/is (= (ctk/get-swap-slot s5) #uuid "9cc181fa-5eef-8084-8004-7bb2ab45fd1f")) (t/is (nil? (ctk/get-swap-slot s6))))) + +(t/deftest test-find-near-match + + (t/testing "shapes not in a component have no near match" + (let [file + ;; :frame1 [:name Frame1] + ;; :child1 [:name Rect1] + (-> (thf/sample-file :file1) + (tho/add-frame-with-child :frame1 :shape1)) + + page (thf/current-page file) + + frame1 (ths/get-shape file :frame1) + shape1 (ths/get-shape file :shape1) + + near-match1 (ctf/find-near-match file page {} frame1) + near-match2 (ctf/find-near-match file page {} shape1)] + + (t/is (nil? near-match1)) + (t/is (nil? near-match2)))) + + (t/testing "shapes in a copy get the ref-shape" + (let [file + ;; {:main-root} [:name Frame1] # [Component :component1] + ;; :main-child1 [:name Rect1] + ;; :main-child2 [:name Rect2] + ;; :main-child3 [:name Rect3] + ;; + ;; :copy-root [:name Frame1] #--> [Component :component1] :main-root + ;; [:name Rect1] ---> :main-child1 + ;; [:name Rect2] ---> :main-child2 + ;; [:name Rect3] ---> :main-child3 + (-> (thf/sample-file :file1) + (tho/add-component-with-many-children-and-copy :component1 + :main-root [:main-child1 :main-child2 :main-child3] + :copy-root)) + + page (thf/current-page file) + + main-root (ths/get-shape file :main-root) + main-child1 (ths/get-shape file :main-child1) + main-child2 (ths/get-shape file :main-child2) + main-child3 (ths/get-shape file :main-child3) + copy-root (ths/get-shape file :copy-root) + copy-child1 (ths/get-shape-by-id file (nth (:shapes copy-root) 0)) + copy-child2 (ths/get-shape-by-id file (nth (:shapes copy-root) 1)) + copy-child3 (ths/get-shape-by-id file (nth (:shapes copy-root) 2)) + + near-main-root (ctf/find-near-match file page {} main-root) + near-main-child1 (ctf/find-near-match file page {} main-child1) + near-main-child2 (ctf/find-near-match file page {} main-child2) + near-main-child3 (ctf/find-near-match file page {} main-child3) + near-copy-root (ctf/find-near-match file page {} copy-root) + near-copy-child1 (ctf/find-near-match file page {} copy-child1) + near-copy-child2 (ctf/find-near-match file page {} copy-child2) + near-copy-child3 (ctf/find-near-match file page {} copy-child3)] + + (t/is (nil? near-main-root)) + (t/is (nil? near-main-child1)) + (t/is (nil? near-main-child2)) + (t/is (nil? near-main-child3)) + (t/is (nil? near-copy-root)) + (t/is (= (:id near-copy-child1) (thi/id :main-child1))) + (t/is (= (:id near-copy-child2) (thi/id :main-child2))) + (t/is (= (:id near-copy-child3) (thi/id :main-child3))))) + + (t/testing "shapes in nested not swapped copies get the ref-shape" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root + ;; :nested-child [:name Rect1] ---> :main1-child + ;; + ;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame1] @--> [Component :component1] :nested-head + ;; :copy2-nested-child [:name Rect1] ---> :nested-child + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head + :nested-head-params {:children-labels [:nested-child]}) + (thc/instantiate-component :component2 :copy2 + :children-labels [:copy2-nested-head :copy2-nested-child])) + + page (thf/current-page file) + + main1-root (ths/get-shape file :main1-root) + main1-child (ths/get-shape file :main1-child) + main2-root (ths/get-shape file :main2-root) + nested-head (ths/get-shape file :nested-head) + nested-child (ths/get-shape file :nested-child) + copy2 (ths/get-shape file :copy2) + copy2-nested-head (ths/get-shape file :copy2-nested-head) + copy2-nested-child (ths/get-shape file :copy2-nested-child) + + near-main1-root (ctf/find-near-match file page {} main1-root) + near-main1-child (ctf/find-near-match file page {} main1-child) + near-main2-root (ctf/find-near-match file page {} main2-root) + near-nested-head (ctf/find-near-match file page {} nested-head) + near-nested-child (ctf/find-near-match file page {} nested-child) + near-copy2 (ctf/find-near-match file page {} copy2) + near-copy2-nested-head (ctf/find-near-match file page {} copy2-nested-head) + near-copy2-nested-child (ctf/find-near-match file page {} copy2-nested-child)] + + (t/is (nil? near-main1-root)) + (t/is (nil? near-main1-child)) + (t/is (nil? near-main2-root)) + (t/is (nil? near-nested-head)) + (t/is (= (:id near-nested-child) (thi/id :main1-child))) + (t/is (nil? near-copy2)) + (t/is (= (:id near-copy2-nested-head) (thi/id :nested-head))) + (t/is (= (:id near-copy2-nested-child) (thi/id :nested-child))))) + + (t/testing "shapes in swapped copies get the swap slot" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested-head [:name Frame1] @--> [Component :component1] :main1-root + ;; :nested-child [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame3] @--> [Component :component3] :main3-root + ;; {swap-slot :nested-head} + ;; [:name Rect3] ---> :main3-child + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested-head + :nested-head-params {:children-labels [:nested-child]}) + (thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head]) + (tho/add-simple-component :component3 :main3-root :main3-child + :root-params {:name "Frame3"} + :child-params {:name "Rect3"}) + (tho/swap-component-in-first-child :copy2 :component3)) + + page (thf/current-page file) + + main1-root (ths/get-shape file :main1-root) + main1-child (ths/get-shape file :main1-child) + main2-root (ths/get-shape file :main2-root) + nested-head (ths/get-shape file :nested-head) + nested-child (ths/get-shape file :nested-child) + copy2 (ths/get-shape file :copy2) + copy2-nested-head (ths/get-shape file :copy2-nested-head) + copy2-nested-child (ths/get-shape-by-id file (first (:shapes copy2-nested-head))) + + near-main1-root (ctf/find-near-match file page {} main1-root) + near-main1-child (ctf/find-near-match file page {} main1-child) + near-main2-root (ctf/find-near-match file page {} main2-root) + near-nested-head (ctf/find-near-match file page {} nested-head) + near-nested-child (ctf/find-near-match file page {} nested-child) + near-copy2 (ctf/find-near-match file page {} copy2) + near-copy2-nested-head (ctf/find-near-match file page {} copy2-nested-head) + near-copy2-nested-child (ctf/find-near-match file page {} copy2-nested-child)] + + (t/is (nil? near-main1-root)) + (t/is (nil? near-main1-child)) + (t/is (nil? near-main2-root)) + (t/is (nil? near-nested-head)) + (t/is (= (:id near-nested-child) (thi/id :main1-child))) + (t/is (nil? near-copy2)) + (t/is (= (:id near-copy2-nested-head) (thi/id :nested-head))) + (t/is (= (:id near-copy2-nested-child) (thi/id :main3-child))))) + + (t/testing "shapes in second level nested copies under swapped get the shape in the new main" + (let [file + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested2-head [:name Frame1] @--> [Component :component1] :main1-root + ;; :nested2-child [:name Rect1] ---> :main1-child + ;; + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; {:main4-root} [:name Frame4] # [Component :component4] + ;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root + ;; :nested4-child [:name Rect3] ---> :main3-child + ;; + ;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame4] @--> [Component :component4] :main4-root + ;; {swap-slot :nested2-head} + ;; [:name Frame3] @--> :nested4-head + ;; [:name Rect3] ---> :nested4-child + (-> (thf/sample-file :file1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested2-head + :nested-head-params {:children-labels [:nested2-child]}) + (thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head]) + (tho/add-nested-component :component3 :main3-root :main3-child + :component4 :main4-root :nested4-head + :root1-params {:name "Frame3"} + :main1-child-params {:name "Rect3"} + :main2-root-params {:name "Frame4"} + :nested-head-params {:children-labels [:nested4-child]}) + (tho/swap-component-in-first-child :copy2 :component4)) + + page (thf/current-page file) + + main1-root (ths/get-shape file :main1-root) + main1-child (ths/get-shape file :main1-child) + main2-root (ths/get-shape file :main2-root) + nested2-head (ths/get-shape file :nested2-head) + nested2-child (ths/get-shape file :nested2-child) + main3-root (ths/get-shape file :main3-root) + main3-child (ths/get-shape file :main3-child) + main4-root (ths/get-shape file :main4-root) + nested4-head (ths/get-shape file :nested4-head) + nested4-child (ths/get-shape file :nested4-child) + copy2 (ths/get-shape file :copy2) + copy2-nested-head (ths/get-shape file :copy2-nested-head) + copy2-nested4-head (ths/get-shape-by-id file (first (:shapes copy2-nested-head))) + copy2-nested4-child (ths/get-shape-by-id file (first (:shapes copy2-nested4-head))) + + near-main1-root (ctf/find-near-match file page {} main1-root) + near-main1-child (ctf/find-near-match file page {} main1-child) + near-main2-root (ctf/find-near-match file page {} main2-root) + near-nested2-head (ctf/find-near-match file page {} nested2-head) + near-nested2-child (ctf/find-near-match file page {} nested2-child) + near-main3-root (ctf/find-near-match file page {} main3-root) + near-main3-child (ctf/find-near-match file page {} main3-child) + near-main4-root (ctf/find-near-match file page {} main4-root) + near-nested4-head (ctf/find-near-match file page {} nested4-head) + near-nested4-child (ctf/find-near-match file page {} nested4-child) + near-copy2 (ctf/find-near-match file page {} copy2) + near-copy2-nested-head (ctf/find-near-match file page {} copy2-nested-head) + near-copy2-nested4-head (ctf/find-near-match file page {} copy2-nested4-head) + near-copy2-nested4-child (ctf/find-near-match file page {} copy2-nested4-child)] + + (t/is (nil? near-main1-root)) + (t/is (nil? near-main1-child)) + (t/is (nil? near-main2-root)) + (t/is (nil? near-nested2-head)) + (t/is (= (:id near-nested2-child) (thi/id :main1-child))) + (t/is (nil? near-main3-root)) + (t/is (nil? near-main3-child)) + (t/is (nil? near-main4-root)) + (t/is (nil? near-nested4-head)) + (t/is (= (:id near-nested4-child) (thi/id :main3-child))) + (t/is (nil? near-copy2)) + (t/is (= (:id near-copy2-nested-head) (thi/id :nested2-head))) + (t/is (= (:id near-copy2-nested4-head) (thi/id :nested4-head))) + (t/is (= (:id near-copy2-nested4-child) (thi/id :nested4-child))))) + + (t/testing "component in external libraries still work well" + (let [library1 + ;; {:main1-root} [:name Frame1] # [Component :component1] + ;; :main1-child [:name Rect1] + ;; + ;; {:main2-root} [:name Frame2] # [Component :component2] + ;; :nested2-head [:name Frame1] @--> [Component :component1] :main1-root + ;; :nested2-child [:name Rect1] ---> :main1-child + (-> (thf/sample-file :library1) + (tho/add-nested-component :component1 :main1-root :main1-child + :component2 :main2-root :nested2-head + :nested-head-params {:children-labels [:nested2-child]})) + library2 + ;; {:main3-root} [:name Frame3] # [Component :component3] + ;; :main3-child [:name Rect3] + ;; + ;; {:main4-root} [:name Frame4] # [Component :component4] + ;; :nested4-head [:name Frame3] @--> [Component :component1] :main3-root + ;; :nested4-child [:name Rect3] ---> :main3-child + (-> (thf/sample-file :library2) + (tho/add-nested-component :component3 :main3-root :main3-child + :component4 :main4-root :nested4-head + :root1-params {:name "Frame3"} + :main1-child-params {:name "Rect3"} + :main2-root-params {:name "Frame4"} + :nested-head-params {:children-labels [:nested4-child]})) + + file + ;; :copy2 [:name Frame2] #--> [Component :component2] :main2-root + ;; :copy2-nested-head [:name Frame4] @--> [Component :component4] :main4-root + ;; {swap-slot :nested2-head} + ;; [:name Frame3] @--> :nested4-head + ;; [:name Rect3] ---> :nested4-child + (-> (thf/sample-file :file1) + (thc/instantiate-component :component2 :copy2 :children-labels [:copy2-nested-head] + :library library1) + (tho/swap-component-in-first-child :copy2 :component4 :library library2)) + + page-library1 (thf/current-page library1) + page-library2 (thf/current-page library2) + page-file (thf/current-page file) + libraries {(:id library1) library1 + (:id library2) library2} + + main1-root (ths/get-shape library1 :main1-root) + main1-child (ths/get-shape library1 :main1-child) + main2-root (ths/get-shape library1 :main2-root) + nested2-head (ths/get-shape library1 :nested2-head) + nested2-child (ths/get-shape library1 :nested2-child) + main3-root (ths/get-shape library2 :main3-root) + main3-child (ths/get-shape library2 :main3-child) + main4-root (ths/get-shape library2 :main4-root) + nested4-head (ths/get-shape library2 :nested4-head) + nested4-child (ths/get-shape library2 :nested4-child) + copy2 (ths/get-shape file :copy2) + copy2-nested-head (ths/get-shape file :copy2-nested-head) + copy2-nested4-head (ths/get-shape-by-id file (first (:shapes copy2-nested-head))) + copy2-nested4-child (ths/get-shape-by-id file (first (:shapes copy2-nested4-head))) + + near-main1-root (ctf/find-near-match file page-file libraries main1-root) + near-main1-child (ctf/find-near-match file page-file libraries main1-child) + near-main2-root (ctf/find-near-match file page-file libraries main2-root) + near-nested2-head (ctf/find-near-match library1 page-library1 libraries nested2-head) + near-nested2-child (ctf/find-near-match library1 page-library1 libraries nested2-child) + near-main3-root (ctf/find-near-match file page-file libraries main3-root) + near-main3-child (ctf/find-near-match file page-file libraries main3-child) + near-main4-root (ctf/find-near-match file page-file libraries main4-root) + near-nested4-head (ctf/find-near-match library2 page-library2 libraries nested4-head) + near-nested4-child (ctf/find-near-match library2 page-library2 libraries nested4-child) + near-copy2 (ctf/find-near-match file page-file libraries copy2) + near-copy2-nested-head (ctf/find-near-match file page-file libraries copy2-nested-head) + near-copy2-nested4-head (ctf/find-near-match file page-file libraries copy2-nested4-head) + near-copy2-nested4-child (ctf/find-near-match file page-file libraries copy2-nested4-child)] + + (thf/dump-file library1 :keys [:name :swap-slot-label] :show-refs? true) + (t/is (some? main1-root)) + (t/is (some? main1-child)) + (t/is (some? main2-root)) + (t/is (some? nested2-head)) + (t/is (some? nested2-child)) + (t/is (some? main3-root)) + (t/is (some? main3-child)) + (t/is (some? main4-root)) + (t/is (some? nested4-head)) + (t/is (some? nested4-child)) + (t/is (some? copy2)) + (t/is (some? copy2-nested-head)) + (t/is (some? copy2-nested4-head)) + (t/is (some? copy2-nested4-child)) + + (t/is (nil? near-main1-root)) + (t/is (nil? near-main1-child)) + (t/is (nil? near-main2-root)) + (t/is (nil? near-nested2-head)) + (t/is (= (:id near-nested2-child) (thi/id :main1-child))) + (t/is (nil? near-main3-root)) + (t/is (nil? near-main3-child)) + (t/is (nil? near-main4-root)) + (t/is (nil? near-nested4-head)) + (t/is (= (:id near-nested4-child) (thi/id :main3-child))) + (t/is (nil? near-copy2)) + (t/is (= (:id near-copy2-nested-head) (thi/id :nested2-head))) + (t/is (= (:id near-copy2-nested4-head) (thi/id :nested4-head))) + (t/is (= (:id near-copy2-nested4-child) (thi/id :nested4-child)))))) diff --git a/common/test/common_tests/types/path_data_test.cljc b/common/test/common_tests/types/path_data_test.cljc index 6dc7fa5207..69d14355b7 100644 --- a/common/test/common_tests/types/path_data_test.cljc +++ b/common/test/common_tests/types/path_data_test.cljc @@ -667,6 +667,41 @@ result (path.subpath/close-subpaths content)] (t/is (seq result))))) +(t/deftest subpath-merge-touching-subpaths + (t/testing "adjacent subpaths sharing an endpoint collapse into one chain" + ;; Heroicons-style fragment: continuous polyline split as M-L M-L M-L + ;; with the second/third subpath starting at the first's endpoint. + (let [content [{:command :move-to :params {:x 0.0 :y 10.0}} + {:command :line-to :params {:x 10.0 :y 10.0}} + {:command :move-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :move-to :params {:x 10.0 :y 10.0}} + {:command :line-to :params {:x 5.0 :y 20.0}}] + result (path.subpath/merge-touching-subpaths content) + moves (filter #(= :move-to (:command %)) result)] + ;; Subpaths 1+2 share (10,10) → merged. Subpath 3 also starts at (10,10), + ;; but the merged chain now ends at (5,0), so it does NOT match and + ;; is preserved as its own subpath. Two move-tos in the final result. + (t/is (= 2 (count moves))) + (t/is (= 5 (count result))))) + (t/testing "non-touching subpaths are left untouched" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :move-to :params {:x 50.0 :y 50.0}} + {:command :line-to :params {:x 60.0 :y 60.0}}] + result (path.subpath/merge-touching-subpaths content)] + (t/is (= content (vec result))))) + (t/testing "closed subpath is not absorbed into a neighbour" + (let [content [{:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 0.0}} + {:command :line-to :params {:x 5.0 :y 5.0}} + {:command :line-to :params {:x 0.0 :y 0.0}} + {:command :move-to :params {:x 0.0 :y 0.0}} + {:command :line-to :params {:x 1.0 :y 1.0}}] + result (path.subpath/merge-touching-subpaths content) + moves (filter #(= :move-to (:command %)) result)] + (t/is (= 2 (count moves)))))) + (t/deftest subpath-reverse-content (let [result (path.subpath/reverse-content simple-open-content)] (t/is (= (count simple-open-content) (count result))) @@ -1100,6 +1135,24 @@ (t/is (path/content? result)) (t/is (seq (vec result))))) +(t/deftest path-merge-touching-subpaths + (t/testing "regression for #5283 — heroicons arrow path serialises as a single chain" + ;; SVG `d` originally split a continuous polyline by inserting a + ;; redundant moveto at the elbow. Importing it must collapse the + ;; first two subpaths so that stroke-linejoin renders the rounded tip. + (let [content (path/from-string + (str "M350.5,1846 L365.5,1846" + " M365.5,1846 L358.75,1839.25" + " M365.5,1846 L358.75,1852.75")) + merged (path/merge-touching-subpaths content) + rendered (str merged)] + (t/is (path/content? merged)) + ;; First two subpaths fold into M ... L ... L ... ; third stays + ;; separate (its start point matches the original M, not the merged + ;; chain's tail), so exactly two M commands remain. + (t/is (= 2 (count (re-seq #"M" rendered)))) + (t/is (= 3 (count (re-seq #"L" rendered))))))) + (t/deftest path-move-content (let [content (path/content sample-content-square) move-vec (gpt/point 3.0 4.0) diff --git a/docker/images/files/config.js b/docker/images/files/config.js index 7bc9ce9404..621331c252 100644 --- a/docker/images/files/config.js +++ b/docker/images/files/config.js @@ -1,2 +1,3 @@ // Frontend configuration //var penpotFlags = ""; +//var penpotOIDCName = ""; diff --git a/docker/images/files/nginx-entrypoint.sh b/docker/images/files/nginx-entrypoint.sh index 9ce2b9261d..01e918ec5c 100644 --- a/docker/images/files/nginx-entrypoint.sh +++ b/docker/images/files/nginx-entrypoint.sh @@ -25,7 +25,16 @@ update_flags() { fi } +update_oidc_name() { + if [ -n "$PENPOT_OIDC_NAME" ]; then + echo "$(sed \ + -e "s|^//var penpotOIDCName = .*;|var penpotOIDCName = \"$PENPOT_OIDC_NAME\";|g" \ + "$1")" > "$1" + fi +} + update_flags /var/www/app/js/config.js +update_oidc_name /var/www/app/js/config.js ######################################### ## Nginx Config diff --git a/docs/plugins/create-a-plugin.md b/docs/plugins/create-a-plugin.md index 42fc096dbe..9a25d101b9 100644 --- a/docs/plugins/create-a-plugin.md +++ b/docs/plugins/create-a-plugin.md @@ -219,8 +219,9 @@ Now that everything is in place you need a manifest.js { "name": "Plugin name", "description": "Plugin description", - "code": "/plugin.js", - "icon": "/icon.png", + "version": 2, + "code": "plugin.js", + "icon": "icon.png", "permissions": [ "content:read", "content:write", @@ -234,6 +235,13 @@ Now that everything is in place you need a manifest.js } ``` +

+Use "version": 2 when your +code and icon values +are relative paths. Version 2 resolves these assets from the manifest location. +If omitted, Penpot treats the manifest as version 1. +

+ ### Icon The plugin icon must be an image file. All image formats are valid, so you can use whichever format works best for your needs. Although there is no specific size requirement, it is recommended that the icon be 56x56 pixels in order to ensure its optimal appearance across all devices. diff --git a/docs/plugins/getting-started.md b/docs/plugins/getting-started.md index abfbc508b1..ea993640a9 100644 --- a/docs/plugins/getting-started.md +++ b/docs/plugins/getting-started.md @@ -131,6 +131,7 @@ The manifest.json file contains the basic infor { "name": "Your plugin name", "description": "Your plugin description", + "version": 2, "code": "plugin.js", "icon": "Your icon", "permissions": [ @@ -147,6 +148,12 @@ The manifest.json file contains the basic infor } ``` +

+Set "version": 2 in your +manifest.json if you use relative paths for +code or icon. +

+ #### Properties - **Name and description**: your plugin's basic information, which will be displayed in the plugin manager modal. diff --git a/docs/technical-guide/configuration.md b/docs/technical-guide/configuration.md index ad4579fcde..4c70936dc7 100644 --- a/docs/technical-guide/configuration.md +++ b/docs/technical-guide/configuration.md @@ -242,6 +242,16 @@ register with another method. PENPOT_FLAGS: [...] enable-oidc-registration ``` +__Since version 2.16.0__ + +Allows customising the label shown on the OIDC login button (defaults to "OpenID"). + +```bash +# Frontend +PENPOT_OIDC_NAME: +``` +
+ #### Azure Active Directory using OpenID Connect Allows integrating with Azure Active Directory as authentication provider: diff --git a/docs/technical-guide/developer/devenv.md b/docs/technical-guide/developer/devenv.md index facb810dd8..0443466e03 100644 --- a/docs/technical-guide/developer/devenv.md +++ b/docs/technical-guide/developer/devenv.md @@ -161,6 +161,59 @@ If an exception is raised or an error occurs when code is reloaded, just use (repl/refresh-all) to finish loading the code correctly and then use (restart) again. + +### MCP Server + +To set up the MCP server local development environment it's needed some additional steps. + +### Activate the MCP features variables + +Create or modify the file `frontend/resources/public/js/config.js` and add (or modify) the `penpotFlags` to add the following: + +```javascript +var penpotFlags = "enable-mcp enable-access-tokens" +``` + +This will enable the MCP in the workspace and in the user settings profile. + +### Start the DEVENV + +Start as usual the development environment + +``` +./manage.sh start-devenv +``` + +Once the TMUX is showing, create a new tmux tab (Ctrl+b c). And in the new tab run: + +```bash +cd mcp +pnpm run bootstrap:multi-user +``` + +This will start the MCP server and the multi-user plugin that will be loaded automaticaly by Penpot. + +There is a NGINX proxy that makes a proxy-pass from outside the docker container so you don't need to remember the ports it's using. + +### Configure the MCP in your tool + +You can use the instructions in [/mcp/#remote-mcp-in-5-steps](/mcp/#remote-mcp-in-5-steps) to setup the server. + +Warning: by default Cursor won't support HTTPS with a self-signed certificate. In order to work around this issue please use the port `3450` that uses an standard `http` protocol + +An example of your cursor configuration can be: + +```javascript +{ + "mcpServers": { + "penpot-devenv": { + "url": "http://localhost:3450/mcp/stream?userToken=TOKEN", + "type": "http" + } + } +} +``` + ## Email To test email sending, the devenv includes [MailCatcher](https://mailcatcher.me/), diff --git a/docs/technical-guide/developer/ui.md b/docs/technical-guide/developer/ui.md index 2a6fb81102..a9bdfaa6f3 100644 --- a/docs/technical-guide/developer/ui.md +++ b/docs/technical-guide/developer/ui.md @@ -199,6 +199,7 @@ Remember that nesting selector increases specificity, and it's usually not neede fill: var(--icon-color); } ``` + Note: Thanks to CSS Modules, identical class names defined in different files are scoped locally and do not cause naming collisions. ### Use CSS logical properties @@ -228,17 +229,21 @@ Note: Although `width` and `height` are physical properties, their use is allowe Avoid hardcoded values like `px`, `rem`, or raw SASS variables `($s-*)`. Use semantic, named variables provided by the Design System to ensure consistency and scalability. #### Spacing (margins, paddings, gaps...) + Use variables from `frontend/src/app/main/ui/ds/spacing.scss`. These are predefined and approved by the design team — **do not add or modify values without design approval**. #### Fixed dimensions + For fixed dimensions (e.g., modals' widths) defined by design and not layout-driven, use or define variables in `frontend/src/app/main/ui/ds/_sizes.scss`. To use them: ```scss @use "ds/_sizes.scss" as *; ``` + Note: Since these values haven't been semantically defined yet, we’re temporarily using SASS variables instead of named CSS custom properties. #### Border Widths + Use border thickness variables from `frontend/src/app/main/ui/ds/_borders.scss`. To import: ```scss @@ -288,16 +293,16 @@ Replace plain text tags with `text*` or `heading*` components from the Design Sy ```clojure ... [app.main.ui.ds.foundations.typography :as t] - [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] [app.main.ui.ds.foundations.typography.text :refer [text*]] ... [:> heading* {:level 2 :typography t/headline-medium - :class (stl/css :modal-title)} + :class (stl/css :modal-title)} title] - [:> text* {:as "div" - :typography t/body-medium + [:> text* {:as "div" + :typography t/body-medium :class (stl/css :modal-content)} "Content"] ``` @@ -308,11 +313,12 @@ When applying typography in SCSS, use the proper mixin from the Design System. ```scss .class { - @include headlineLargeTypography; + @include headline-large-typography; } ``` ✅ **DO: Use the DS mixin** + ```scss @use "ds/typography.scss" as t; @@ -320,10 +326,10 @@ When applying typography in SCSS, use the proper mixin from the Design System. @include t.use-typography("body-small"); } ``` + You can find the full list of available typography tokens in [Storybook](https://design.penpot.app/storybook/?path=/docs/foundations-typography--docs). If the design you are implementing doesn't match any of them, ask a designer. - ### Use custom properties within components Reduce the need for one-off SASS variables by leveraging [CSS custom properties](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_cascading_variables/Using_CSS_custom_properties) in your component styles. This keeps component theming flexible and composable. @@ -664,7 +670,6 @@ We use three **levels of tokens**: We can leverage component tokens to easily implement variants as explained [here](/technical-guide/developer/ui/#use-custom-properties-within-components). - ### Using icons and SVG assets Please refer to the Storybook [documentation for icons](https://hourly.penpot.dev/storybook/?path=/docs/foundations-assets-icon--docs) and other [SVG assets](https://hourly.penpot.dev/storybook/?path=/docs/foundations-assets-rawsvg--docs) (logos, illustrations, etc.). diff --git a/exporter/src/app/browser.cljs b/exporter/src/app/browser.cljs index 526ae77380..798a9c3f44 100644 --- a/exporter/src/app/browser.cljs +++ b/exporter/src/app/browser.cljs @@ -47,6 +47,19 @@ [page ms] (.waitForTimeout ^js page ms)) +(defn wait-for-fonts + "Wait until the browser has finished loading all fonts" + ([page] (wait-for-fonts page nil)) + ([page {:keys [timeout] :or {timeout 15000}}] + (-> (.waitForFunction ^js page + "() => document.fonts && document.fonts.status === 'loaded'" + nil + #js {:timeout timeout}) + (p/catch (fn [cause] + (l/warn :hint "wait-for-fonts timed out; continuing anyway" + :cause (ex-message cause)) + (p/resolved nil)))))) + (defn wait-for ([locator] (wait-for locator nil)) ([locator {:keys [state timeout] :or {state "visible" timeout 10000}}] diff --git a/exporter/src/app/handlers/resources.cljs b/exporter/src/app/handlers/resources.cljs index f0f655c498..e981856da4 100644 --- a/exporter/src/app/handlers/resources.cljs +++ b/exporter/src/app/handlers/resources.cljs @@ -36,7 +36,7 @@ {:path path :mtype (mime/get type) :name name - :filename (str/concat (str/slug name) (mime/get-extension type)) + :filename (str/concat (str/replace name #"[\\/:*?\"<>|]" "_") (mime/get-extension type)) :id task-id})) (defn create-zip diff --git a/exporter/src/app/renderer/bitmap.cljs b/exporter/src/app/renderer/bitmap.cljs index 6b9dbcb4b9..63276ee48a 100644 --- a/exporter/src/app/renderer/bitmap.cljs +++ b/exporter/src/app/renderer/bitmap.cljs @@ -47,6 +47,7 @@ ;; navigate to the page and perform basic setup (bw/nav! page (str uri)) (bw/sleep page 1000) ; the good old fix with sleep + (bw/wait-for-fonts page) (bw/eval! page (js* "() => document.body.style.background = 'transparent'")) ;; take the screnshot of requested objects, one by one diff --git a/exporter/src/app/renderer/pdf.cljs b/exporter/src/app/renderer/pdf.cljs index edfdcda1b1..bdfd8c6dc5 100644 --- a/exporter/src/app/renderer/pdf.cljs +++ b/exporter/src/app/renderer/pdf.cljs @@ -66,6 +66,7 @@ (sync-page-size! dom) (bw/screenshot dom {:full-page? true}) (bw/sleep page 2000) ; the good old fix with sleep + (bw/wait-for-fonts page) (bw/pdf page {:path path}) path))) diff --git a/exporter/src/app/renderer/svg.cljs b/exporter/src/app/renderer/svg.cljs index 71da424fb3..135edee8d0 100644 --- a/exporter/src/app/renderer/svg.cljs +++ b/exporter/src/app/renderer/svg.cljs @@ -338,6 +338,7 @@ ;; navigate to the page and perform basic setup (bw/nav! page (str uri)) (bw/sleep page 1000) ; the good old fix with sleep + (bw/wait-for-fonts page) ;; take the screnshot of requested objects, one by one (p/run (partial render-object page) objects) diff --git a/frontend/AGENTS.md b/frontend/AGENTS.md index 0e33a32f30..681528b4f3 100644 --- a/frontend/AGENTS.md +++ b/frontend/AGENTS.md @@ -329,6 +329,31 @@ CSS modules pattern): - [ ] Selectors are flat (no deep nesting). +### Translations (`tr`) and Memoization + +`(tr "some.key")` resolves the translation string from the **currently active +locale at call time**. This has two consequences: + +- **Never call `(tr ...)` at namespace level** (inside a `def` or `defonce`). + Doing so would freeze the label to the locale active at module load time and + break runtime language switching. +- **Always call `(tr ...)` at render time** — either directly in the component + body or inside a `mf/with-memo` / `mf/use-memo` block. + +When a component renders a **static list of options** whose labels come from +`(tr ...)` (e.g. radio button options, select options), wrap the vector in +`mf/with-memo []` with no dependencies. This ensures the vector and its +`(tr ...)` calls are evaluated once per component mount instead of on every +render, while still respecting the render-time requirement: + +```clojure +(let [options (mf/with-memo [] + [{:value "top" :label (tr "some.key.top")} + {:value "center" :label (tr "some.key.center")} + {:value "bottom" :label (tr "some.key.bottom")}])] + ...) +``` + ### Performance Macros (`app.common.data.macros`) Always prefer these macros over their `clojure.core` equivalents — they compile to faster JavaScript: diff --git a/frontend/package.json b/frontend/package.json index 564f2bf0ca..82047594c1 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -31,7 +31,7 @@ "fmt:scss": "prettier -c resources/styles -c src/**/*.scss -w", "lint:clj": "clj-kondo --parallel --lint ../common/src src/", "lint:js": "exit 0", - "lint:scss": "exit 0", + "lint:scss": "pnpx stylelint '{src,resources}/**/*.scss'", "build:test": "clojure -M:dev:shadow-cljs compile test", "test": "pnpm run build:wasm && pnpm run build:test && node target/tests/test.js", "test:storybook": "vitest run --project=storybook", @@ -94,6 +94,7 @@ "postcss": "^8.5.8", "postcss-clean": "^1.2.2", "postcss-modules": "^6.0.1", + "postcss-scss": "^4.0.9", "prettier": "3.8.1", "pretty-time": "^1.1.0", "prop-types": "^15.8.1", @@ -111,6 +112,10 @@ "source-map-support": "^0.5.21", "storybook": "10.3.5", "style-dictionary": "5.0.0-rc.1", + "stylelint": "^17.4.0", + "stylelint-config-standard-scss": "^17.0.0", + "stylelint-scss": "^7.0.0", + "stylelint-use-logical-spec": "^5.0.1", "svg-sprite": "^2.0.4", "tdigest": "^0.1.2", "tinycolor2": "^1.6.0", diff --git a/frontend/packages/mousetrap/index.js b/frontend/packages/mousetrap/index.js index 5a0bc3e0bc..12bcbab1b9 100644 --- a/frontend/packages/mousetrap/index.js +++ b/frontend/packages/mousetrap/index.js @@ -187,6 +187,14 @@ function _addEvent(object, type, callback) { */ function _characterFromEvent(e) { + // Numpad digits as "num0".."num9" — keeps them separate from main-row bindings across NumLock states and event types. + if (e.code && e.code.indexOf('Numpad') === 0) { + var suffix = e.code.substring(6); + if (suffix.length === 1 && suffix >= '0' && suffix <= '9') { + return 'num' + suffix; + } + } + // for keypress events we should return the character as is if (e.type == 'keypress') { var character = String.fromCharCode(e.which); diff --git a/frontend/playwright/data/workspace/get-file-fragment-tokens.json b/frontend/playwright/data/workspace/get-file-fragment-tokens.json index 128f45d28d..69061c79eb 100644 --- a/frontend/playwright/data/workspace/get-file-fragment-tokens.json +++ b/frontend/playwright/data/workspace/get-file-fragment-tokens.json @@ -186,7 +186,8 @@ }, "~:fills": [ { - "~:fill-color": "#7f9cf5" + "~:fill-color": "#7f9cf5", + "~:fill-opacity": 1 } ], "~:flip-x": null, @@ -235,7 +236,8 @@ "~:letter-spacing": "0", "~:fills": [ { - "~:fill-color": "#ffffff" + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 } ], "~:font-family": "sourcesanspro", @@ -257,7 +259,8 @@ "~:letter-spacing": "0", "~:fills": [ { - "~:fill-color": "#ffffff" + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 } ], "~:font-family": "sourcesanspro" @@ -328,7 +331,8 @@ "~:y2": 37.33333456516266, "~:fills": [ { - "~:fill-color": "#ffffff" + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 } ], "~:x2": 86.60417175292969, @@ -445,7 +449,8 @@ }, "~:fills": [ { - "~:fill-color": "#ffffff" + "~:fill-color": "#ffffff", + "~:fill-opacity": 1 } ], "~:flip-x": null, diff --git a/frontend/playwright/ui/pages/DashboardPage.js b/frontend/playwright/ui/pages/DashboardPage.js index f7e4df2582..4dee04f18e 100644 --- a/frontend/playwright/ui/pages/DashboardPage.js +++ b/frontend/playwright/ui/pages/DashboardPage.js @@ -147,6 +147,7 @@ export class DashboardPage extends BaseWebSocketPage { "get-projects?team-id=*", "dashboard/get-projects-full.json", ); + await this.mockRPC( "get-project-files?project-id=*", "dashboard/get-project-files.json", diff --git a/frontend/playwright/ui/pages/WorkspacePage.js b/frontend/playwright/ui/pages/WorkspacePage.js index f114d8abda..ec963f718a 100644 --- a/frontend/playwright/ui/pages/WorkspacePage.js +++ b/frontend/playwright/ui/pages/WorkspacePage.js @@ -191,6 +191,7 @@ export class WorkspacePage extends BaseWebSocketPage { this.tokensUpdateCreateModal = page.getByTestId( "token-update-create-modal", ); + this.tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); this.tokenThemeUpdateCreateModal = page.getByTestId( "token-theme-update-create-modal", ); @@ -311,7 +312,7 @@ export class WorkspacePage extends BaseWebSocketPage { async clickWithDragViewportAt(x, y, width, height) { await this.page.waitForTimeout(100); const box = await this.viewport.boundingBox(); - if (!box) throw new Error('Viewport not visible'); + if (!box) throw new Error("Viewport not visible"); const startX = box.x + x; const startY = box.y + y; @@ -364,7 +365,9 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.keyboard.press("T"); await this.page.waitForTimeout(timeToWait); - const layersCountBefore = await this.layers.getByTestId("layer-row").count(); + const layersCountBefore = await this.layers + .getByTestId("layer-row") + .count(); await this.clickAndMove(x1, y1, x2, y2); if (initialText) { @@ -387,10 +390,13 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.keyboard.press("ControlOrMeta+C"); } // wait for the clipboard to be updated - await this.page.waitForFunction(async () => { - const content = await navigator.clipboard.readText() - return content !== ""; - }, { timeout: 1000 }); + await this.page.waitForFunction( + async () => { + const content = await navigator.clipboard.readText(); + return content !== ""; + }, + { timeout: 1000 }, + ); } async cut(kind = "keyboard", locator = undefined) { @@ -401,13 +407,15 @@ export class WorkspacePage extends BaseWebSocketPage { await this.page.keyboard.press("ControlOrMeta+X"); } // wait for the clipboard to be updated - await this.page.waitForFunction(async () => { - const content = await navigator.clipboard.readText() - return content !== ""; - }, { timeout: 1000 }); + await this.page.waitForFunction( + async () => { + const content = await navigator.clipboard.readText(); + return content !== ""; + }, + { timeout: 1000 }, + ); await this.page.waitForTimeout(3000); - } /** diff --git a/frontend/playwright/ui/specs/colorpicker.spec.js b/frontend/playwright/ui/specs/colorpicker.spec.js index a0e28eea07..75dad9c97f 100644 --- a/frontend/playwright/ui/specs/colorpicker.spec.js +++ b/frontend/playwright/ui/specs/colorpicker.spec.js @@ -85,14 +85,6 @@ test("Create a LINEAR gradient", async ({ page }) => { .last(); await inputOpacity2.fill("40"); - const inputOpacityGlobal = workspacePage.colorpicker.getByTestId( - "opacity-global-input", - ); - await inputOpacityGlobal.fill("50"); - await inputOpacityGlobal.press("Enter"); - await expect(inputOpacityGlobal).toHaveValue("50"); - await expect(inputOpacityGlobal).toBeVisible(); - await expect( workspacePage.page.getByText("Linear gradient") ).toBeVisible(); @@ -169,14 +161,6 @@ test("Create a RADIAL gradient", async ({ page }) => { .last(); await inputOpacity2.fill("100"); - const inputOpacityGlobal = workspacePage.colorpicker.getByTestId( - "opacity-global-input", - ); - await inputOpacityGlobal.fill("50"); - await inputOpacityGlobal.press("Enter"); - await expect(inputOpacityGlobal).toHaveValue("50"); - await expect(inputOpacityGlobal).toBeVisible(); - await expect( workspacePage.page.getByText("Radial gradient") ).toBeVisible(); @@ -212,7 +196,7 @@ test("Gradient stops limit", async ({ page }) => { }); // Fix for https://tree.taiga.io/project/penpot/issue/9900 -test("Bug 9900 - Color picker has no inputs for HSV values", async ({ +test("Bug 9900 - Color picker has no inputs for HSB values", async ({ page, }) => { const workspacePage = new WasmWorkspacePage(page); @@ -223,12 +207,12 @@ test("Bug 9900 - Color picker has no inputs for HSV values", async ({ const swatch = workspacePage.page.getByRole("button", { name: "E8E9EA" }); await swatch.click(); - const HSVA = await workspacePage.page.getByLabel("HSVA"); - await HSVA.click(); + const HSBA = await workspacePage.page.getByLabel("HSBA"); + await HSBA.click(); await workspacePage.page.getByLabel("H", { exact: true }).isVisible(); await workspacePage.page.getByLabel("S", { exact: true }).isVisible(); - await workspacePage.page.getByLabel("V", { exact: true }).isVisible(); + await workspacePage.page.getByLabel("B(V)", { exact: true }).isVisible(); }); test("Bug 10089 - Cannot change alpha", async ({ page }) => { diff --git a/frontend/playwright/ui/specs/components.spec.js b/frontend/playwright/ui/specs/components.spec.js index 50adc17eae..9661ba9c88 100644 --- a/frontend/playwright/ui/specs/components.spec.js +++ b/frontend/playwright/ui/specs/components.spec.js @@ -3,9 +3,12 @@ import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-token-input"]); }); -test("BUG 13267 - Component instance is not synced with parent for geometry changes", async ({ page }) => { +test("BUG 13267 - Component instance is not synced with parent for geometry changes", async ({ + page, +}) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(page); await workspacePage.mockGetFile("components/get-file-13267.json"); @@ -21,7 +24,9 @@ test("BUG 13267 - Component instance is not synced with parent for geometry chan // Select the main component await workspacePage.clickLeafLayer("A Component", {}, 1); - const rotationInput = workspacePage.rightSidebar.getByTestId("rotation").getByRole("textbox"); + const rotationInput = workspacePage.rightSidebar.getByRole("textbox", { + name: "Rotation", + }); await rotationInput.fill("45"); await rotationInput.press("Enter"); @@ -30,4 +35,4 @@ test("BUG 13267 - Component instance is not synced with parent for geometry chan await workspacePage.clickLeafLayer("Rectangle"); await expect(rotationInput).toHaveValue("45"); -}); \ No newline at end of file +}); diff --git a/frontend/playwright/ui/specs/design-tab.spec.js b/frontend/playwright/ui/specs/design-tab.spec.js index 8fe67b9d3a..0cf953a302 100644 --- a/frontend/playwright/ui/specs/design-tab.spec.js +++ b/frontend/playwright/ui/specs/design-tab.spec.js @@ -1,8 +1,11 @@ import { test, expect } from "@playwright/test"; import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; +const tokenInputFlag = "enable-feature-token-input"; + test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, [tokenInputFlag]); }); const multipleConstraintsFileId = `03bff843-920f-81a1-8004-756365e1eb6a`; @@ -71,7 +74,10 @@ test.describe("Shape attributes", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-feature-render-wasm"]); + await workspace.mockConfigFlags([ + "enable-feature-render-wasm", + tokenInputFlag, + ]); await workspace.setupEmptyFile(); await workspace.mockRPC(/get\-file\?/, "design/get-file-fills-limit.json"); @@ -95,7 +101,10 @@ test.describe("Shape attributes", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-feature-render-wasm"]); + await workspace.mockConfigFlags([ + "enable-feature-render-wasm", + tokenInputFlag, + ]); await workspace.setupEmptyFile(); await workspace.mockRPC( /get\-file\?/, @@ -236,7 +245,7 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-background-blur"]); + await workspace.mockConfigFlags(["enable-background-blur", tokenInputFlag]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); @@ -260,7 +269,7 @@ test.describe("Background blur", () => { page, }) => { const workspace = new WasmWorkspacePage(page); - await workspace.mockConfigFlags(["enable-background-blur"]); + await workspace.mockConfigFlags(["enable-background-blur", tokenInputFlag]); await workspace.setupEmptyFile(); await workspace.mockGetFile("render-wasm/get-file-background-blur.json"); @@ -319,6 +328,7 @@ test("BUG 9543 - Layout padding inputs not showing 'mixed' when needed", async ( page, }) => { const workspace = new WasmWorkspacePage(page); + await workspace.setupEmptyFile(); await workspace.mockRPC(/get\-file\?/, "design/get-file-9543.json"); await workspace.mockRPC( @@ -338,14 +348,18 @@ test("BUG 9543 - Layout padding inputs not showing 'mixed' when needed", async ( }); await toggle.click(); - await workspace.page.getByLabel("Top padding").fill("10"); + const topPaddingInput = workspace.page.getByRole("textbox", { + name: "Top padding", + }); + await topPaddingInput.fill("10"); + await topPaddingInput.press("Enter"); await toggle.click(); - await expect(workspace.page.getByLabel("Vertical padding")).toHaveValue(""); - await expect(workspace.page.getByLabel("Vertical padding")).toHaveAttribute( - "placeholder", - "Mixed", - ); + const verticalPaddingInput = await workspace.page.getByRole("textbox", { + name: "Vertical padding", + }); + await expect(verticalPaddingInput).toHaveValue(""); + await expect(verticalPaddingInput).toHaveAttribute("placeholder", "Mixed"); }); test("BUG 11177 - Font size input not showing 'mixed' when needed", async ({ diff --git a/frontend/playwright/ui/specs/multiseleccion.spec.js b/frontend/playwright/ui/specs/multiseleccion.spec.js index 1b4be19e4c..5d7ee1c92c 100644 --- a/frontend/playwright/ui/specs/multiseleccion.spec.js +++ b/frontend/playwright/ui/specs/multiseleccion.spec.js @@ -3,6 +3,7 @@ import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-token-input"]); }); test("Multiselection - check multiple values in measures", async ({ page }) => { @@ -27,37 +28,53 @@ test("Multiselection - check multiple values in measures", async ({ page }) => { await workspacePage.layers.getByTestId("layer-row").nth(0).click(); // === CHECK SINGLE SELECTION - ALL MEASURE FIELDS === - const measuresSection = workspacePage.rightSidebar.getByRole('region', { name: 'shape-measures-section' }); + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); await expect(measuresSection).toBeVisible(); // Width - const widthInput = measuresSection.getByTitle('Width', { exact: true }).getByRole('textbox'); + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); await expect(widthInput).toHaveValue("360"); // Height - const heightInput = measuresSection.getByTitle('Height', { exact: true }).getByRole('textbox'); + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); await expect(heightInput).toHaveValue("53"); // X Position (using "X axis" title) - const xPosInput = measuresSection.getByTitle('X axis', { exact: true }).getByRole('textbox'); + const xPosInput = measuresSection.getByRole("textbox", { + name: "X axis", + exact: true, + }); await expect(xPosInput).toHaveValue("1094"); // Y Position (using "Y axis" title) - const yPosInput = measuresSection.getByTitle('Y axis', { exact: true }).getByRole('textbox'); + const yPosInput = measuresSection.getByRole("textbox", { + name: "Y axis", + exact: true, + }); await expect(yPosInput).toHaveValue("856"); // === CHECK MULTI-SELECTION - MIXED VALUES === // Shift+click to add second layer to selection - await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] }); + await workspacePage.layers + .getByTestId("layer-row") + .nth(1) + .click({ modifiers: ["Shift"] }); // All measure fields should show "Mixed" placeholder when values differ - await expect(widthInput).toHaveAttribute('placeholder', 'Mixed'); - await expect(heightInput).toHaveAttribute('placeholder', 'Mixed'); - await expect(xPosInput).toHaveAttribute('placeholder', 'Mixed'); - await expect(yPosInput).toHaveAttribute('placeholder', 'Mixed'); + await expect(widthInput).toHaveAttribute("placeholder", "Mixed"); + await expect(heightInput).toHaveAttribute("placeholder", "Mixed"); + await expect(xPosInput).toHaveAttribute("placeholder", "Mixed"); + await expect(yPosInput).toHaveAttribute("placeholder", "Mixed"); }); - test("Multiselection - check fill multiple values", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(page); @@ -79,17 +96,22 @@ test("Multiselection - check fill multiple values", async ({ page }) => { await workspacePage.layers.getByTestId("layer-row").nth(0).click(); // Fill section - const fillSection = workspacePage.rightSidebar.getByRole('region', { name: "Fill section" }); + const fillSection = workspacePage.rightSidebar.getByRole("region", { + name: "Fill section", + }); await expect(fillSection).toBeVisible(); // Single selection - fill color should be visible (not "Mixed") await expect(fillSection.getByText(/Mixed/i)).not.toBeVisible(); // Multi-selection with Shift+click - await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] }); + await workspacePage.layers + .getByTestId("layer-row") + .nth(1) + .click({ modifiers: ["Shift"] }); // Should show "Mixed" for fills when shapes have different fill colors - await expect(fillSection.getByText('Mixed')).toBeVisible(); + await expect(fillSection.getByText("Mixed")).toBeVisible(); }); test("Multiselection - check stroke multiple values", async ({ page }) => { @@ -113,17 +135,22 @@ test("Multiselection - check stroke multiple values", async ({ page }) => { await workspacePage.layers.getByTestId("layer-row").nth(0).click(); // Stroke section - const strokeSection = workspacePage.rightSidebar.getByRole('region', { name: "Stroke section" }); + const strokeSection = workspacePage.rightSidebar.getByRole("region", { + name: "Stroke section", + }); await expect(strokeSection).toBeVisible(); // Single selection - stroke should be visible (not "Mixed") await expect(strokeSection.getByText(/Mixed/i)).not.toBeVisible(); // Multi-selection - await workspacePage.layers.getByTestId("layer-row").nth(1).click({ modifiers: ['Shift'] }); + await workspacePage.layers + .getByTestId("layer-row") + .nth(1) + .click({ modifiers: ["Shift"] }); // Should show "Mixed" for strokes when shapes have different stroke colors - await expect(strokeSection.getByText('Mixed')).toBeVisible(); + await expect(strokeSection.getByText("Mixed")).toBeVisible(); }); test("Multiselection - check rotation multiple values", async ({ page }) => { @@ -147,26 +174,33 @@ test("Multiselection - check rotation multiple values", async ({ page }) => { await workspacePage.layers.getByTestId("layer-row").nth(1).click(); // Measures section contains rotation - const measuresSection = workspacePage.rightSidebar.getByRole('region', { name: 'shape-measures-section' }); + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); await expect(measuresSection).toBeVisible(); // Rotation field exists - const rotationInput = measuresSection.getByTitle('Rotation', { exact: true }).getByRole('textbox'); + const rotationInput = measuresSection.getByRole("textbox", { + name: "Rotation", + exact: true, + }); await expect(rotationInput).toBeVisible(); // Rotate that shape await rotationInput.fill("45"); - await page.keyboard.press('Enter'); + await page.keyboard.press("Enter"); await expect(rotationInput).toHaveValue("45"); // Rotation should be 45 // Multi-selection - await workspacePage.layers.getByTestId("layer-row").nth(0).click({ modifiers: ['Shift'] }); + await workspacePage.layers + .getByTestId("layer-row") + .nth(0) + .click({ modifiers: ["Shift"] }); // Rotation should show "Mixed" placeholder - await expect(rotationInput).toHaveAttribute('placeholder', 'Mixed'); + await expect(rotationInput).toHaveAttribute("placeholder", "Mixed"); }); - test("Multiselection of text and typographies", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(page); @@ -181,29 +215,45 @@ test("Multiselection of text and typographies", async ({ page }) => { }); const plainTextLayer = workspacePage.layers.getByTestId("layer-row").nth(5); - const plainTextLayerTwo = workspacePage.layers.getByTestId("layer-row").nth(2); - const typographyTextLayerOne = workspacePage.layers.getByTestId("layer-row").nth(7); - const typographyTextLayerTwo = workspacePage.layers.getByTestId("layer-row").nth(4); - const tokenTypographyTextLayerOne = workspacePage.layers.getByTestId("layer-row").nth(6); - const tokenTypographyTextLayerTwo = workspacePage.layers.getByTestId("layer-row").nth(3); + const plainTextLayerTwo = workspacePage.layers + .getByTestId("layer-row") + .nth(2); + const typographyTextLayerOne = workspacePage.layers + .getByTestId("layer-row") + .nth(7); + const typographyTextLayerTwo = workspacePage.layers + .getByTestId("layer-row") + .nth(4); + const tokenTypographyTextLayerOne = workspacePage.layers + .getByTestId("layer-row") + .nth(6); + const tokenTypographyTextLayerTwo = workspacePage.layers + .getByTestId("layer-row") + .nth(3); const rectangleLayer = workspacePage.layers.getByTestId("layer-row").nth(1); const elipseLayer = workspacePage.layers.getByTestId("layer-row").nth(0); - const textSection = workspacePage.rightSidebar.getByRole('region', { name: "Text section" }); + const textSection = workspacePage.rightSidebar.getByRole("region", { + name: "Text section", + }); // Select rectangle and elipse together await rectangleLayer.click(); - await elipseLayer.click({ modifiers: ['Control'] }); + await elipseLayer.click({ modifiers: ["Control"] }); await expect(textSection).not.toBeVisible(); - + // Select plain text layer await plainTextLayer.click(); await expect(textSection).toBeVisible(); - await expect(textSection.getByText("Multiple typographies")).not.toBeVisible(); + await expect( + textSection.getByText("Multiple typographies"), + ).not.toBeVisible(); // Select two plain text layer with different font family - await plainTextLayerTwo.click({ modifiers: ['Control'] }); + await plainTextLayerTwo.click({ modifiers: ["Control"] }); await expect(textSection).toBeVisible(); - await expect(textSection.getByTitle("Font family").getByText("--")).toBeVisible(); + await expect( + textSection.getByTitle("Font family").getByText("--"), + ).toBeVisible(); // Select typography text layer await typographyTextLayerOne.click(); @@ -211,48 +261,50 @@ test("Multiselection of text and typographies", async ({ page }) => { await expect(textSection.getByText("Typography one")).toBeVisible(); // Select two typography text layer with different typography - await typographyTextLayerTwo.click({ modifiers: ['Control'] }); + await typographyTextLayerTwo.click({ modifiers: ["Control"] }); await expect(textSection).toBeVisible(); await expect(textSection.getByText("Multiple typographies")).toBeVisible(); - // Select token typography text layer + // Select token typography text layer // TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY await tokenTypographyTextLayerOne.click(); await expect(textSection).toBeVisible(); - await expect(textSection.getByText('Metrophobic')).toBeVisible(); + await expect(textSection.getByText("Metrophobic")).toBeVisible(); // Select two token typography text layer with different token typography - // TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY - await tokenTypographyTextLayerTwo.click({ modifiers: ['Control'] }); + // TODO: CHANGE WHEN TOKEN TYPOGRAPHY ROW IS READY + await tokenTypographyTextLayerTwo.click({ modifiers: ["Control"] }); await expect(textSection).toBeVisible(); - await expect(textSection.getByTitle("Font family").getByText("--")).toBeVisible(); + await expect( + textSection.getByTitle("Font family").getByText("--"), + ).toBeVisible(); //Select plain text layer and typography text layer together await plainTextLayer.click(); - await typographyTextLayerOne.click({ modifiers: ['Control'] }); + await typographyTextLayerOne.click({ modifiers: ["Control"] }); await expect(textSection).toBeVisible(); await expect(textSection.getByText("Multiple typographies")).toBeVisible(); //Select plain text layer and typography text layer together on reverse order await typographyTextLayerOne.click(); - await plainTextLayer.click({ modifiers: ['Control'] }); + await plainTextLayer.click({ modifiers: ["Control"] }); await expect(textSection).toBeVisible(); await expect(textSection.getByText("Multiple typographies")).toBeVisible(); //Selen token typography text layer and typography text layer together await tokenTypographyTextLayerOne.click(); - await typographyTextLayerOne.click({ modifiers: ['Control'] }); + await typographyTextLayerOne.click({ modifiers: ["Control"] }); await expect(textSection).toBeVisible(); await expect(textSection.getByText("Multiple typographies")).toBeVisible(); //Select token typography text layer and typography text layer together on reverse order await typographyTextLayerOne.click(); - await tokenTypographyTextLayerOne.click({ modifiers: ['Control'] }); + await tokenTypographyTextLayerOne.click({ modifiers: ["Control"] }); await expect(textSection).toBeVisible(); await expect(textSection.getByText("Multiple typographies")).toBeVisible(); // Select rectangle and elipse together await rectangleLayer.click(); - await elipseLayer.click({ modifiers: ['Control'] }); + await elipseLayer.click({ modifiers: ["Control"] }); await expect(textSection).not.toBeVisible(); -}); \ No newline at end of file +}); diff --git a/frontend/playwright/ui/specs/profile-menu.spec.js b/frontend/playwright/ui/specs/profile-menu.spec.js index e86a79a826..71bdbb4199 100644 --- a/frontend/playwright/ui/specs/profile-menu.spec.js +++ b/frontend/playwright/ui/specs/profile-menu.spec.js @@ -10,12 +10,16 @@ test("Navigate to penpot changelog from profile menu", async ({ page }) => { await dashboardPage.goToDashboard(); await dashboardPage.openProfileMenu(); - await dashboardPage.clickProfileMenuItem("About Penpot"); + const aboutPenpotItem = page.getByText("About Penpot"); + await aboutPenpotItem.hover(); + + const changelogSubmenuItem = page.getByText("Penpot Changelog"); + await expect(changelogSubmenuItem).toBeVisible(); // Listen for the new page (tab) that opens when clicking "Penpot Changelog" const [newPage] = await Promise.all([ page.context().waitForEvent("page"), - dashboardPage.clickProfileMenuItem("Penpot Changelog"), + changelogSubmenuItem.click(), ]); await newPage.waitForLoadState(); diff --git a/frontend/playwright/ui/specs/tokens/apply.spec.js b/frontend/playwright/ui/specs/tokens/apply.spec.js index 23a25f3669..64f49733ce 100644 --- a/frontend/playwright/ui/specs/tokens/apply.spec.js +++ b/frontend/playwright/ui/specs/tokens/apply.spec.js @@ -4,7 +4,8 @@ import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; import { setupTokensFileRender, setupTypographyTokensFileRender, - unfoldTokenTree, + unfoldTokenType, + createToken, } from "./helpers"; test.beforeEach(async ({ page }) => { @@ -24,10 +25,9 @@ test.describe("Tokens: Apply token", () => { .filter({ hasText: "Button" }) .click(); - const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); - await tokensTabButton.click(); + await page.getByRole("tab", { name: "Tokens" }).click(); - unfoldTokenTree(tokensSidebar, "color", "colors.black"); + await unfoldTokenType(tokensSidebar, "color"); await tokensSidebar .getByRole("button", { name: "black" }) @@ -52,17 +52,15 @@ test.describe("Tokens: Apply token", () => { await workspacePage.layers.getByTestId("layer-row").nth(1).click(); // Open tokens sections on left sidebar - const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); - await tokensTabButton.click(); - // Unfold border radius tokens - await page.getByRole("button", { name: "Border Radius 3" }).click(); + await page.getByRole("tab", { name: "Tokens" }).click(); + + await unfoldTokenType(tokensSidebar, "border radius"); await expect( - tokensSidebar.getByRole("button", { name: "borderRadius" }), - ).toBeVisible(); - await tokensSidebar.getByRole("button", { name: "borderRadius" }).click(); - await expect( - tokensSidebar.getByRole("button", { name: "borderRadius.sm" }), + tokensSidebar.getByRole("button", { + name: "borderRadius.sm", + exact: true, + }), ).toBeVisible(); // Apply border radius token from token panels @@ -72,7 +70,7 @@ test.describe("Tokens: Apply token", () => { // Check if border radius sections is visible on right sidebar const borderRadiusSection = page.getByRole("region", { - name: "border-radius-section", + name: "Border radius section", }); await expect(borderRadiusSection).toBeVisible(); @@ -84,8 +82,9 @@ test.describe("Tokens: Apply token", () => { await brTokenPillSM.click(); // Change token from dropdown - const brTokenOptionXl = borderRadiusSection - .getByRole("option", { name: "borderRadius.xl" }); + const brTokenOptionXl = borderRadiusSection.getByRole("option", { + name: "borderRadius.xl", + }); await expect(brTokenOptionXl).toBeVisible(); await brTokenOptionXl.click(); @@ -118,13 +117,7 @@ test.describe("Tokens: Apply token", () => { await tokensTabButton.click(); // Unfold opacity tokens - await page.getByRole("button", { name: "Opacity 3" }).click(); - await expect( - tokensSidebar.getByRole("button", { name: "opacity", exact: true }), - ).toBeVisible(); - await tokensSidebar - .getByRole("button", { name: "opacity", exact: true }) - .click(); + await unfoldTokenType(tokensSidebar, "opacity"); await expect( tokensSidebar.getByRole("button", { name: "opacity.high" }), ).toBeVisible(); @@ -134,7 +127,7 @@ test.describe("Tokens: Apply token", () => { // Check if opacity sections is visible on right sidebar const layerMenuSection = page.getByRole("region", { - name: "layer-menu-section", + name: "Layer menu section", }); await expect(layerMenuSection).toBeVisible(); @@ -151,7 +144,9 @@ test.describe("Tokens: Apply token", () => { await detachButton.click(); // Open dropdown from input - const dropdownBtn = layerMenuSection.getByRole('button', { name: 'Open token list' }) + const dropdownBtn = layerMenuSection.getByRole("button", { + name: "Open token list", + }); await expect(dropdownBtn).toBeVisible(); await dropdownBtn.click(); @@ -200,12 +195,8 @@ test.describe("Tokens: Apply token", () => { test("User adds shadow token with multiple shadows and applies it to shape", async ({ page, }) => { - const { - tokensUpdateCreateModal, - tokensSidebar, - workspacePage, - tokenContextMenuForToken, - } = await setupTokensFileRender(page, { flags: ["enable-token-shadow"] }); + const { tokensUpdateCreateModal, tokensSidebar, workspacePage } = + await setupTokensFileRender(page, { flags: ["enable-token-shadow"] }); const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -227,8 +218,12 @@ test.describe("Tokens: Apply token", () => { await expect(firstShadowFields).toBeVisible(); // Fill in the shadow values - const offsetXInput = firstShadowFields.getByRole('textbox', { name: 'X' }); - const offsetYInput = firstShadowFields.getByRole('textbox', { name: 'Y' }); + const offsetXInput = firstShadowFields.getByRole("textbox", { + name: "X", + }); + const offsetYInput = firstShadowFields.getByRole("textbox", { + name: "Y", + }); const blurInput = firstShadowFields.getByRole("textbox", { name: "Blur", }); @@ -301,8 +296,12 @@ test.describe("Tokens: Apply token", () => { await expect(thirdShadowFields).toBeVisible(); // User adds values for the third shadow - const thirdOffsetXInput = thirdShadowFields.getByRole('textbox', { name: 'X' }); - const thirdOffsetYInput = thirdShadowFields.getByRole('textbox', { name: 'Y' }); + const thirdOffsetXInput = thirdShadowFields.getByRole("textbox", { + name: "X", + }); + const thirdOffsetYInput = thirdShadowFields.getByRole("textbox", { + name: "Y", + }); const thirdBlurInput = thirdShadowFields.getByRole("textbox", { name: "Blur", }); @@ -330,10 +329,10 @@ test.describe("Tokens: Apply token", () => { // Verify that the first shadow kept its values const firstOffsetXValue = await firstShadowFields - .getByRole('textbox', { name: 'X' }) + .getByRole("textbox", { name: "X" }) .inputValue(); const firstOffsetYValue = await firstShadowFields - .getByRole('textbox', { name: 'Y' }) + .getByRole("textbox", { name: "Y" }) .inputValue(); const firstBlurValue = await firstShadowFields .getByRole("textbox", { name: "Blur" }) @@ -359,10 +358,10 @@ test.describe("Tokens: Apply token", () => { await expect(newSecondShadowFields).toBeVisible(); const secondOffsetXValue = await newSecondShadowFields - .getByRole('textbox', { name: 'X' }) + .getByRole("textbox", { name: "X" }) .inputValue(); const secondOffsetYValue = await newSecondShadowFields - .getByRole('textbox', { name: 'Y' }) + .getByRole("textbox", { name: "Y" }) .inputValue(); const secondBlurValue = await newSecondShadowFields .getByRole("textbox", { name: "Blur" }) @@ -412,10 +411,10 @@ test.describe("Tokens: Apply token", () => { // Verify first shadow values are still there const restoredFirstOffsetX = await firstShadowFields - .getByRole('textbox', { name: 'X' }) + .getByRole("textbox", { name: "X" }) .inputValue(); const restoredFirstOffsetY = await firstShadowFields - .getByRole('textbox', { name: 'Y' }) + .getByRole("textbox", { name: "Y" }) .inputValue(); const restoredFirstBlur = await firstShadowFields .getByRole("textbox", { name: "Blur" }) @@ -435,10 +434,10 @@ test.describe("Tokens: Apply token", () => { // Verify second shadow values are still there const restoredSecondOffsetX = await newSecondShadowFields - .getByRole('textbox', { name: 'X' }) + .getByRole("textbox", { name: "X" }) .inputValue(); const restoredSecondOffsetY = await newSecondShadowFields - .getByRole('textbox', { name: 'Y' }) + .getByRole("textbox", { name: "Y" }) .inputValue(); const restoredSecondBlur = await newSecondShadowFields .getByRole("textbox", { name: "Blur" }) @@ -465,8 +464,6 @@ test.describe("Tokens: Apply token", () => { await submitButton.click(); await expect(tokensUpdateCreateModal).not.toBeVisible(); - unfoldTokenTree(tokensSidebar, "shadow", "primary"); - // Verify token appears in sidebar const shadowToken = tokensSidebar.getByRole("button", { name: "primary", @@ -501,7 +498,7 @@ test.describe("Tokens: Apply token", () => { const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); await tokensTabButton.click(); - unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.sm"); + await unfoldTokenType(tokensSidebar, "dimensions"); // Apply token to width and height token from token panel await tokensSidebar.getByRole("button", { name: "dimension.sm" }).click(); @@ -554,7 +551,7 @@ test.describe("Tokens: Apply token", () => { const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); await tokensTabButton.click(); - unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.sm"); + await unfoldTokenType(tokensSidebar, "dimensions"); // Apply token to width and height token from token panel await tokensSidebar @@ -610,13 +607,13 @@ test.describe("Tokens: Apply token", () => { const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); await tokensTabButton.click(); - unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.sm"); + await unfoldTokenType(tokensSidebar, "dimensions"); // Apply token to width and height token from token panel await tokensSidebar .getByRole("button", { name: "dimension.sm" }) .click({ button: "right" }); - await tokenContextMenuForToken.getByText("Y").click(); + await tokenContextMenuForToken.getByText("Y", { exact: true }).click(); // Check if measures sections is visible on right sidebar const measuresSection = page.getByRole("region", { @@ -666,7 +663,7 @@ test.describe("Tokens: Apply token", () => { const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); await tokensTabButton.click(); - unfoldTokenTree(tokensSidebar, "dimensions", "dimension.dimension.xs"); + await unfoldTokenType(tokensSidebar, "dimensions"); // Apply token to width and height token from token panel await tokensSidebar @@ -677,7 +674,7 @@ test.describe("Tokens: Apply token", () => { // Check if border radius sections is visible on right sidebar const borderRadiusSection = page.getByRole("region", { - name: "border-radius-section", + name: "Border radius section", }); await expect(borderRadiusSection).toBeVisible(); @@ -798,8 +795,7 @@ test.describe("Tokens: Apply token", () => { const tokensTab = page.getByRole("tab", { name: "Tokens" }); await expect(tokensTab).toBeVisible(); await tokensTab.click(); - await page.getByRole("button", { name: "Dimensions 4" }).click(); - await page.getByRole("button", { name: "dim", exact: true }).click(); + await unfoldTokenType(workspace.tokensSidebar, "dimensions"); const tokensSidebar = workspace.tokensSidebar; await expect( tokensSidebar.getByRole("button", { name: "dim.md" }), @@ -818,7 +814,7 @@ test.describe("Tokens: Apply token", () => { // Check if token pill is visible on right sidebar const layoutItemSectionSidebar = rightSidebar.getByRole("region", { - name: "layout item menu", + name: "Layout item section", }); await expect(layoutItemSectionSidebar).toBeVisible(); const marginPillMd = layoutItemSectionSidebar.getByRole("button", { @@ -870,11 +866,7 @@ test.describe("Tokens: Detach token", () => { await tokensTabButton.click(); // Unfold border radius tokens - await page.getByRole("button", { name: "Border Radius 3" }).click(); - await expect( - tokensSidebar.getByRole("button", { name: "borderRadius" }), - ).toBeVisible(); - await tokensSidebar.getByRole("button", { name: "borderRadius" }).click(); + await unfoldTokenType(tokensSidebar, "Border Radius"); await expect( tokensSidebar.getByRole("button", { name: "borderRadius.sm" }), ).toBeVisible(); @@ -886,7 +878,7 @@ test.describe("Tokens: Detach token", () => { // Check if border radius sections is visible on right sidebar const borderRadiusSection = page.getByRole("region", { - name: "border-radius-section", + name: "Border radius section", }); await expect(borderRadiusSection).toBeVisible(); @@ -936,3 +928,497 @@ test.describe("Tokens: Detach token", () => { await expect(brokenPill).not.toBeVisible(); }); }); + +test("Bug: 13959, User select shapes with different hidden state.", async ({ + page, +}) => { + const { workspacePage } = await setupTokensFileRender(page); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + const layerMenuSection = page.getByRole("region", { + name: "Layer menu section", + }); + await expect(layerMenuSection).toBeVisible(); + await layerMenuSection + .getByRole("button", { name: "Toggle layer visibility" }) + .click(); + await expect(layerMenuSection).toBeVisible(); + await workspacePage.layers + .getByTestId("layer-row") + .nth(0) + .click({ modifiers: ["Shift"] }); + await expect(layerMenuSection).toBeVisible(); +}); + +test("Bug: 13960, User select shapes with different opacity and input show mixed state.", async ({ + page, +}) => { + const { workspacePage } = await setupTokensFileRender(page); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + const layerMenuSection = page.getByRole("region", { + name: "Layer menu section", + }); + await expect(layerMenuSection).toBeVisible(); + await layerMenuSection.getByRole("textbox", { name: "Opacity" }).fill("50"); + await expect(layerMenuSection).toBeVisible(); + await workspacePage.layers + .getByTestId("layer-row") + .nth(0) + .click({ modifiers: ["Shift"] }); + await expect( + layerMenuSection.getByRole("textbox", { name: "Opacity" }), + ).toBeVisible(); + await expect( + layerMenuSection.getByRole("textbox", { name: "Opacity" }), + ).toBeVisible(); + + await expect( + layerMenuSection.getByRole("textbox", { name: "Opacity" }), + ).toHaveAttribute("placeholder", "Mixed"); +}); + +test("BUG: 13930, Token colors are shown on selected colors section", async ({ + page, +}) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers + .getByTestId("layer-row") + .filter({ hasText: "Button" }) + .click(); + + await page.getByRole("tab", { name: "Tokens" }).click(); + + await unfoldTokenType(tokensSidebar, "color"); + + await tokensSidebar + .getByRole("button", { name: "black" }) + .click({ button: "right" }); + await tokenContextMenuForToken.getByText("Fill").click(); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers + .getByTestId("layer-row") + .filter({ hasText: "Rectangle" }) + .first() + .click({ modifiers: ["Shift"] }); + + await expect( + workspacePage.page.getByRole("region", { name: "Color selection section" }), + ).toBeVisible(); + + await workspacePage.page + .getByRole("button", { name: "Resolved value: #7f9cf5" }) + .click(); + await expect( + workspacePage.page.getByRole("region", { name: "Color selection section" }), + ).toBeVisible(); + + await expect( + workspacePage.page + .getByTestId("colorpicker") + .getByRole("button", { name: "colors.black" }), + ).toBeVisible(); +}); + +test.describe("Numeric Input and Token Integration Tests", () => { + test("Token pill persists after blur in gap inputs", async ({ page }) => { + // Setup the workspace with token features enabled + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Transform a rectangle into a flex container to expose gap properties + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + await addLayoutButton.click(); + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Column gap").click(); + + // Verify that the token pill appears in the layout section, check after blur + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + + await page + .getByTestId("inspect-layout") + .getByRole("textbox", { name: "Vertical padding" }) + .click(); + + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + }); + + test("Padding tokens are applied to both vertical or horizontal properties", async ({ + page, + }) => { + // Setup the workspace with token features enabled + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Transform a rectangle into a flex container to expose gap properties + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + await addLayoutButton.click(); + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Horizontal").click(); + + // Verify that the token pill appears in the layout section, check after blur + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + + await layoutSection + .getByRole("button", { name: "Show 4 sided padding options" }) + .click(); + + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toHaveCount(2); + + await layoutSection + .getByRole("button", { name: "Show 4 sided padding options" }) + .click(); + + await expect( + page + .getByTestId("inspect-layout") + .getByRole("button", { name: "spacing.lg" }), + ).toBeVisible(); + }); + + test("Token pill persists after blur in min/max width inputs", async ({ + page, + }) => { + // Setup the workspace with token features enabled + const { workspacePage } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + // Create a flex container to expose min/max width properties + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(2).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + await addLayoutButton.click(); + await page.getByText("Flex layout").click(); + + // Verify that the flex container (Flex board) is created + await expect( + page.getByRole("button", { name: "Flex board" }), + ).toBeVisible(); + + // Select element inside flex container to access to layout constrains inputs + // Apply token to min width property + await workspacePage.layers + .getByTestId("layer-row") + .nth(2) + .getByTestId("toggle-content") + .click(); + + await workspacePage.layers.getByTestId("layer-row").nth(3).click(); + + const layoutItemSection = page.getByRole("region", { + name: "Layout item section", + }); + + await layoutItemSection.getByTestId("behaviour-h-fill").click(); + + const constraintsSection = layoutItemSection.getByRole("region", { + name: "layout item size constraints", + }); + await expect(constraintsSection).toBeVisible(); + + await constraintsSection + .getByRole("button", { name: "Open token list" }) + .nth(0) + .click(); + + await expect( + page.getByRole("option", { name: "dimension.md" }), + ).toBeVisible(); + await page.getByRole("option", { name: "dimension.md" }).click(); + + await expect( + constraintsSection.getByRole("button", { name: "dimension.md" }), + ).toBeVisible(); + + // Focus another input (Max width) to trigger blur and check if token pill persists + await constraintsSection + .getByRole("textbox", { name: "Max width" }) + .click(); + + await expect( + constraintsSection.getByRole("button", { name: "dimension.md" }), + ).toBeVisible(); + }); + + test("Invalid formula reverts to previous value in padding inputs", async ({ + page, + }) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + + await addLayoutButton.click(); + + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Column gap").click(); + + const verticalPaddingInput = layoutSection.getByRole("textbox", { + name: "Vertical padding", + }); + + // Enter a valid value first + await verticalPaddingInput.fill("23"); + await verticalPaddingInput.press("Enter"); + // Wait for potential error handling + await page.waitForTimeout(500); + + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Enter invalid expression + await verticalPaddingInput.fill("abc+1"); + await verticalPaddingInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + + // Value should revert to previous valid value + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Should NOT contain invalid characters + expect(await verticalPaddingInput.inputValue()).not.toContain("abc"); + }); + + test("Division by zero reverts to previous value", async ({ page }) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const layoutSection = + workspacePage.rightSidebar.getByTestId("inspect-layout"); + + const addLayoutButton = layoutSection + .getByRole("button", { name: "Add layout" }) + .first(); + + await addLayoutButton.click(); + + await page.getByText("Flex layout").click(); + + // Apply a spacing token to the Column gap property + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "spacing"); + + await tokensSidebar + .getByRole("button", { name: "spacing.lg" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("Column gap").click(); + + const verticalPaddingInput = layoutSection.getByRole("textbox", { + name: "Vertical padding", + }); + + // Enter a valid value first + await verticalPaddingInput.fill("23"); + await verticalPaddingInput.press("Enter"); + // Wait for potential error handling + await page.waitForTimeout(500); + + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Enter invalid expression + await verticalPaddingInput.fill("10/0"); + await verticalPaddingInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + + // Value should revert to previous valid value + expect(await verticalPaddingInput.inputValue()).toMatch("23"); + + // Should NOT contain invalid characters + expect(await verticalPaddingInput.inputValue()).not.toContain("10/0"); + + // Value should revert + expect(await verticalPaddingInput.inputValue()).toMatch(/^(\d+|--)$/); + expect(await verticalPaddingInput.inputValue()).not.toBe("Infinity"); + }); + + test("Negative expression result handled correctly", async ({ page }) => { + const { workspacePage, tokensSidebar, tokenContextMenuForToken } = + await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + const widthInput = workspacePage.rightSidebar.getByRole("textbox", { + name: "Width", + }); + await expect(widthInput).toBeVisible(); + + // Enter a valid value first + await widthInput.fill("23"); + await widthInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + expect(await widthInput.inputValue()).toMatch("23"); + + // Enter a negative expression + await widthInput.fill("10-50"); + await widthInput.press("Enter"); + + // Wait for potential error handling + await page.waitForTimeout(500); + + expect(await widthInput.inputValue()).toMatch("0.01"); + + // Should NOT negative values + expect(await widthInput.inputValue()).not.toContain("-40"); + }); + + test("Token pill show broken reference when set is not activated", async ({ + page, + }) => { + // Setup the workspace with token features enabled + const { + workspacePage, + tokensSidebar, + tokenContextMenuForToken, + tokenThemesSetsSidebar, + } = await setupTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); + // Create a token with a reference value in other set. + await createToken(page, "Dimensions", "reference-token", "Value", "{card.padding}"); + + + // Apply this token to a shape + await page.getByRole("tab", { name: "Layers" }).click(); + + await workspacePage.layers.getByTestId("layer-row").nth(1).click(); + + const tokensTabButton = page.getByRole("tab", { name: "Tokens" }); + await tokensTabButton.click(); + await unfoldTokenType(tokensSidebar, "dimensions"); + + await tokensSidebar + .getByRole("button", { name: "reference-token" }) + .click({ button: "right" }); + + await tokenContextMenuForToken.getByText("X", { exact: true }).click(); + + //Check if token is applied and visible on right sidebar + const measuresSection = page.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + await expect(measuresSection.getByRole('button', { name: 'reference-token' })).toBeVisible(); + + // Deactivate token set where reference token exist to make token broken + await tokenThemesSetsSidebar.getByRole('button', { name: 'theme' }).getByRole('checkbox').click(); + + // Check if token pill show broken reference state + const brokenPill = measuresSection.getByRole("button", { + name: "is not in any active set", + }); + await expect(brokenPill).toHaveCount(2); + }); +}); diff --git a/frontend/playwright/ui/specs/tokens/crud.spec.js b/frontend/playwright/ui/specs/tokens/crud.spec.js index ac72a2cc7c..4bfd1c6a4b 100644 --- a/frontend/playwright/ui/specs/tokens/crud.spec.js +++ b/frontend/playwright/ui/specs/tokens/crud.spec.js @@ -6,7 +6,8 @@ import { setupTokensFileRender, setupTypographyTokensFileRender, testTokenCreationFlow, - unfoldTokenTree, + unfoldTokenType, + createToken, } from "./helpers"; test.beforeEach(async ({ page }) => { @@ -31,15 +32,9 @@ test.describe("Tokens - creation", () => { }); test("User creates border radius token with combobox", async ({ page }) => { - const invalidValueError = "Invalid token value"; - const emptyNameError = "Name should be at least 1 character"; - const selfReferenceError = "Token has self reference"; - const missingReferenceError = "Missing token references"; - - const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = - await setupEmptyTokensFileRender(page, { - flags: ["enable-token-combobox", "enable-feature-token-input"], - }); + const { tokensUpdateCreateModal } = await setupEmptyTokensFileRender(page, { + flags: ["enable-token-combobox", "enable-feature-token-input"], + }); // Open modal const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -83,8 +78,10 @@ test.describe("Tokens - creation", () => { await submitButton.click(); + await unfoldTokenType(tokensTabPanel, "border radius"); + await expect( - tokensTabPanel.getByRole('button', { name: 'my-token' }), + tokensTabPanel.getByRole("button", { name: "my-token" }), ).toBeEnabled(); // Create second token referencing the first one using the combobox options @@ -310,7 +307,7 @@ test.describe("Tokens - creation", () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); - await unfoldTokenTree(tokensSidebar, "color", "color.primary"); + await unfoldTokenType(tokensSidebar, "color"); // Create token referencing the previous one with keyboard @@ -477,6 +474,8 @@ test.describe("Tokens - creation", () => { await submitButton.click(); + await unfoldTokenType(tokensTabPanel, "font family"); + await expect( tokensTabPanel.getByRole("button", { name: "my-token" }), ).toBeEnabled(); @@ -631,6 +630,8 @@ test.describe("Tokens - creation", () => { await submitButton.click(); + await unfoldTokenType(tokensTabPanel, "font weight"); + await expect( tokensTabPanel.getByRole("button", { name: "my-token" }), ).toBeEnabled(); @@ -767,6 +768,8 @@ test.describe("Tokens - creation", () => { await submitButton.click(); + await unfoldTokenType(tokensTabPanel, "text case"); + await expect( tokensTabPanel.getByRole("button", { name: "my-token" }), ).toBeEnabled(); @@ -885,6 +888,8 @@ test.describe("Tokens - creation", () => { await submitButton.click(); + await unfoldTokenType(tokensTabPanel, "text decoration"); + await expect( tokensTabPanel.getByRole("button", { name: "my-token" }), ).toBeEnabled(); @@ -914,7 +919,9 @@ test.describe("Tokens - creation", () => { const emptyNameError = "Name should be at least 1 character"; const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = - await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] }); + await setupEmptyTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); // Open modal const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -1049,6 +1056,8 @@ test.describe("Tokens - creation", () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); + await unfoldTokenType(tokensTabPanel, "shadow"); + await expect( tokensTabPanel.getByRole("button", { name: "my-token" }), ).toBeEnabled(); @@ -1086,6 +1095,8 @@ test.describe("Tokens - creation", () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); + + await unfoldTokenType(tokensTabPanel, "shadow"); await expect( tokensTabPanel.getByRole("button", { name: "my-token-2" }), ).toBeEnabled(); @@ -1107,7 +1118,9 @@ test.describe("Tokens - creation", () => { const nameField = tokensUpdateCreateModal.getByLabel("Name"); await nameField.fill("typography.empty"); - const valueField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Size" }); + const valueField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Font Size", + }); // Insert a value and then delete it await valueField.fill("1"); @@ -1130,7 +1143,9 @@ test.describe("Tokens - creation", () => { const emptyNameError = "Name should be at least 1 character"; const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = - await setupEmptyTokensFileRender(page, { flags: ["enable-token-shadow"] }); + await setupEmptyTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); // Open modal const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -1270,6 +1285,8 @@ test.describe("Tokens - creation", () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); + await unfoldTokenType(tokensTabPanel, "shadow"); + await expect( tokensTabPanel.getByRole("button", { name: "my-token" }), ).toBeEnabled(); @@ -1576,7 +1593,8 @@ test.describe("Tokens - creation", () => { const nameField = tokensUpdateCreateModal.getByLabel("Name"); await nameField.fill(newTokenTitle); - const referenceTabButton = tokensUpdateCreateModal.getByTestId("reference-opt"); + const referenceTabButton = + tokensUpdateCreateModal.getByTestId("reference-opt"); await referenceTabButton.click(); const referenceField = tokensUpdateCreateModal.getByRole("textbox", { @@ -1637,7 +1655,7 @@ test.describe("Tokens - creation", () => { await expect(submitButton).toBeEnabled(); await submitButton.click(); - await unfoldTokenTree(tokensSidebar, "color", "dark.primary"); + await unfoldTokenType(tokensSidebar, "color"); await expect(tokensSidebar.getByLabel("primary")).toBeEnabled(); }); @@ -1676,10 +1694,10 @@ test.describe("Tokens - creation", () => { await expect(tokensSidebar).toBeVisible(); - unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); + await unfoldTokenType(tokensSidebar, "color"); const colorToken = tokensSidebar.getByRole("button", { - name: "100", + name: "colors.blue.100", }); await colorToken.click({ button: "right" }); @@ -1719,7 +1737,7 @@ test("User creates grouped color token", async ({ page }) => { await expect(submitButton).toBeEnabled(); await submitButton.click(); - await unfoldTokenTree(tokensSidebar, "color", "dark.primary"); + await unfoldTokenType(tokensSidebar, "color"); await expect(tokensSidebar.getByLabel("primary")).toBeEnabled(); }); @@ -1756,10 +1774,10 @@ test("User duplicate color token", async ({ page }) => { await expect(tokensSidebar).toBeVisible(); - unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); + await unfoldTokenType(tokensSidebar, "color"); const colorToken = tokensSidebar.getByRole("button", { - name: "100", + name: "colors.blue.100", }); await colorToken.click({ button: "right" }); @@ -1773,6 +1791,27 @@ test("User duplicate color token", async ({ page }) => { ).toBeVisible(); }); +test("User disables the current set but token still have resolved values shown in the sidebar", async ({ + page, +}) => { + const { tokenThemesSetsSidebar, tokensSidebar } = await setupEmptyTokensFileRender(page); + + // Create color token + await createToken(page, "Color", "color.primary", "Value", "#ff0000"); + await unfoldTokenType(tokensSidebar, "color"); + + // Deactivate current set + await tokenThemesSetsSidebar + .getByRole("checkbox") + .click(); + + // Tokens tab panel should have a token with the color #ff0000 and correct resolved value in the tooltip + const colorTokenPill = tokensSidebar.getByRole("button", { name: "#ff0000 color.primary" }); + await expect(colorTokenPill).toHaveCount(1); + await colorTokenPill.hover(); // Force title attribute to be attached to the button + await expect(colorTokenPill).toHaveAttribute("title", /Resolved value: #ff0000/); +}); + test.describe("Tokens tab - edition", () => { test("User edits typography token and all fields are valid", async ({ page, @@ -1804,7 +1843,9 @@ test.describe("Tokens tab - edition", () => { await fontFamilyField.fill("OneWord"); // Invalidate incorrect values for font size - const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Size" }); + const fontSizeField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Font Size", + }); await fontSizeField.fill("invalid"); await expect( tokensUpdateCreateModal.getByText(/Invalid token value:/), @@ -1819,13 +1860,21 @@ test.describe("Tokens tab - edition", () => { await fontSizeField.fill("16"); await expect(saveButton).toBeEnabled(); - const fontWeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Font Weight" }); - const letterSpacingField = - tokensUpdateCreateModal.getByRole("textbox", { name: "Letter Spacing" }); - const lineHeightField = tokensUpdateCreateModal.getByRole("textbox", { name: "Line Height" }); - const textCaseField = tokensUpdateCreateModal.getByRole("textbox", { name: "Text Case" }); - const textDecorationField = - tokensUpdateCreateModal.getByRole("textbox", { name: "Text Decoration" }); + const fontWeightField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Font Weight", + }); + const letterSpacingField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Letter Spacing", + }); + const lineHeightField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Line Height", + }); + const textCaseField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Text Case", + }); + const textDecorationField = tokensUpdateCreateModal.getByRole("textbox", { + name: "Text Decoration", + }); // Capture all values before switching tabs const originalValues = { @@ -1878,10 +1927,10 @@ test.describe("Tokens tab - edition", () => { await expect(tokensSidebar).toBeVisible(); - await unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); + await unfoldTokenType(tokensSidebar, "color"); const colorToken = tokensSidebar.getByRole("button", { - name: "100", + name: "colors.blue.100", }); await expect(colorToken).toBeVisible(); @@ -1899,7 +1948,7 @@ test.describe("Tokens tab - edition", () => { await expect(tokensUpdateCreateModal).not.toBeVisible(); - await unfoldTokenTree(tokensSidebar, "color", "colors.blue.100.changed"); + await unfoldTokenType(tokensSidebar, "color"); const colorTokenChanged = tokensSidebar.getByRole("button", { name: "changed", @@ -1970,10 +2019,10 @@ test.describe("Tokens tab - delete", () => { await expect(tokensSidebar).toBeVisible(); - unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); + await unfoldTokenType(tokensSidebar, "color"); const colorToken = tokensSidebar.getByRole("button", { - name: "100", + name: "colors.blue.100", }); await expect(colorToken).toBeVisible(); await colorToken.click({ button: "right" }); @@ -1984,48 +2033,4 @@ test.describe("Tokens tab - delete", () => { await expect(tokenContextMenuForToken).not.toBeVisible(); await expect(colorToken).not.toBeVisible(); }); - - test("User removes node and all child tokens", async ({ page }) => { - const { tokensSidebar } = await setupTokensFileRender(page); - - await expect(tokensSidebar).toBeVisible(); - - // Expand color tokens - unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); - - // Verify that the node and child token are visible before deletion - const colorNode = tokensSidebar.getByRole("button", { - name: "colors", - exact: true, - }); - const colorNodeToken = tokensSidebar.getByRole("button", { - name: "100", - }); - - // Select a node and right click on it to open context menu - await expect(colorNode).toBeVisible(); - await expect(colorNodeToken).toBeVisible(); - await colorNode.click({ button: "right" }); - - // select "Delete" from the context menu - const deleteNodeButton = page.getByRole("button", { - name: "Delete", - exact: true, - }); - await expect(deleteNodeButton).toBeVisible(); - await deleteNodeButton.click(); - - // Verify that the node is removed - await expect(colorNode).not.toBeVisible(); - // Verify that child token is also removed - await expect(colorNodeToken).not.toBeVisible(); - - // Save the type button to verify that expands/folds - const tokenTypeButton = await tokensSidebar.getByRole("button", { - name: "Color", - exact: true, - }); - - await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false"); - }); }); diff --git a/frontend/playwright/ui/specs/tokens/helpers.js b/frontend/playwright/ui/specs/tokens/helpers.js index 63c54af0f9..937a268242 100644 --- a/frontend/playwright/ui/specs/tokens/helpers.js +++ b/frontend/playwright/ui/specs/tokens/helpers.js @@ -161,6 +161,7 @@ const setupTokensFileRender = async (page, options = {}) => { workspacePage, tokensUpdateCreateModal: workspacePage.tokensUpdateCreateModal, tokenThemeUpdateCreateModal: workspacePage.tokenThemeUpdateCreateModal, + tokensRenameNodeModal: workspacePage.tokensRenameNodeModal, tokenThemesSetsSidebar: workspacePage.tokenThemesSetsSidebar, tokenSetItems: workspacePage.tokenSetItems, tokenSetGroupItems: workspacePage.tokenSetGroupItems, @@ -206,7 +207,7 @@ const testTokenCreationFlow = async ( const selfReferenceError = "Token has self reference"; const missingReferenceError = "Missing token references"; - const { tokensUpdateCreateModal, tokenThemesSetsSidebar } = + const { tokensUpdateCreateModal, tokensSidebar } = await setupEmptyTokensFileRender(page); // Open modal @@ -312,12 +313,11 @@ const testTokenCreationFlow = async ( ).toBeEnabled(); }; -const unfoldTokenTree = async (tokensTabPanel, type, tokenName) => { - const tokenSegments = tokenName.split("."); - const tokenFolderTree = tokenSegments.slice(0, -1); - const tokenLeafName = tokenSegments.pop(); - - const typeParentWrapper = tokensTabPanel.getByTestId(`section-${type}`); +const unfoldTokenType = async (tokensTabPanel, type) => { + const kebabClaseType = type.toLocaleLowerCase().replace(/\s/g, "-"); + const typeParentWrapper = tokensTabPanel.getByTestId( + `section-${kebabClaseType}`, + ); const typeSectionButton = typeParentWrapper .getByRole("button", { name: type, @@ -330,24 +330,34 @@ const unfoldTokenTree = async (tokensTabPanel, type, tokenName) => { if (isSectionExpanded === "false") { await typeSectionButton.click(); } +}; - for (const segment of tokenFolderTree) { - const segmentButton = typeParentWrapper - .getByRole("listitem") - .getByRole("button", { name: segment }) - .first(); +const createToken = async (page, type, name, textFieldName, value) => { + const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); - const isExpanded = await segmentButton.getAttribute("aria-expanded"); - if (isExpanded === "false") { - await segmentButton.click(); - } - } + const { tokensUpdateCreateModal } = await setupTokensFileRender(page, { + flags: ["enable-token-shadow"], + }); - await expect( - typeParentWrapper.getByRole("button", { - name: tokenLeafName, - }), - ).toBeEnabled(); + // Create base token + await tokensTabPanel + .getByRole("button", { name: `Add Token: ${type}` }) + .click(); + await expect(tokensUpdateCreateModal).toBeVisible(); + + const nameField = tokensUpdateCreateModal.getByLabel("Name"); + await nameField.fill(name); + + const colorField = tokensUpdateCreateModal.getByRole("textbox", { + name: textFieldName, + }); + await colorField.fill(value); + + const submitButton = tokensUpdateCreateModal.getByRole("button", { + name: "Save", + }); + await submitButton.click(); + await expect(tokensUpdateCreateModal).not.toBeVisible(); }; export { @@ -358,5 +368,6 @@ export { setupTypographyTokensFile, setupTypographyTokensFileRender, testTokenCreationFlow, - unfoldTokenTree, + unfoldTokenType, + createToken, }; diff --git a/frontend/playwright/ui/specs/tokens/remapping.spec.js b/frontend/playwright/ui/specs/tokens/remapping.spec.js index 4563b491b3..55472cb4a1 100644 --- a/frontend/playwright/ui/specs/tokens/remapping.spec.js +++ b/frontend/playwright/ui/specs/tokens/remapping.spec.js @@ -2,6 +2,7 @@ import { test, expect } from "@playwright/test"; import { WorkspacePage } from "../../pages/WorkspacePage"; import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; import { + createToken, setupTokensFileRender, setupTypographyTokensFileRender, } from "./helpers"; @@ -14,34 +15,6 @@ test.beforeEach(async ({ page }) => { await WasmWorkspacePage.mockRPC(page, "get-teams", "get-teams-tokens.json"); }); -const createToken = async (page, type, name, textFieldName, value) => { - const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); - - const { tokensUpdateCreateModal } = await setupTokensFileRender(page, { - flags: ["enable-token-shadow"], - }); - - // Create base token - await tokensTabPanel - .getByRole("button", { name: `Add Token: ${type}` }) - .click(); - await expect(tokensUpdateCreateModal).toBeVisible(); - - const nameField = tokensUpdateCreateModal.getByLabel("Name"); - await nameField.fill(name); - - const colorField = tokensUpdateCreateModal.getByRole("textbox", { - name: textFieldName, - }); - await colorField.fill(value); - - const submitButton = tokensUpdateCreateModal.getByRole("button", { - name: "Save", - }); - await submitButton.click(); - await expect(tokensUpdateCreateModal).not.toBeVisible(); -}; - const createTokenCombobox = async (page, type, name, textFieldName, value) => { const tokensTabPanel = page.getByRole("tabpanel", { name: "tokens" }); @@ -123,7 +96,7 @@ const createCompositeDerivedToken = async (page, type, name, reference) => { await expect(tokensUpdateCreateModal).not.toBeVisible(); }; -test.describe("Remapping Tokens", () => { +test.describe("Remapping a single token", () => { test.describe("Box Shadow Token Remapping", () => { test("User renames box shadow token with alias references", async ({ page, @@ -634,3 +607,92 @@ test.describe("Remapping Tokens", () => { }); }); }); + +test.describe("Remapping group of tokens", () => { + test("User renames a group - and remaps", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + const workspacePage = new WasmWorkspacePage(page); + const rightSidebar = workspacePage.rightSidebar; + + // Create multiple tokens in a group + await createToken(page, "Color", "light.primary", "Value", "#FFFFFF"); + await createToken(page, "Color", "light.secondary", "Value", "#EEEEEE"); + + // Verify that the node and child token are visible before deletion + const lightNode = tokensSidebar.getByRole("button", { + name: "light", + exact: true, + }); + const lightNodeToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + + // Select a node and right click on it to open context menu + await expect(lightNode).toBeVisible(); + await expect(lightNodeToken).toBeVisible(); + + // Apply token to a shape to ensure remapping modal appears with applied token reference + await page.getByRole("tab", { name: "Layers" }).click(); + await page + .getByTestId("layer-row") + .filter({ hasText: "Rectangle" }) + .first() + .click(); + + await page.getByRole("tab", { name: "Tokens" }).click(); + const lightPrimaryToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + await lightPrimaryToken.click(); + + // Right click on the node to rename + + await lightNode.click({ button: "right" }); + const renameNodeButton = page.getByRole("button", { + name: "Rename", + exact: true, + }); + await expect(renameNodeButton).toBeVisible(); + await renameNodeButton.click(); + + // Expect the rename modal to be visible, fill in the new name and submit + const tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); + await expect(tokenRenameNodeModal).toBeVisible(); + + const nameField = tokenRenameNodeModal.getByRole("textbox", { + name: "Name", + }); + await nameField.fill("lighter"); + + const submitButton = tokenRenameNodeModal.getByRole("button", { + name: "Rename", + }); + await submitButton.click(); + + // Ensure that the remapping modal appears and confirm remap + const remappingModal = page.getByTestId("token-remapping-modal"); + await expect(remappingModal).toBeVisible({ timeout: 5000 }); + + const confirmButton = remappingModal.getByRole("button", { + name: "remap tokens", + }); + await confirmButton.click(); + + // Verify that the node has been renamed and tokens are still visible + const lighterNode = tokensSidebar.getByRole("button", { + name: "lighter", + exact: true, + }); + + await expect(lighterNode).toBeVisible(); + + // Verify that the applied token reference has been updated in the right sidebar for the selected shape + const fillSection = rightSidebar.getByRole("region", { name: "Fill section" }); + await expect(fillSection).toBeVisible(); + + const tokenReference = fillSection.getByLabel("lighter.primary", { + exact: true, + }); + await expect(tokenReference).toBeVisible(); + }); +}); diff --git a/frontend/playwright/ui/specs/tokens/tree.spec.js b/frontend/playwright/ui/specs/tokens/tree.spec.js index ae43197acc..243a539432 100644 --- a/frontend/playwright/ui/specs/tokens/tree.spec.js +++ b/frontend/playwright/ui/specs/tokens/tree.spec.js @@ -1,7 +1,7 @@ import { test, expect } from "@playwright/test"; import { WasmWorkspacePage } from "../../pages/WasmWorkspacePage"; import { BaseWebSocketPage } from "../../pages/BaseWebSocketPage"; -import { setupTokensFileRender, unfoldTokenTree } from "./helpers"; +import { createToken, setupTokensFileRender, unfoldTokenType } from "./helpers"; test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); @@ -20,13 +20,168 @@ test.describe("Tokens - node tree", () => { await expect(tokensColorGroup).toBeVisible(); await tokensColorGroup.click(); - await unfoldTokenTree(tokensSidebar, "color", "colors.blue.100"); - const colorToken = tokensSidebar.getByRole("button", { - name: "100", + name: "colors.blue.100", }); await expect(colorToken).toBeVisible(); await tokensColorGroup.click(); await expect(colorToken).not.toBeVisible(); }); + + test("User renames a group", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + + // Create multiple tokens in a group + await createToken(page, "Color", "dark.primary", "Value", "#000000"); + await createToken(page, "Color", "dark.secondary", "Value", "#111111"); + + // Verify that the node and child token are visible before deletion + const darkNode = tokensSidebar.getByRole("button", { + name: "dark", + exact: true, + }); + const darkNodeToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + + // Select a node and right click on it to open context menu + await expect(darkNode).toBeVisible(); + await expect(darkNodeToken).toBeVisible(); + await darkNode.click({ button: "right" }); + + // select "Rename" from the context menu + const renameNodeButton = page.getByRole("button", { + name: "Rename", + exact: true, + }); + await expect(renameNodeButton).toBeVisible(); + await renameNodeButton.click(); + + // Expect the rename modal to be visible, fill in the new name and submit + const tokenRenameNodeModal = page.getByTestId("token-rename-node-modal"); + await expect(tokenRenameNodeModal).toBeVisible(); + + const nameField = tokenRenameNodeModal.getByRole("textbox", { + name: "Name", + }); + await nameField.fill("darker"); + + const submitButton = tokenRenameNodeModal.getByRole("button", { + name: "Rename", + }); + await submitButton.click(); + + // Ensure that the remapping modal does not appear + const remappingModal = page.getByTestId("token-remapping-modal"); + await expect(remappingModal).not.toBeVisible(); + + // Verify that the node has been renamed and tokens are still visible + const darkerNode = tokensSidebar.getByRole("button", { + name: "darker", + exact: true, + }); + + await expect(darkerNode).toBeVisible(); + }); + + test("User duplicates a group", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + + // Create multiple tokens in a group + await createToken(page, "Color", "dark.primary", "Value", "#000000"); + await createToken(page, "Color", "dark.secondary", "Value", "#111111"); + + // Verify that the node and child token are visible before deletion + const darkNode = tokensSidebar.getByRole("button", { + name: "dark", + exact: true, + }); + const darkNodeToken = tokensSidebar.getByRole("button", { + name: "primary", + }); + + // Select a node and right click on it to open context menu + await expect(darkNode).toBeVisible(); + await expect(darkNodeToken).toBeVisible(); + await darkNode.click({ button: "right" }); + + // select "Duplicate" from the context menu + const duplicateNodeButton = page.getByRole("button", { + name: "Duplicate", + exact: true, + }); + await expect(duplicateNodeButton).toBeVisible(); + await duplicateNodeButton.click(); + + // Expect the duplicate modal to be visible, fill in the new name and submit + const tokenDuplicateNodeModal = page.getByTestId("token-rename-node-modal"); + await expect(tokenDuplicateNodeModal).toBeVisible(); + + const nameField = tokenDuplicateNodeModal.getByRole("textbox", { + name: "Name", + }); + await nameField.fill("darker"); + + const submitButton = tokenDuplicateNodeModal.getByRole("button", { + name: "Duplicate", + }); + await submitButton.click(); + + // Verify that the node has been duplicated and tokens are visible + const darkerNode = tokensSidebar.getByRole("button", { + name: "darker", + exact: true, + }); + + const darkerNodeToken = tokensSidebar.getByRole("button", { + name: "darker.primary", + }); + + await expect(darkerNode).toBeVisible(); + await expect(darkerNodeToken).toBeVisible(); + }); + + test("User removes node and all child tokens", async ({ page }) => { + const { tokensSidebar } = await setupTokensFileRender(page); + + await expect(tokensSidebar).toBeVisible(); + + // Expand color tokens + await unfoldTokenType(tokensSidebar, "color"); + + // Verify that the node and child token are visible before deletion + const colorNode = tokensSidebar.getByRole("button", { + name: "colors", + exact: true, + }); + const colorNodeToken = tokensSidebar.getByRole("button", { + name: "colors.blue.100", + }); + + // Select a node and right click on it to open context menu + await expect(colorNode).toBeVisible(); + await expect(colorNodeToken).toBeVisible(); + await colorNode.click({ button: "right" }); + + // select "Delete" from the context menu + const deleteNodeButton = page.getByRole("button", { + name: "Delete", + exact: true, + }); + await expect(deleteNodeButton).toBeVisible(); + await deleteNodeButton.click(); + + // Verify that the node is removed + await expect(colorNode).not.toBeVisible(); + // Verify that child token is also removed + await expect(colorNodeToken).not.toBeVisible(); + + // Save the type button to verify that expands/folds + const tokenTypeButton = await tokensSidebar.getByRole("button", { + name: "Color", + exact: true, + }); + + await expect(tokenTypeButton).toHaveAttribute("aria-expanded", "false"); + }); }); diff --git a/frontend/playwright/ui/specs/workspace-modifers.spec.js b/frontend/playwright/ui/specs/workspace-modifers.spec.js index 8e5f871fd8..bbea6199f8 100644 --- a/frontend/playwright/ui/specs/workspace-modifers.spec.js +++ b/frontend/playwright/ui/specs/workspace-modifers.spec.js @@ -3,13 +3,17 @@ import { WasmWorkspacePage } from "../pages/WasmWorkspacePage"; test.beforeEach(async ({ page }) => { await WasmWorkspacePage.init(page); + await WasmWorkspacePage.mockConfigFlags(page, ["enable-feature-token-input"]); }); test("BUG 13305 - Fix resize board to fit content", async ({ page }) => { const workspacePage = new WasmWorkspacePage(page); await workspacePage.setupEmptyFile(); await workspacePage.mockGetFile("workspace/get-file-13305.json"); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-13305.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-13305.json", + ); await workspacePage.goToWorkspace({ fileId: "9666e946-78e8-8111-8007-8fe5f0f454bf", @@ -17,12 +21,42 @@ test("BUG 13305 - Fix resize board to fit content", async ({ page }) => { }); await workspacePage.clickLeafLayer("Board"); - await workspacePage.rightSidebar.getByRole("button", { name: "Resize board to fit content" }).click(); + await workspacePage.rightSidebar + .getByRole("button", { name: "Resize board to fit content" }) + .click(); - await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("630"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("630"); - await expect(workspacePage.rightSidebar.getByTitle("X axis").getByRole("textbox")).toHaveValue("110"); - await expect(workspacePage.rightSidebar.getByTitle("Y axis").getByRole("textbox")).toHaveValue("110"); + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + // Width + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("630"); + + // Height + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + await expect(heightInput).toHaveValue("630"); + + // X Position (using "X axis" title) + const xPosInput = measuresSection.getByRole("textbox", { + name: "X axis", + exact: true, + }); + await expect(xPosInput).toHaveValue("110"); + + // Y Position (using "Y axis" title) + const yPosInput = measuresSection.getByRole("textbox", { + name: "Y axis", + exact: true, + }); + await expect(yPosInput).toHaveValue("110"); }); test("BUG 13382 - Fix problem with flex layout", async ({ page }) => { @@ -35,7 +69,10 @@ test("BUG 13382 - Fix problem with flex layout", async ({ page }) => { "workspace/get-file-13382-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "52c4e771-3853-8190-8007-9506c70e8100", @@ -47,13 +84,26 @@ test("BUG 13382 - Fix problem with flex layout", async ({ page }) => { await workspacePage.clickToggableLayer("C"); await workspacePage.clickLeafLayer("R2"); - const heightText = workspacePage.rightSidebar.getByTitle("Height").getByPlaceholder('--'); - await heightText.fill("200"); - await heightText.press("Enter"); + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + await heightInput.fill("200"); + await heightInput.press("Enter"); await workspacePage.clickLeafLayer("B"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("340"); + // Width + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("393"); }); test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { @@ -66,7 +116,10 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { "workspace/get-file-13468-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "3a4d7ec7-c391-8146-8007-9a05c41da6b9", @@ -76,10 +129,21 @@ test("BUG 13468 - Fix problem with flex propagation", async ({ page }) => { await workspacePage.clickToggableLayer("Parent"); await workspacePage.clickToggableLayer("Container"); - await workspacePage.sidebar.getByRole('button', { name: 'Show' }).click(); + await workspacePage.sidebar.getByRole("button", { name: "Show" }).click(); await workspacePage.clickLeafLayer("Container"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("76"); + + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + + await expect(heightInput).toHaveValue("76"); }); test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => { @@ -92,7 +156,10 @@ test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => { "workspace/get-file-13272-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "3b9773cc-d4f1-81e1-8007-b3f8dcaba770", @@ -102,15 +169,31 @@ test("BUG 13272 - Fix problem with snap to pixel", async ({ page }) => { await workspacePage.clickToggableLayer("Group"); await workspacePage.clickLeafLayer("Group"); - await workspacePage.page.locator('g:nth-child(11) > .cursor-resize-nesw-0').hover(); + await workspacePage.page + .locator("g:nth-child(11) > .cursor-resize-nesw-0") + .hover(); await workspacePage.page.mouse.down(); await workspacePage.page.mouse.move(1200, 800); await workspacePage.page.mouse.up(); await workspacePage.clickLeafLayer("Rectangle"); - await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("197.5"); - await expect(workspacePage.rightSidebar.getByTitle("Height").getByRole("textbox")).toHaveValue("128.28"); + + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const heightInput = measuresSection.getByRole("textbox", { + name: "Height", + exact: true, + }); + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("197.5"); + await expect(heightInput).toHaveValue("128.28"); }); test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { @@ -123,7 +206,10 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { "workspace/get-file-13755-fragment.json", ); - await workspacePage.mockRPC("update-file?id=*", "workspace/update-file-empty.json"); + await workspacePage.mockRPC( + "update-file?id=*", + "workspace/update-file-empty.json", + ); await workspacePage.goToWorkspace({ fileId: "7fd33337-c651-80ae-8007-c357213f876e", @@ -133,9 +219,19 @@ test("BUG 13755 - Fix problem with text change modiifers", async ({ page }) => { await workspacePage.clickToggableLayer("Board"); await workspacePage.clickLeafLayer("uno dos tres cuatro"); - await workspacePage.page.keyboard.press('Enter'); - await workspacePage.page.keyboard.type('test'); + await workspacePage.page.keyboard.press("Enter"); + await workspacePage.page.keyboard.type("test"); await workspacePage.clickToggableLayer("Board"); - await expect(workspacePage.rightSidebar.getByTitle("Width").getByRole("textbox")).toHaveValue("23"); + + const measuresSection = workspacePage.rightSidebar.getByRole("region", { + name: "shape-measures-section", + }); + await expect(measuresSection).toBeVisible(); + + const widthInput = measuresSection.getByRole("textbox", { + name: "Width", + exact: true, + }); + await expect(widthInput).toHaveValue("23"); }); diff --git a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js index 5e3f1a5eff..9a61a8ae97 100644 --- a/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-dashboard.spec.js @@ -9,6 +9,7 @@ test("User goes to an empty dashboard", async ({ page }) => { const dashboardPage = new DashboardPage(page); await dashboardPage.goToDashboard(); + await expect(dashboardPage.page).toHaveURL(/dashboard/); await expect(dashboardPage.mainHeading).toBeVisible(); await expect(dashboardPage.page).toHaveScreenshot(); @@ -122,9 +123,7 @@ test("User goes to a full search page", async ({ page }) => { await dashboardPage.searchInput.fill("3"); await expect(dashboardPage.mainHeading).toHaveText("Search results"); - await expect( - dashboardPage.page.getByRole("button", { name: "New File 3" }), - ).toBeVisible(); + await expect(page.getByRole("button", { name: "New File 3" })).toBeVisible(); await expect(dashboardPage.page).toHaveScreenshot(); }); @@ -202,6 +201,10 @@ test("User opens teams selector with more than one team", async ({ page }) => { test("User goes to second team", async ({ page }) => { const dashboardPage = new DashboardPage(page); await dashboardPage.setupDashboardFull(); + await dashboardPage.mockRPC( + `get-projects?team-id=${DashboardPage.secondTeamId}`, + "dashboard/get-projects-second-team.json", + ); await dashboardPage.goToDashboard(); await dashboardPage.teamDropdown.click(); @@ -216,6 +219,10 @@ test("User goes to second team", async ({ page }) => { test("User opens team management dropdown", async ({ page }) => { const dashboardPage = new DashboardPage(page); await dashboardPage.setupDashboardFull(); + await dashboardPage.mockRPC( + `get-projects?team-id=${DashboardPage.secondTeamId}`, + "dashboard/get-projects-second-team.json", + ); await dashboardPage.goToSecondTeamDashboard(); await expect(page.getByText("Team Up")).toBeVisible(); diff --git a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js index 977eb57fcb..8361c263fb 100644 --- a/frontend/playwright/ui/visual-specs/visual-viewer.spec.js +++ b/frontend/playwright/ui/visual-specs/visual-viewer.spec.js @@ -103,7 +103,7 @@ test("User goes to the Viewer Inspect code", async ({ page }) => { await expect( viewerPage.page.getByRole("button", { - name: "Toggle panel Size & Position", + name: "Toggle panel Size and position", }), ).toBeVisible(); diff --git a/frontend/playwright/ui/visual-specs/workspace.spec.js b/frontend/playwright/ui/visual-specs/workspace.spec.js index c1cc4ecbcc..df766736a0 100644 --- a/frontend/playwright/ui/visual-specs/workspace.spec.js +++ b/frontend/playwright/ui/visual-specs/workspace.spec.js @@ -158,7 +158,9 @@ test.describe("Palette", () => { .getByRole("button", { name: "Color Palette" }) .click(); await expect( - workspace.palette.getByRole("button", { name: "#7798ff" }), + workspace.palette.getByText( + "There are no color styles in your library yet", + ), ).toBeVisible(); }); }); diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fdd69fb27a..5fa2751411 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -148,6 +148,9 @@ importers: postcss-modules: specifier: ^6.0.1 version: 6.0.1(postcss@8.5.8) + postcss-scss: + specifier: ^4.0.9 + version: 4.0.9(postcss@8.5.8) prettier: specifier: 3.8.1 version: 3.8.1 @@ -199,6 +202,18 @@ importers: style-dictionary: specifier: 5.0.0-rc.1 version: 5.0.0-rc.1(tslib@2.8.1) + stylelint: + specifier: ^17.4.0 + version: 17.4.0(typescript@6.0.2) + stylelint-config-standard-scss: + specifier: ^17.0.0 + version: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)) + stylelint-scss: + specifier: ^7.0.0 + version: 7.0.0(stylelint@17.4.0(typescript@6.0.2)) + stylelint-use-logical-spec: + specifier: ^5.0.1 + version: 5.0.1(stylelint@17.4.0(typescript@6.0.2)) svg-sprite: specifier: ^2.0.4 version: 2.0.4 @@ -555,6 +570,12 @@ packages: '@bundled-es-modules/postcss-calc-ast-parser@0.1.6': resolution: {integrity: sha512-y65TM5zF+uaxo9OeekJ3rxwTINlQvrkbZLogYvQYVoLtxm4xEiHfZ7e/MyiWbStYyWZVZkVqsaVU6F4SUK5XUA==} + '@cacheable/memory@2.0.8': + resolution: {integrity: sha512-FvEb29x5wVwu/Kf93IWwsOOEuhHh6dYCJF3vcKLzXc0KXIW181AOzv6ceT4ZpBHDvAfG60eqb+ekmrnLHIy+jw==} + + '@cacheable/utils@2.4.0': + resolution: {integrity: sha512-PeMMsqjVq+bF0WBsxFBxr/WozBJiZKY0rUojuaCoIaKnEl3Ju1wfEwS+SV1DU/cSe8fqHIPiYJFif8T3MVt4cQ==} + '@colors/colors@1.6.0': resolution: {integrity: sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==} engines: {node: '>=0.1.90'} @@ -604,6 +625,9 @@ packages: '@csstools/css-syntax-patches-for-csstree@1.0.26': resolution: {integrity: sha512-6boXK0KkzT5u5xOgF6TKB+CLq9SOpEGmkZw0g5n9/7yg85wab3UzSxB8TxhLJ31L4SGJ6BCFRw/iftTha1CJXA==} + '@csstools/css-syntax-patches-for-csstree@1.1.0': + resolution: {integrity: sha512-H4tuz2nhWgNKLt1inYpoVCfbJbMwX/lQKp3g69rrrIMIYlFD9+zTykOKhNR8uGrAmbS/kT9n6hTFkmDkxLgeTA==} + '@csstools/css-syntax-patches-for-csstree@1.1.2': resolution: {integrity: sha512-5GkLzz4prTIpoyeUiIu3iV6CSG3Plo7xRVOFPKI7FVEJ3mZ0A8SwK0XU3Gl7xAkiQ+mDyam+NNp875/C5y+jSA==} peerDependencies: @@ -616,6 +640,25 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@csstools/media-query-list-parser@5.0.0': + resolution: {integrity: sha512-T9lXmZOfnam3eMERPsszjY5NK0jX8RmThmmm99FZ8b7z8yMaFZWKwLWGZuTwdO3ddRY5fy13GmmEYZXB4I98Eg==} + engines: {node: '>=20.19.0'} + peerDependencies: + '@csstools/css-parser-algorithms': ^4.0.0 + '@csstools/css-tokenizer': ^4.0.0 + + '@csstools/selector-resolve-nested@4.0.0': + resolution: {integrity: sha512-9vAPxmp+Dx3wQBIUwc1v7Mdisw1kbbaGqXUM8QLTgWg7SoPGYtXBsMXvsFs/0Bn5yoFhcktzxNZGNaUt0VjgjA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + + '@csstools/selector-specificity@6.0.0': + resolution: {integrity: sha512-4sSgl78OtOXEX/2d++8A83zHNTgwCJMaR24FvsYL7Uf/VS8HZk9PTwR51elTbGqMuwH3szLvvOXEaVnqn0Z3zA==} + engines: {node: '>=20.19.0'} + peerDependencies: + postcss-selector-parser: ^7.1.1 + '@dabh/diagnostics@2.0.8': resolution: {integrity: sha512-R4MSXTVnuMzGD7bzHdW2ZhhdPC/igELENcq5IjEverBvq5hn1SXCWcsi6eSsdWP0/Ur+SItRRjAktmdoX/8R/Q==} @@ -1491,6 +1534,15 @@ packages: peerDependencies: tslib: '2' + '@keyv/bigmap@1.3.1': + resolution: {integrity: sha512-WbzE9sdmQtKy8vrNPa9BRnwZh5UF4s1KTmSK0KUVLo3eff5BlQNNWDnFOouNpKfPKDnms9xynJjsMYjMaT/aFQ==} + engines: {node: '>= 18'} + peerDependencies: + keyv: ^5.6.0 + + '@keyv/serialize@1.1.1': + resolution: {integrity: sha512-dXn3FZhPv0US+7dtJsIi2R+c7qWYiReoEh5zUntWCf4oSpMNib8FDhSoed6m3QyZdx5hK7iLFkYk3rNxwt8vTA==} + '@mdx-js/react@3.1.1': resolution: {integrity: sha512-f++rKLQgUVYDAtECQ6fn/is15GkEH9+nZPM3MS0RcxVqoTfawHvDlSCH7JbMhAM6uJ32v3eXLvLmLvjGu7PTQw==} peerDependencies: @@ -2003,6 +2055,10 @@ packages: '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} + '@sindresorhus/merge-streams@4.0.0': + resolution: {integrity: sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ==} + engines: {node: '>=18'} + '@so-ric/colorspace@1.1.6': resolution: {integrity: sha512-/KiKkpHNOBgkFJwu9sh48LkHSMYGyuTcSFK/qMBdnOAlrRJzRSXAOFB5qwzaVQuDl8wAvHVMkaASQDReTahxuw==} @@ -2526,6 +2582,10 @@ packages: ast-v8-to-istanbul@1.0.0: resolution: {integrity: sha512-1fSfIwuDICFA4LKkCzRPO7F0hzFf0B7+Xqrl27ynQaa+Rh0e1Es0v6kWHPott3lU10AyAr7oKHa65OppjLn3Rg==} + astral-regex@2.0.0: + resolution: {integrity: sha512-Z7tMw1ytTXt5jqMcOP+OQteU1VuNK9Y02uuJtKQ1Sv69jXQKKg5cibLwGJow8yzZP+eAc18EmLGPal0bp36rvQ==} + engines: {node: '>=8'} + async-function@1.0.0: resolution: {integrity: sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==} engines: {node: '>= 0.4'} @@ -2659,6 +2719,9 @@ packages: resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} engines: {node: '>=8'} + cacheable@2.3.3: + resolution: {integrity: sha512-iffYMX4zxKp54evOH27fm92hs+DeC1DhXmNVN8Tr94M/iZIV42dqTHSR2Ik4TOSPyOAwKr7Yu3rN9ALoLkbWyQ==} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} @@ -2797,6 +2860,9 @@ packages: resolution: {integrity: sha512-ezmVcLR3xAVp8kYOm4GS45ZLLgIE6SPAFoduLr6hTDajwb3KZ2F46gulK3XpcwRFb5KKGCSezCBAY4Dw4HsyXA==} engines: {node: '>=18'} + colord@2.9.3: + resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + colorjs.io@0.4.5: resolution: {integrity: sha512-yCtUNCmge7llyfd/Wou19PMAcf5yC3XXhgFoAh6zsO2pGswhUPBaaUh8jzgHnXtXuZyFKzXZNAnyF5i+apICow==} @@ -2880,6 +2946,15 @@ packages: core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cosmiconfig@9.0.1: + resolution: {integrity: sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==} + engines: {node: '>=14'} + peerDependencies: + typescript: '>=4.9.5' + peerDependenciesMeta: + typescript: + optional: true + cross-fetch@3.2.0: resolution: {integrity: sha512-Q+xVJLoGOeIMXZmbUK4HYk+69cQH6LudR0Vu/pRm2YlU/hDV9CiS0gKUMaWY5f2NeUH9C1nV3bsTlCo0FsTV1Q==} @@ -2891,6 +2966,10 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-functions-list@3.3.3: + resolution: {integrity: sha512-8HFEBPKhOpJPEPu70wJJetjKta86Gw9+CCyCnB3sui2qQfOvRyqBy4IKLKKAwdMpWb2lHXWk9Wb4Z6AmaUT1Pg==} + engines: {node: '>=12'} + css-select@4.3.0: resolution: {integrity: sha512-wPpOYtnsVontu2mODhA19JrqWxNsfdatRKd64kmpRbQgh1KtItko5sTnEpPdpSaJszTOhEMlF/RPz28qj4HqhQ==} @@ -3182,6 +3261,10 @@ packages: resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} engines: {node: '>=0.12'} + env-paths@2.2.1: + resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==} + engines: {node: '>=6'} + error-ex@1.3.4: resolution: {integrity: sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==} @@ -3411,6 +3494,10 @@ packages: fast-levenshtein@2.0.6: resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + fastest-levenshtein@1.0.16: + resolution: {integrity: sha512-eRnCtTTtGZFpQCwhJiUOuxPQWRXVKYDn0b2PeHfXL6/Zi53SLAzAHfVhVWK2AryC/WH05kGfxhFIPvTF0SXQzg==} + engines: {node: '>= 4.9.1'} + fastq@1.20.1: resolution: {integrity: sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==} @@ -3435,6 +3522,9 @@ packages: fflate@0.8.2: resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + file-entry-cache@11.1.2: + resolution: {integrity: sha512-N2WFfK12gmrK1c1GXOqiAJ1tc5YE+R53zvQ+t5P8S5XhnmKYVB5eZEiLNZKDSmoG8wqqbF9EXYBBW/nef19log==} + file-entry-cache@8.0.0: resolution: {integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==} engines: {node: '>=16.0.0'} @@ -3458,6 +3548,9 @@ packages: resolution: {integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==} engines: {node: '>=16'} + flat-cache@6.1.20: + resolution: {integrity: sha512-AhHYqwvN62NVLp4lObVXGVluiABTHapoB57EyegZVmazN+hhGhLTn3uZbOofoTw4DSDvVCadzzyChXhOAvy8uQ==} + flatted@3.3.3: resolution: {integrity: sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==} @@ -3552,6 +3645,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.5.0: + resolution: {integrity: sha512-CQ+bEO+Tva/qlmw24dCejulK5pMzVnUOFOijVogd3KQs07HnRIgp8TGipvCCRT06xeYEbpbgwaCxglFyiuIcmA==} + engines: {node: '>=18'} + get-func-name@2.0.2: resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} @@ -3608,6 +3705,14 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me + global-modules@2.0.0: + resolution: {integrity: sha512-NGbfmJBp9x8IxyJSd1P+otYK8vonoJactOogrVfFRIAEY1ukil8RSKDz2Yo7wh1oihl51l/r6W4epkeKJHqL8A==} + engines: {node: '>=6'} + + global-prefix@3.0.0: + resolution: {integrity: sha512-awConJSVCHVGND6x3tmMaKcQvwXLhjdkmomy2W+Goaui8YPgYgXJZewhg3fWC+DlfqqQuWg8AwqjGTD2nAPVWg==} + engines: {node: '>=6'} + globals@14.0.0: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} @@ -3616,6 +3721,13 @@ packages: resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} engines: {node: '>= 0.4'} + globby@16.1.1: + resolution: {integrity: sha512-dW7vl+yiAJSp6aCekaVnVJxurRv7DCOLyXqEG3RYMYUg7AuJ2jCqPkZTA8ooqC2vtnkaMcV5WfFBMuEnTu1OQg==} + engines: {node: '>=20'} + + globjoin@0.1.4: + resolution: {integrity: sha512-xYfnw62CKG8nLkZBfWbhWwDw02CHty86jfPcc2cr3ZfeuK9ysoVPPEUxf21bAD/rWAgk52SuBrLJlefNy8mvFg==} + gopd@1.2.0: resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} engines: {node: '>= 0.4'} @@ -3635,6 +3747,10 @@ packages: resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} engines: {node: '>=8'} + has-flag@5.0.1: + resolution: {integrity: sha512-CsNUt5x9LUdx6hnk/E2SZLsDyvfqANZSUq4+D3D8RzDJ2M+HDTIkF60ibS1vHaK55vzgiZw1bEPFG9yH7l33wA==} + engines: {node: '>=12'} + has-property-descriptors@1.0.2: resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} @@ -3650,6 +3766,10 @@ packages: resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} engines: {node: '>= 0.4'} + hashery@1.5.0: + resolution: {integrity: sha512-nhQ6ExaOIqti2FDWoEMWARUqIKyjr2VcZzXShrI+A3zpeiuPWzx6iPftt44LhP74E5sW36B75N6VHbvRtpvO6Q==} + engines: {node: '>=20'} + hasown@2.0.2: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} @@ -3668,6 +3788,9 @@ packages: resolution: {integrity: sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==} engines: {node: '>=12.0.0'} + hookified@1.15.1: + resolution: {integrity: sha512-MvG/clsADq1GPM2KGo2nyfaWVyn9naPiXrqIe4jYjXNZQt238kWyOGrsyc/DmRAQ+Re6yeo6yX/yoNCG5KAEVg==} + hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} @@ -3678,6 +3801,10 @@ packages: html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + html-tags@5.1.0: + resolution: {integrity: sha512-n6l5uca7/y5joxZ3LUePhzmBFUJ+U2YWzhMa8XUTecSeSlQiZdF5XAd/Q3/WUl0VsXgUwWi8I7CNIwdI5WN1SQ==} + engines: {node: '>=20.10'} + http-errors@2.0.1: resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} engines: {node: '>= 0.8'} @@ -3722,6 +3849,10 @@ packages: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} + ignore@7.0.5: + resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} + engines: {node: '>= 4'} + immutable@3.7.6: resolution: {integrity: sha512-AizQPcaofEtO11RZhPPHBOJRdo/20MKQF9mBLnVkBoyHi1/zXK8fzVdnEpSV9gxqtnh6Qomfp3F0xT5qP/vThw==} engines: {node: '>=0.8.0'} @@ -3740,6 +3871,9 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + import-meta-resolve@4.2.0: + resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -3870,10 +4004,18 @@ packages: resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} engines: {node: '>=0.12.0'} + is-path-inside@4.0.0: + resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} + engines: {node: '>=12'} + is-plain-obj@4.1.0: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + is-potential-custom-element-name@1.0.1: resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==} @@ -4025,6 +4167,9 @@ packages: json-parse-better-errors@1.0.2: resolution: {integrity: sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==} + json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} @@ -4060,9 +4205,19 @@ packages: keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + keyv@5.6.0: + resolution: {integrity: sha512-CYDD3SOtsHtyXeEORYRx2qBtpDJFjRTGXUtmNEMGyzYOKj1TE3tycdlho7kA1Ufx9OYWZzg52QFBGALTirzDSw==} + + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + klaw-sync@6.0.0: resolution: {integrity: sha512-nIeuVSzdCCs6TDPTqI8w1Yre34sSq7AkZ4B3sfOBbI2CgVSB4Du4aLQijFU2+lhAFCwt9+42Hel6lQNIv6AntQ==} + known-css-properties@0.37.0: + resolution: {integrity: sha512-JCDrsP4Z1Sb9JwG0aJ8Eo2r7k4Ou5MwmThS/6lcIe1ICyb7UBJKGRIUUdqc2ASdE/42lgz6zFUnzAIhtXnBVrQ==} + kolorist@1.8.0: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} @@ -4154,6 +4309,9 @@ packages: resolution: {integrity: sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==} engines: {node: '>= 12.0.0'} + lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + load-json-file@4.0.0: resolution: {integrity: sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw==} engines: {node: '>=4'} @@ -4186,6 +4344,9 @@ packages: lodash.merge@4.6.2: resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + lodash.truncate@4.4.2: + resolution: {integrity: sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==} + lodash@4.17.23: resolution: {integrity: sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==} @@ -4257,6 +4418,9 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + mathml-tag-names@4.0.0: + resolution: {integrity: sha512-aa6AU2Pcx0VP/XWnh8IGL0SYSgQHDT6Ucror2j2mXeFAlN3ahaNs8EZtG1YiticMkSLj3Gt6VPFfZogt7G5iFQ==} + mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} @@ -4282,6 +4446,10 @@ packages: resolution: {integrity: sha512-S3UwM3yj5mtUSEfP41UZmt/0SCoVYUcU1rkXv+BQ5Ig8ndL4sPoJNBUJERafdPb5jjHJGuMgytgKvKIf58XNBw==} engines: {node: '>= 0.10.0'} + meow@14.1.0: + resolution: {integrity: sha512-EDYo6VlmtnumlcBCbh1gLJ//9jvM/ndXHfVXIFrZVr6fGcwTUyCTFNTLCKuY3ffbK8L/+3Mzqnd58RojiZqHVw==} + engines: {node: '>=20'} + merge-descriptors@2.0.0: resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} engines: {node: '>=18'} @@ -4585,6 +4753,10 @@ packages: resolution: {integrity: sha512-aOIos8bujGN93/8Ox/jPLh7RwVnPEysynVFE+fQZyg6jKELEHwzgKdLRFHUgXJL6kylijVSBC4BvN9OmsB48Rw==} engines: {node: '>=4'} + parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + parse5@8.0.0: resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==} @@ -4730,6 +4902,9 @@ packages: resolution: {integrity: sha512-DpuMWW19Dd2K9KY4wknMz3khq9q2yZYa2U37bnhzdtBdBv0ggIfUj5T2XD3ir6gKVlDkb5OtOqw1iQJWq6qvpw==} engines: {node: '>=4.0.0'} + postcss-media-query-parser@0.2.3: + resolution: {integrity: sha512-3sOlxmbKcSHMjlUXQZKQ06jOswE7oVkXPxmZdoB1r5l0q6gTFTQSHxNxOrCccElbW7dxNytifNEo8qidX2Vsig==} + postcss-modules-extract-imports@3.1.0: resolution: {integrity: sha512-k3kNe0aNFQDAZGbin48pL2VNidTF0w4/eASDsxlyspobzU3wZQLOGj7L9gfRe0Jo9/4uud09DsjFNH7winGv8Q==} engines: {node: ^10 || ^12 || >= 14} @@ -4759,6 +4934,21 @@ packages: peerDependencies: postcss: ^8.0.0 + postcss-resolve-nested-selector@0.1.6: + resolution: {integrity: sha512-0sglIs9Wmkzbr8lQwEyIzlDOOC9bGmfVKcJTaxv3vMmd3uo4o4DerC3En0bnmgceeql9BfC8hRkp7cg0fjdVqw==} + + postcss-safe-parser@7.0.1: + resolution: {integrity: sha512-0AioNCJZ2DPYz5ABT6bddIqlhgwhpHZ/l65YAYo0BCIn0xiDpsnTHz0gnoTGk0OXZW0JRs+cDwL8u/teRdz+8A==} + engines: {node: '>=18.0'} + peerDependencies: + postcss: ^8.4.31 + + postcss-scss@4.0.9: + resolution: {integrity: sha512-AjKOeiwAitL/MXxQW2DliT28EKukvvbEWx3LBmJIRN8KfBGZbRTxNYW0kSqi1COiTZ57nZ9NW06S6ux//N1c9A==} + engines: {node: '>=12.0'} + peerDependencies: + postcss: ^8.4.29 + postcss-selector-parser@7.1.1: resolution: {integrity: sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==} engines: {node: '>=4'} @@ -4780,6 +4970,7 @@ packages: prebuild-install@7.1.3: resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==} engines: {node: '>=10'} + deprecated: No longer maintained. Please contact the author of the relevant native addon; alternatives are available. hasBin: true prelude-ls@1.2.1: @@ -4849,6 +5040,10 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qified@0.6.0: + resolution: {integrity: sha512-tsSGN1x3h569ZSU1u6diwhltLyfUWDp3YbFHedapTmpBl0B3P6U3+Qptg7xu+v+1io1EwhdPyyRHYbEw0KN2FA==} + engines: {node: '>=20'} + qs@6.14.1: resolution: {integrity: sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==} engines: {node: '>=0.6'} @@ -5305,6 +5500,14 @@ packages: resolution: {integrity: sha512-ZYKh3Wh2z1PpEXWr0MpSBZ0V6mZHAQfYevttO11c51CaWjGTaadiKZ+wVt1PbMlDV5qhMFslpZCemhwOK7C89A==} engines: {node: '>=6'} + slash@5.1.0: + resolution: {integrity: sha512-ZA6oR3T/pEyuqwMgAKT0/hAv8oAXckzbkmR0UkUosQ+Mc4RxGoJkRmwHgHufaenlyAgE1Mxgpdcrf75y6XcnDg==} + engines: {node: '>=14.16'} + + slice-ansi@4.0.0: + resolution: {integrity: sha512-qMCMfhY040cVHT43K9BFygqYbUPFZKHOg7K73mtTWJRb8pyP3fzf4Ixd5SzdEJQ6MRUg/WBnOLxghZtKKurENQ==} + engines: {node: '>=10'} + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5382,6 +5585,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@8.2.0: + resolution: {integrity: sha512-6hJPQ8N0V0P3SNmP6h2J99RLuzrWz2gvT7VnK5tKvrNqJoyS9W4/Fb8mo31UiPvy00z7DQXkP2hnKBVav76thw==} + engines: {node: '>=20'} + string.prototype.codepointat@0.2.1: resolution: {integrity: sha512-2cBVCj6I4IOvEnjgO/hWqXjqBGsY+zwPmHl12Srk9IXSZ56Jwwmy+66XO5Iut/oQVR7t5ihYdLB0GMa4alEUcg==} @@ -5461,6 +5668,59 @@ packages: engines: {node: '>=22.0.0'} hasBin: true + stylelint-config-recommended-scss@17.0.0: + resolution: {integrity: sha512-VkVD9r7jfUT/dq3mA3/I1WXXk2U71rO5wvU2yIil9PW5o1g3UM7Xc82vHmuVJHV7Y8ok5K137fmW5u3HbhtTOA==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^17.0.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-recommended@18.0.0: + resolution: {integrity: sha512-mxgT2XY6YZ3HWWe3Di8umG6aBmWmHTblTgu/f10rqFXnyWxjKWwNdjSWkgkwCtxIKnqjSJzvFmPT5yabVIRxZg==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-config-standard-scss@17.0.0: + resolution: {integrity: sha512-uLJS6xgOCBw5EMsDW7Ukji8l28qRoMnkRch15s0qwZpskXvWt9oPzMmcYM307m9GN4MxuWLsQh4I6hU9yI53cQ==} + engines: {node: '>=20'} + peerDependencies: + postcss: ^8.3.3 + stylelint: ^17.0.0 + peerDependenciesMeta: + postcss: + optional: true + + stylelint-config-standard@40.0.0: + resolution: {integrity: sha512-EznGJxOUhtWck2r6dJpbgAdPATIzvpLdK9+i5qPd4Lx70es66TkBPljSg4wN3Qnc6c4h2n+WbUrUynQ3fanjHw==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^17.0.0 + + stylelint-scss@7.0.0: + resolution: {integrity: sha512-H88kCC+6Vtzj76NsC8rv6x/LW8slBzIbyeSjsKVlS+4qaEJoDrcJR4L+8JdrR2ORdTscrBzYWiiT2jq6leYR1Q==} + engines: {node: '>=20.19.0'} + peerDependencies: + stylelint: ^16.8.2 || ^17.0.0 + + stylelint-use-logical-spec@5.0.1: + resolution: {integrity: sha512-UfLB4LW6iG4r3cXxjxkiHQrFyhWFqt8FpNNngD+TyvgMWSokk5TYwTvBHS3atUvZhOogllTOe/PUrGE+4z84AA==} + engines: {node: '>=8.0.0'} + peerDependencies: + stylelint: '>=11 < 17' + + stylelint@17.4.0: + resolution: {integrity: sha512-3kQ2/cHv3Zt8OBg+h2B8XCx9evEABQIrv4hh3uXahGz/ZEHrTR80zxBiK2NfXNaSoyBzxO1pjsz1Vhdzwn5XSw==} + engines: {node: '>=20.19.0'} + hasBin: true + + supports-color@10.2.2: + resolution: {integrity: sha512-SS+jx45GF1QjgEXQx4NJZV9ImqmO2NPz5FNsIHrsDjh2YsHnawpan7SNQ1o8NuhrbHZy9AZhIoCUiCeaW/C80g==} + engines: {node: '>=18'} + supports-color@5.5.0: resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} engines: {node: '>=4'} @@ -5473,6 +5733,10 @@ packages: resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} engines: {node: '>=10'} + supports-hyperlinks@4.4.0: + resolution: {integrity: sha512-UKbpT93hN5Nr9go5UY7bopIB9YQlMz9nm/ct4IXt/irb5YRkn9WaqrOBJGZ5Pwvsd5FQzSVeYlGdXoCAPQZrPg==} + engines: {node: '>=20'} + supports-preserve-symlinks-flag@1.0.0: resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} engines: {node: '>= 0.4'} @@ -5482,6 +5746,9 @@ packages: engines: {node: '>=12'} hasBin: true + svg-tags@1.0.0: + resolution: {integrity: sha512-ovssysQTa+luh7A5Weu3Rta6FJlFBBbInjOh722LIt6klpU2/HtdUbszju/G4devcvk8PGt7FCLv5wftu3THUA==} + svgo@2.8.0: resolution: {integrity: sha512-+N/Q9kV1+F+UeWYoSiULYo4xYSDQlTgb+ayMobAXPwMnLvop7oxKMo9OzIrX5x3eS4L4f2UHhc9axXwY8DpChg==} engines: {node: '>=10.13.0'} @@ -5503,6 +5770,10 @@ packages: resolution: {integrity: sha512-gAQ9qrUN/UCypHtGFbbe7Rc/f9bzO88IwrG8TDo/aMKAApKyD6E3W4Cm0EfhfBb6Z6SKt59tTCTfD+n1xmAvMg==} engines: {node: '>=16.0.0'} + table@6.9.0: + resolution: {integrity: sha512-9kY+CygyYM6j02t5YFHbNz2FN5QmYGv9zAjVp4lCDjlCw7amdckXlEt/bjMhUIfj4ThGRE4gCUH5+yGnNuPo5A==} + engines: {node: '>=10.0.0'} + tar-fs@2.1.4: resolution: {integrity: sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ==} @@ -5726,6 +5997,10 @@ packages: resolution: {integrity: sha512-H/nlJ/h0ggGC+uRL3ovD+G0i4bqhvsDOpbDv7At5eFLlj2b41L8QliGbnl2H7SnDiYhENphh1tQFJZf+MyfLsQ==} engines: {node: '>=20.18.1'} + unicorn-magic@0.4.0: + resolution: {integrity: sha512-wH590V9VNgYH9g3lH9wWjTrUoKsjLF6sGLjhR4sH1LWpLmCOH0Zf7PukhDA8BiS7KHe4oPNkcTHqYkj7SOGUOw==} + engines: {node: '>=20'} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -6037,6 +6312,10 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + write-file-atomic@7.0.1: + resolution: {integrity: sha512-OTIk8iR8/aCRWBqvxrzxR0hgxWpnYBblY1S5hDWBQfk/VFmJwzmJgQFN3WsoUKHISv2eAwe+PpbUzyL1CKTLXg==} + engines: {node: ^20.17.0 || >=22.9.0} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -6379,6 +6658,18 @@ snapshots: dependencies: postcss-calc-ast-parser: 0.1.4 + '@cacheable/memory@2.0.8': + dependencies: + '@cacheable/utils': 2.4.0 + '@keyv/bigmap': 1.3.1(keyv@5.6.0) + hookified: 1.15.1 + keyv: 5.6.0 + + '@cacheable/utils@2.4.0': + dependencies: + hashery: 1.5.0 + keyv: 5.6.0 + '@colors/colors@1.6.0': {} '@csstools/color-helpers@6.0.1': {} @@ -6415,12 +6706,27 @@ snapshots: '@csstools/css-syntax-patches-for-csstree@1.0.26': {} + '@csstools/css-syntax-patches-for-csstree@1.1.0': {} + '@csstools/css-syntax-patches-for-csstree@1.1.2(css-tree@3.2.1)': optionalDependencies: css-tree: 3.2.1 '@csstools/css-tokenizer@4.0.0': {} + '@csstools/media-query-list-parser@5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)': + dependencies: + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-tokenizer': 4.0.0 + + '@csstools/selector-resolve-nested@4.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + + '@csstools/selector-specificity@6.0.0(postcss-selector-parser@7.1.1)': + dependencies: + postcss-selector-parser: 7.1.1 + '@dabh/diagnostics@2.0.8': dependencies: '@so-ric/colorspace': 1.1.6 @@ -6998,6 +7304,14 @@ snapshots: '@jsonjoy.com/codegen': 17.65.0(tslib@2.8.1) tslib: 2.8.1 + '@keyv/bigmap@1.3.1(keyv@5.6.0)': + dependencies: + hashery: 1.5.0 + hookified: 1.15.1 + keyv: 5.6.0 + + '@keyv/serialize@1.1.1': {} + '@mdx-js/react@3.1.1(@types/react@19.2.14)(react@19.2.4)': dependencies: '@types/mdx': 2.0.13 @@ -7367,6 +7681,8 @@ snapshots: '@sinclair/typebox@0.27.10': {} + '@sindresorhus/merge-streams@4.0.0': {} + '@so-ric/colorspace@1.1.6': dependencies: color: 5.0.3 @@ -8030,6 +8346,8 @@ snapshots: estree-walker: 3.0.3 js-tokens: 10.0.0 + astral-regex@2.0.0: {} + async-function@1.0.0: {} async@3.2.6: {} @@ -8179,6 +8497,14 @@ snapshots: cac@6.7.14: {} + cacheable@2.3.3: + dependencies: + '@cacheable/memory': 2.0.8 + '@cacheable/utils': 2.4.0 + hookified: 1.15.1 + keyv: 5.6.0 + qified: 0.6.0 + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 @@ -8323,6 +8649,8 @@ snapshots: color-convert: 3.1.3 color-string: 2.1.4 + colord@2.9.3: {} + colorjs.io@0.4.5: {} colorjs.io@0.5.2: {} @@ -8393,6 +8721,15 @@ snapshots: core-util-is@1.0.3: {} + cosmiconfig@9.0.1(typescript@6.0.2): + dependencies: + env-paths: 2.2.1 + import-fresh: 3.3.1 + js-yaml: 4.1.1 + parse-json: 5.2.0 + optionalDependencies: + typescript: 6.0.2 + cross-fetch@3.2.0(encoding@0.1.13): dependencies: node-fetch: 2.7.0(encoding@0.1.13) @@ -8413,6 +8750,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-functions-list@3.3.3: {} + css-select@4.3.0: dependencies: boolbase: 1.0.0 @@ -8689,6 +9028,8 @@ snapshots: entities@7.0.1: {} + env-paths@2.2.1: {} + error-ex@1.3.4: dependencies: is-arrayish: 0.2.1 @@ -9165,6 +9506,8 @@ snapshots: fast-levenshtein@2.0.6: {} + fastest-levenshtein@1.0.16: {} + fastq@1.20.1: dependencies: reusify: 1.1.0 @@ -9191,6 +9534,10 @@ snapshots: fflate@0.8.2: {} + file-entry-cache@11.1.2: + dependencies: + flat-cache: 6.1.20 + file-entry-cache@8.0.0: dependencies: flat-cache: 4.0.1 @@ -9224,6 +9571,12 @@ snapshots: flatted: 3.4.2 keyv: 4.5.4 + flat-cache@6.1.20: + dependencies: + cacheable: 2.3.3 + flatted: 3.3.3 + hookified: 1.15.1 + flatted@3.3.3: {} flatted@3.4.2: {} @@ -9304,6 +9657,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.5.0: {} + get-func-name@2.0.2: {} get-intrinsic@1.3.0: @@ -9377,6 +9732,16 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + global-modules@2.0.0: + dependencies: + global-prefix: 3.0.0 + + global-prefix@3.0.0: + dependencies: + ini: 1.3.8 + kind-of: 6.0.3 + which: 1.3.1 + globals@14.0.0: {} globalthis@1.0.4: @@ -9384,6 +9749,17 @@ snapshots: define-properties: 1.2.1 gopd: 1.2.0 + globby@16.1.1: + dependencies: + '@sindresorhus/merge-streams': 4.0.0 + fast-glob: 3.3.3 + ignore: 7.0.5 + is-path-inside: 4.0.0 + slash: 5.1.0 + unicorn-magic: 0.4.0 + + globjoin@0.1.4: {} + gopd@1.2.0: {} graceful-fs@4.2.11: {} @@ -9394,6 +9770,8 @@ snapshots: has-flag@4.0.0: {} + has-flag@5.0.1: {} + has-property-descriptors@1.0.2: dependencies: es-define-property: 1.0.1 @@ -9408,6 +9786,10 @@ snapshots: dependencies: has-symbols: 1.1.0 + hashery@1.5.0: + dependencies: + hookified: 1.15.1 + hasown@2.0.2: dependencies: function-bind: 1.1.2 @@ -9422,6 +9804,8 @@ snapshots: highlight.js@11.11.1: {} + hookified@1.15.1: {} + hosted-git-info@2.8.9: {} html-encoding-sniffer@6.0.0: @@ -9432,6 +9816,8 @@ snapshots: html-escaper@2.0.2: {} + html-tags@5.1.0: {} + http-errors@2.0.1: dependencies: depd: 2.0.0 @@ -9476,6 +9862,8 @@ snapshots: ignore@5.3.2: {} + ignore@7.0.5: {} + immutable@3.7.6: {} immutable@5.1.4: {} @@ -9489,6 +9877,8 @@ snapshots: import-lazy@4.0.0: {} + import-meta-resolve@4.2.0: {} + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -9609,8 +9999,12 @@ snapshots: is-number@7.0.0: {} + is-path-inside@4.0.0: {} + is-plain-obj@4.1.0: {} + is-plain-object@5.0.0: {} + is-potential-custom-element-name@1.0.1: {} is-promise@4.0.0: {} @@ -9806,6 +10200,8 @@ snapshots: json-parse-better-errors@1.0.2: {} + json-parse-even-better-errors@2.3.1: {} + json-schema-traverse@0.4.1: {} json-schema-traverse@1.0.0: {} @@ -9845,10 +10241,18 @@ snapshots: dependencies: json-buffer: 3.0.1 + keyv@5.6.0: + dependencies: + '@keyv/serialize': 1.1.1 + + kind-of@6.0.3: {} + klaw-sync@6.0.0: dependencies: graceful-fs: 4.2.11 + known-css-properties@0.37.0: {} + kolorist@1.8.0: {} kuler@2.0.0: {} @@ -9913,6 +10317,8 @@ snapshots: lightningcss-win32-arm64-msvc: 1.32.0 lightningcss-win32-x64-msvc: 1.32.0 + lines-and-columns@1.2.4: {} + load-json-file@4.0.0: dependencies: graceful-fs: 4.2.11 @@ -9945,6 +10351,8 @@ snapshots: lodash.merge@4.6.2: {} + lodash.truncate@4.4.2: {} + lodash@4.17.23: {} lodash@4.18.1: {} @@ -10012,6 +10420,8 @@ snapshots: math-intrinsics@1.1.0: {} + mathml-tag-names@4.0.0: {} + mdn-data@2.0.14: {} mdn-data@2.0.28: {} @@ -10041,6 +10451,8 @@ snapshots: memorystream@0.3.1: {} + meow@14.1.0: {} + merge-descriptors@2.0.0: {} merge-stream@2.0.0: {} @@ -10341,6 +10753,13 @@ snapshots: error-ex: 1.3.4 json-parse-better-errors: 1.0.2 + parse-json@5.2.0: + dependencies: + '@babel/code-frame': 7.29.0 + error-ex: 1.3.4 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + parse5@8.0.0: dependencies: entities: 6.0.1 @@ -10466,6 +10885,8 @@ snapshots: clean-css: 4.2.4 postcss: 6.0.23 + postcss-media-query-parser@0.2.3: {} + postcss-modules-extract-imports@3.1.0(postcss@8.5.8): dependencies: postcss: 8.5.8 @@ -10499,6 +10920,16 @@ snapshots: postcss-modules-values: 4.0.0(postcss@8.5.8) string-hash: 1.1.3 + postcss-resolve-nested-selector@0.1.6: {} + + postcss-safe-parser@7.0.1(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + + postcss-scss@4.0.9(postcss@8.5.8): + dependencies: + postcss: 8.5.8 + postcss-selector-parser@7.1.1: dependencies: cssesc: 3.0.0 @@ -10595,6 +11026,10 @@ snapshots: punycode@2.3.1: {} + qified@0.6.0: + dependencies: + hookified: 1.15.1 + qs@6.14.1: dependencies: side-channel: 1.1.0 @@ -11117,6 +11552,14 @@ snapshots: slash@2.0.0: {} + slash@5.1.0: {} + + slice-ansi@4.0.0: + dependencies: + ansi-styles: 4.3.0 + astral-regex: 2.0.0 + is-fullwidth-code-point: 3.0.0 + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -11203,6 +11646,11 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.2 + string-width@8.2.0: + dependencies: + get-east-asian-width: 1.5.0 + strip-ansi: 7.1.2 + string.prototype.codepointat@0.2.1: {} string.prototype.includes@2.0.1: @@ -11317,6 +11765,93 @@ snapshots: transitivePeerDependencies: - tslib + stylelint-config-recommended-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)): + dependencies: + postcss-scss: 4.0.9(postcss@8.5.8) + stylelint: 17.4.0(typescript@6.0.2) + stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@6.0.2)) + stylelint-scss: 7.0.0(stylelint@17.4.0(typescript@6.0.2)) + optionalDependencies: + postcss: 8.5.8 + + stylelint-config-recommended@18.0.0(stylelint@17.4.0(typescript@6.0.2)): + dependencies: + stylelint: 17.4.0(typescript@6.0.2) + + stylelint-config-standard-scss@17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)): + dependencies: + stylelint: 17.4.0(typescript@6.0.2) + stylelint-config-recommended-scss: 17.0.0(postcss@8.5.8)(stylelint@17.4.0(typescript@6.0.2)) + stylelint-config-standard: 40.0.0(stylelint@17.4.0(typescript@6.0.2)) + optionalDependencies: + postcss: 8.5.8 + + stylelint-config-standard@40.0.0(stylelint@17.4.0(typescript@6.0.2)): + dependencies: + stylelint: 17.4.0(typescript@6.0.2) + stylelint-config-recommended: 18.0.0(stylelint@17.4.0(typescript@6.0.2)) + + stylelint-scss@7.0.0(stylelint@17.4.0(typescript@6.0.2)): + dependencies: + css-tree: 3.1.0 + is-plain-object: 5.0.0 + known-css-properties: 0.37.0 + mdn-data: 2.27.1 + postcss-media-query-parser: 0.2.3 + postcss-resolve-nested-selector: 0.1.6 + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + stylelint: 17.4.0(typescript@6.0.2) + + stylelint-use-logical-spec@5.0.1(stylelint@17.4.0(typescript@6.0.2)): + dependencies: + stylelint: 17.4.0(typescript@6.0.2) + + stylelint@17.4.0(typescript@6.0.2): + dependencies: + '@csstools/css-calc': 3.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0) + '@csstools/css-syntax-patches-for-csstree': 1.1.0 + '@csstools/css-tokenizer': 4.0.0 + '@csstools/media-query-list-parser': 5.0.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0) + '@csstools/selector-resolve-nested': 4.0.0(postcss-selector-parser@7.1.1) + '@csstools/selector-specificity': 6.0.0(postcss-selector-parser@7.1.1) + colord: 2.9.3 + cosmiconfig: 9.0.1(typescript@6.0.2) + css-functions-list: 3.3.3 + css-tree: 3.1.0 + debug: 4.4.3(supports-color@5.5.0) + fast-glob: 3.3.3 + fastest-levenshtein: 1.0.16 + file-entry-cache: 11.1.2 + global-modules: 2.0.0 + globby: 16.1.1 + globjoin: 0.1.4 + html-tags: 5.1.0 + ignore: 7.0.5 + import-meta-resolve: 4.2.0 + imurmurhash: 0.1.4 + is-plain-object: 5.0.0 + mathml-tag-names: 4.0.0 + meow: 14.1.0 + micromatch: 4.0.8 + normalize-path: 3.0.0 + picocolors: 1.1.1 + postcss: 8.5.8 + postcss-safe-parser: 7.0.1(postcss@8.5.8) + postcss-selector-parser: 7.1.1 + postcss-value-parser: 4.2.0 + string-width: 8.2.0 + supports-hyperlinks: 4.4.0 + svg-tags: 1.0.0 + table: 6.9.0 + write-file-atomic: 7.0.1 + transitivePeerDependencies: + - supports-color + - typescript + + supports-color@10.2.2: {} + supports-color@5.5.0: dependencies: has-flag: 3.0.0 @@ -11329,6 +11864,11 @@ snapshots: dependencies: has-flag: 4.0.0 + supports-hyperlinks@4.4.0: + dependencies: + has-flag: 5.0.1 + supports-color: 10.2.2 + supports-preserve-symlinks-flag@1.0.0: {} svg-sprite@2.0.4: @@ -11351,6 +11891,8 @@ snapshots: xpath: 0.0.34 yargs: 17.7.2 + svg-tags@1.0.0: {} + svgo@2.8.0: dependencies: '@trysound/sax': 0.2.0 @@ -11377,6 +11919,14 @@ snapshots: sync-message-port@1.2.0: {} + table@6.9.0: + dependencies: + ajv: 8.13.0 + lodash.truncate: 4.4.2 + slice-ansi: 4.0.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + tar-fs@2.1.4: dependencies: chownr: 1.1.4 @@ -11591,6 +12141,8 @@ snapshots: undici@7.24.7: {} + unicorn-magic@0.4.0: {} + universalify@2.0.1: {} unpipe@1.0.0: {} @@ -11938,6 +12490,10 @@ snapshots: wrappy@1.0.2: {} + write-file-atomic@7.0.1: + dependencies: + signal-exit: 4.1.0 + ws@8.19.0: {} ws@8.20.0: {} diff --git a/frontend/resources/images/assets/nitrate-welcome.svg b/frontend/resources/images/assets/nitrate-welcome.svg new file mode 100644 index 0000000000..18ced86fa1 --- /dev/null +++ b/frontend/resources/images/assets/nitrate-welcome.svg @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/resources/images/icons/stroke-center.svg b/frontend/resources/images/icons/stroke-center.svg new file mode 100644 index 0000000000..a00cdf58df --- /dev/null +++ b/frontend/resources/images/icons/stroke-center.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/resources/images/icons/stroke-dashed.svg b/frontend/resources/images/icons/stroke-dashed.svg new file mode 100644 index 0000000000..40c3bdcae1 --- /dev/null +++ b/frontend/resources/images/icons/stroke-dashed.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/resources/images/icons/stroke-dotted.svg b/frontend/resources/images/icons/stroke-dotted.svg new file mode 100644 index 0000000000..8b3c1940e3 --- /dev/null +++ b/frontend/resources/images/icons/stroke-dotted.svg @@ -0,0 +1,3 @@ + + + \ No newline at end of file diff --git a/frontend/resources/images/icons/stroke-inside.svg b/frontend/resources/images/icons/stroke-inside.svg new file mode 100644 index 0000000000..21f2eb1c52 --- /dev/null +++ b/frontend/resources/images/icons/stroke-inside.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/resources/images/icons/stroke-mixed.svg b/frontend/resources/images/icons/stroke-mixed.svg new file mode 100644 index 0000000000..56070d56ee --- /dev/null +++ b/frontend/resources/images/icons/stroke-mixed.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/frontend/resources/images/icons/stroke-outside.svg b/frontend/resources/images/icons/stroke-outside.svg new file mode 100644 index 0000000000..0f4dec0924 --- /dev/null +++ b/frontend/resources/images/icons/stroke-outside.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/resources/images/icons/stroke-solid.svg b/frontend/resources/images/icons/stroke-solid.svg new file mode 100644 index 0000000000..a9bba0e9b9 --- /dev/null +++ b/frontend/resources/images/icons/stroke-solid.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/resources/images/newsletter-notification.svg b/frontend/resources/images/newsletter-notification.svg new file mode 100644 index 0000000000..395e291284 --- /dev/null +++ b/frontend/resources/images/newsletter-notification.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/resources/styles/common/base.scss b/frontend/resources/styles/common/base.scss index 0d61ab7ebb..37df67b3e8 100644 --- a/frontend/resources/styles/common/base.scss +++ b/frontend/resources/styles/common/base.scss @@ -27,6 +27,14 @@ body { width: 100vw; height: 100vh; overflow: hidden; + + &.cursor-drag-scrub { + cursor: ew-resize !important; + + * { + cursor: ew-resize !important; + } + } } #app { @@ -113,16 +121,15 @@ hr { input[type="number"]::-webkit-inner-spin-button, input[type="number"]::-webkit-outer-spin-button { - -webkit-appearance: none; + appearance: none; margin: 0; } input[type="number"] { - -moz-appearance: textfield; + appearance: textfield; } [contenteditable] { - -webkit-user-select: text; user-select: text; } @@ -132,15 +139,12 @@ select { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs14; margin-bottom: $size-4; - -webkit-appearance: none; - -moz-appearance: none; + appearance: none; } [draggable] { - -moz-user-select: none; - -khtml-user-select: none; - -webkit-user-select: none; user-select: none; + /* Required to make elements draggable in old WebKit */ -khtml-user-drag: element; -webkit-user-drag: element; diff --git a/frontend/resources/styles/common/dependencies/_hljs-dark-theme.scss b/frontend/resources/styles/common/dependencies/_hljs-dark-theme.scss index ddfa3a09e7..4654c54c94 100644 --- a/frontend/resources/styles/common/dependencies/_hljs-dark-theme.scss +++ b/frontend/resources/styles/common/dependencies/_hljs-dark-theme.scss @@ -82,7 +82,7 @@ .hljs-section { /* prettylights-syntax-markup-heading */ color: #316dca; - font-weight: bold; + font-weight: 700; } .hljs-bullet { @@ -99,7 +99,7 @@ .hljs-strong { /* prettylights-syntax-markup-bold */ color: #adbac7; - font-weight: bold; + font-weight: 700; } .hljs-addition { diff --git a/frontend/resources/styles/common/dependencies/_hljs-light-theme.scss b/frontend/resources/styles/common/dependencies/_hljs-light-theme.scss index ea2d601f76..78397f2cf4 100644 --- a/frontend/resources/styles/common/dependencies/_hljs-light-theme.scss +++ b/frontend/resources/styles/common/dependencies/_hljs-light-theme.scss @@ -11,7 +11,7 @@ .hljs { color: #24292e; - background: #ffffff; + background: #fff; } .hljs-doctag, @@ -83,7 +83,7 @@ .hljs-section { /* prettylights-syntax-markup-heading */ color: #005cc5; - font-weight: bold; + font-weight: 700; } .hljs-bullet { @@ -100,7 +100,7 @@ .hljs-strong { /* prettylights-syntax-markup-bold */ color: #24292e; - font-weight: bold; + font-weight: 700; } .hljs-addition { diff --git a/frontend/resources/styles/common/dependencies/animations.scss b/frontend/resources/styles/common/dependencies/animations.scss index ea30c21e10..ac8b99d7d9 100644 --- a/frontend/resources/styles/common/dependencies/animations.scss +++ b/frontend/resources/styles/common/dependencies/animations.scss @@ -7,13 +7,11 @@ */ .animated { - -webkit-animation-duration: 1s; animation-duration: 1s; - -webkit-animation-fill-mode: both; animation-fill-mode: both; } -@-webkit-keyframes fadeIn { +@keyframes fade-in { 0% { opacity: 0; } @@ -23,79 +21,22 @@ } } -@keyframes fadeIn { - 0% { - opacity: 0; - } - - 100% { - opacity: 1; - } +.fade-in { + animation-name: fade-in; } -.fadeIn { - -webkit-animation-name: fadeIn; - animation-name: fadeIn; -} - -@-webkit-keyframes fadeInDown { +@keyframes fade-in-down { 0% { opacity: 0; - -webkit-transform: translate3d(0, -100%, 0); transform: translate3d(0, -100%, 0); } 100% { opacity: 1; - -webkit-transform: none; transform: none; } } -@keyframes fadeInDown { - 0% { - opacity: 0; - -webkit-transform: translate3d(0, -100%, 0); - transform: translate3d(0, -100%, 0); - } - - 100% { - opacity: 1; - -webkit-transform: none; - transform: none; - } -} - -.fadeInDown { - -webkit-animation-name: fadeInDown; - animation-name: fadeInDown; -} - -@keyframes loaderColor { - 0% { - fill: #513b56; - } - - 33% { - fill: #348aa7; - } - - 66% { - fill: #5dd39e; - } - - 100% { - fill: #513b56; - } -} - -//pencil loader animation -@keyframes linePencil { - 0% { - transform: translateY(0); - } - - 100% { - transform: translateY(-150px); - } +.fade-in-down { + animation-name: fade-in-down; } diff --git a/frontend/resources/styles/common/dependencies/fonts.scss b/frontend/resources/styles/common/dependencies/fonts.scss index 146f7099fd..ed4e821f7e 100644 --- a/frontend/resources/styles/common/dependencies/fonts.scss +++ b/frontend/resources/styles/common/dependencies/fonts.scss @@ -10,7 +10,7 @@ $style-name, $file, $unicode-range, - $weight: unquote("normal"), + $weight: string.unquote("normal"), $style: string.unquote("normal") ) { $filepath: "../fonts/" + $file; @@ -22,6 +22,7 @@ url($filepath + ".ttf") format("truetype"); font-weight: string.unquote($weight); font-style: string.unquote($style); + @if $unicode-range { unicode-range: $unicode-range; } diff --git a/frontend/resources/styles/common/dependencies/highlight.scss b/frontend/resources/styles/common/dependencies/highlight.scss index 9d53084cb7..a7bebe984c 100644 --- a/frontend/resources/styles/common/dependencies/highlight.scss +++ b/frontend/resources/styles/common/dependencies/highlight.scss @@ -7,9 +7,9 @@ @use "sass:meta"; :root { - @include meta.load-css("./_hljs-dark-theme.scss"); + @include meta.load-css("./_hljs-dark-theme"); } .light { - @include meta.load-css("./_hljs-light-theme.scss"); + @include meta.load-css("./_hljs-light-theme"); } diff --git a/frontend/resources/styles/common/dependencies/reset.scss b/frontend/resources/styles/common/dependencies/reset.scss index 39e198d8d7..d86c883697 100644 --- a/frontend/resources/styles/common/dependencies/reset.scss +++ b/frontend/resources/styles/common/dependencies/reset.scss @@ -11,12 +11,13 @@ License: none (public domain) div { vertical-align: top; } + img { display: block; } // #Reset & Basics (Inspired by E. Meyers) -//================================================== +// ================================================== a, abbr, acronym, @@ -100,7 +101,9 @@ var, video { border: 0; font: inherit; + /* stylelint-disable-next-line declaration-property-unit-allowed-list */ font-size: 100%; + // TODO: Changing line-height to 1 (as it should be) makes the visual tests // fail with a max pixel diff ratio of 0.005. // We should tackle this later. @@ -124,6 +127,7 @@ nav, section { display: block; } + body { line-height: 1; } @@ -138,10 +142,10 @@ q { quotes: none; } -blockquote:before, -blockquote:after, -q:before, -q:after { +blockquote::before, +blockquote::after, +q::before, +q::after { content: ""; } @@ -151,5 +155,5 @@ table { } select { - -webkit-appearance: none; + appearance: none; } diff --git a/frontend/resources/styles/common/refactor/animations.scss b/frontend/resources/styles/common/refactor/animations.scss index 44cdf2ee65..a35832ba1a 100644 --- a/frontend/resources/styles/common/refactor/animations.scss +++ b/frontend/resources/styles/common/refactor/animations.scss @@ -5,16 +5,6 @@ // Copyright (c) KALEIDOS INC @mixin animation($delay, $duration, $animation) { - -webkit-animation-delay: $delay; - -webkit-animation-duration: $duration; - -webkit-animation-name: $animation; - -webkit-animation-fill-mode: both; - - -moz-animation-delay: $delay; - -moz-animation-duration: $duration; - -moz-animation-name: $animation; - -moz-animation-fill-mode: both; - animation-delay: $delay; animation-duration: $duration; animation-name: $animation; diff --git a/frontend/resources/styles/common/refactor/basic-rules.scss b/frontend/resources/styles/common/refactor/basic-rules.scss index 91068275cc..efac327f36 100644 --- a/frontend/resources/styles/common/refactor/basic-rules.scss +++ b/frontend/resources/styles/common/refactor/basic-rules.scss @@ -12,11 +12,12 @@ @use "./z-index.scss" as *; // SCROLLBAR -.new-scrollbar { +%new-scrollbar { scrollbar-width: thin; - scrollbar-color: rgba(170, 181, 186, 0.3) transparent; + scrollbar-color: rgb(170 181 186 / 0.3) transparent; + &:hover { - scrollbar-color: rgba(170, 181, 186, 0.7) transparent; + scrollbar-color: rgb(170 181 186 / 0.7) transparent; } // These rules do not apply in chrome - 121 or higher @@ -27,18 +28,20 @@ height: $s-12; width: $s-12; } + ::-webkit-scrollbar-track, ::-webkit-scrollbar-corner { background-color: transparent; } ::-webkit-scrollbar-thumb { - background-color: rgba(170, 181, 186, 0.3); + background-color: rgb(170 181 186 / 0.3); background-clip: content-box; border: $s-2 solid transparent; border-radius: $br-8; + &:hover { - background-color: rgba(170, 181, 186, 0.7); + background-color: rgb(170 181 186 / 0.7); outline: none; } } @@ -48,48 +51,53 @@ color: var(--text-editor-selection-foreground-color); } - ::placeholder, - ::-webkit-input-placeholder { - @include bodySmallTypography; + ::placeholder { + @include body-small-typography; + color: var(--input-placeholder-color); } } // BUTTONS -.button-primary { - @include buttonStyle; - @include flexCenter; - @include headlineSmallTypography; +%button-primary { + @include button-style; + @include flex-center; + @include headline-small-typography; + background-color: var(--button-primary-background-color-rest); border: $s-1 solid var(--button-primary-border-color-rest); color: var(--button-primary-foreground-color-rest); border-radius: $br-8; min-height: $s-32; - svg, - span svg { + + svg { stroke: var(--button-primary-foreground-color-rest); } - @include focusPrimary; + + @include focus-primary; + &:hover { background-color: var(--button-primary-background-color-hover); border: $s-1 solid var(--button-primary-border-color-hover); color: var(--button-primary-foreground-color-hover); text-decoration: none; - svg, - span svg { + + svg { stroke: var(--button-primary-foreground-color-hover); } } + &:active { background-color: var(--button-primary-background-color-active); border: $s-1 solid var(--button-primary-border-color-active); color: var(--button-primary-foreground-color-active); outline: none; - svg, - span svg { + + svg { stroke: var(--button-primary-foreground-color-active); } } + &:global(.disabled), &[disabled], &:disabled { @@ -100,38 +108,43 @@ } } -.button-secondary { - @include buttonStyle; - @include flexCenter; +%button-secondary { + @include button-style; + @include flex-center; + border-radius: $br-8; background-color: var(--button-secondary-background-color-rest); border: $s-1 solid var(--button-secondary-border-color-rest); color: var(--button-secondary-foreground-color-rest); - svg, - span svg { + + svg { stroke: var(--button-secondary-foreground-color-rest); } - @include focusSecondary; + + @include focus-secondary; + &:hover { background-color: var(--button-secondary-background-color-hover); border: $s-1 solid var(--button-secondary-border-color-hover); color: var(--button-secondary-foreground-color-hover); text-decoration: none; - svg, - span svg { + + svg { stroke: var(--button-secondary-foreground-color-hover); } } + &:active { outline: none; background-color: var(--button-secondary-background-color-active); border: $s-1 solid var(--button-secondary-border-color-active); color: var(--button-secondary-foreground-color-active); - svg, - span svg { + + svg { stroke: var(--button-secondary-foreground-color-active); } } + &:global(.disabled), &[disabled], &:disabled { @@ -142,37 +155,42 @@ } } -.button-tertiary { - @include buttonStyle; - @include flexCenter; +%button-tertiary { + @include button-style; + @include flex-center; + --button-tertiary-border-width: #{$s-2}; + border-radius: $br-8; color: var(--button-tertiary-foreground-color-rest); background-color: transparent; border: var(--button-tertiary-border-width) solid transparent; display: grid; place-content: center; - svg, - span svg { + + svg { stroke: var(--button-tertiary-foreground-color-rest); } - @include focusTertiary; + + @include focus-tertiary; + &:hover { background-color: var(--button-tertiary-background-color-hover); color: var(--button-tertiary-foreground-color-hover); border-color: var(--button-secondary-border-color-hover); - svg, - span svg { + + svg { stroke: var(--button-tertiary-foreground-color-hover); } } + &:active { outline: none; border-color: transparent; background-color: var(--button-tertiary-background-color-active); color: var(--button-tertiary-foreground-color-active); - svg, - span svg { + + svg { stroke: var(--button-tertiary-foreground-color-active); } } @@ -184,89 +202,98 @@ cursor: unset; pointer-events: none; - svg, - span svg { + svg { stroke: var(--button-foreground-color-disabled); } } } -.button-icon-selected { +%button-icon-selected { outline: none; border-color: var(--button-icon-border-color-selected); background-color: var(--button-icon-background-color-selected); color: var(--button-icon-foreground-color-selected); + svg { stroke: var(--button-icon-foreground-color-selected); } } .button-radio { - @include buttonStyle; - @include flexCenter; + @include button-style; + @include flex-center; + border-radius: $br-8; color: var(--button-radio-foreground-color-rest); border-color: $s-1 solid var(--button-radio-background-color-rest); - svg, - span svg { + + svg { stroke: var(--button-radio-foreground-color-rest); } - @include focusRadio; + + @include focus-radio; + &:hover { background-color: var(--button-radio-background-color-rest); color: var(--button-radio-foreground-color-hover); border: $s-1 solid transparent; - svg, - span svg { + + svg { stroke: var(--button-radio-foreground-color-hover); } } + &:active { outline: none; border: $s-1 solid transparent; background-color: var(--button-radio-background-color-active); color: var(--button-radio-foreground-color-active); - svg, - span svg { + + svg { stroke: var(--button-radio-foreground-color-active); } } } .button-warning { - @include buttonStyle; - @include flexCenter; + @include button-style; + @include flex-center; + background-color: var(--button-warning-background-color-rest); border: $s-1 solid var(--button-warning-border-color-rest); color: var(--button-warning-foreground-color-rest); } -.button-disabled { - @include buttonStyle; - @include flexCenter; +%button-disabled { + @include button-style; + @include flex-center; + background-color: var(--button-background-color-disabled); border: $s-1 solid var(--button-border-color-disabled); color: var(--button-foreground-color-disabled); cursor: unset; } -.button-tag { - @include buttonStyle; - @include flexCenter; +%button-tag { + @include button-style; + @include flex-center; @include focus; + &:hover { svg { stroke: var(--title-foreground-color-hover); } } + &:active { border: none; background-color: transparent; } } -.button-icon { - @include flexCenter; +%button-icon { + @include flex-center; + height: $s-16; width: $s-16; color: transparent; @@ -274,21 +301,24 @@ stroke-width: 1px; } -.button-icon-small { - @extend .button-icon; +%button-icon-small { + @extend %button-icon; + height: $s-12; width: $s-12; stroke-width: 1.33px; } .button-constraint { - @include buttonStyle; + @include button-style; + width: $s-32; height: $s-4; border-radius: $br-8; background-color: var(--button-constraint-background-color-rest); padding: 0; margin: 0; + &:hover { outline: $s-4 solid var(--button-constraint-border-color-hover); background-color: var(--button-constraint-background-color-hover); @@ -296,9 +326,10 @@ } // INPUTS -.input-base { - @include removeInputStyle; - @include textEllipsis; +%input-base { + @include remove-input-style; + @include text-ellipsis; + height: $s-28; width: 100%; flex-grow: 1; @@ -306,6 +337,7 @@ padding: 0 0 0 $s-6; border-radius: $br-8; color: var(--input-foreground-color-active); + &[disabled] { opacity: 0.5; pointer-events: none; @@ -313,24 +345,31 @@ } .input-icon { - @include flexCenter; + @include flex-center; + min-width: $s-12; height: $s-32; + svg { - @extend .button-icon-small; + @extend %button-icon-small; } } -.input-label { - @include headlineSmallTypography; - @include flexCenter; +%input-label { + @include headline-small-typography; + @include flex-center; + width: $s-20; padding-left: $s-8; height: $s-32; color: var(--input-foreground-color); } -.input-element { +.input-label { + @extend %input-label; +} + +%input-element { display: flex; align-items: center; height: $s-32; @@ -338,60 +377,83 @@ background-color: var(--input-background-color); border: $s-1 solid var(--input-border-color); color: var(--input-foreground-color); + + &:not(:focus-within) { + cursor: ew-resize; + + input { + cursor: ew-resize; + } + } + span, label { - @extend .input-label; + @extend %input-label; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--input-foreground-color); } } input { - @extend .input-base; + @extend %input-base; } ::placeholder { color: var(--input-placeholder-color); } - @include focusInput; + @include focus-input; + &:hover { border: $s-1 solid var(--input-border-color-hover); background-color: var(--input-background-color-hover); + span { color: var(--input-foreground-color-hover); } + input { color: var(--input-foreground-color-hover); } } + &:active { border: $s-1 solid var(--input-border-color-active); background-color: var(--input-background-color-active); + span { color: var(--input-foreground-color-active); } + input { color: var(--input-foreground-color-active); } } + &:focus, &:focus-within { border: $s-1 solid var(--input-border-color-focus); background-color: var(--input-background-color-focus); + span { color: var(--input-foreground-color-focus); } + input { color: var(--input-foreground-color-focus); } + &:hover { border: $s-1 solid var(--input-border-color-focus); background-color: var(--input-background-color-focus); + span { color: var(--input-foreground-color-focus); } + input { color: var(--input-foreground-color-focus); } @@ -399,13 +461,16 @@ } } -.input-element-label { - @include bodySmallTypography; +%input-element-label { + @include body-small-typography; + display: flex; align-items: flex-start; padding: 0; + input { - @extend .input-base; + @extend %input-base; + padding-left: $s-8; display: flex; align-items: flex-start; @@ -418,10 +483,13 @@ color: var(--input-foreground-color-active); background-color: var(--input-background-color); } + ::placeholder { - @include bodySmallTypography; + @include body-small-typography; + color: var(--input-placeholder-color); } + &:hover { input { color: var(--input-foreground-color-active); @@ -439,22 +507,25 @@ } } -.disabled-input { +%disabled-input { background-color: var(--input-background-color-disabled); border: $s-1 solid var(--input-border-color-disabled); color: var(--input-foreground-color-disabled); + input { pointer-events: none; cursor: default; color: var(--input-foreground-color-disabled); } - span svg { + + svg { stroke: var(--input-foreground-color-disabled); } } -.checkbox-icon { - @include flexCenter; +%checkbox-icon { + @include flex-center; + width: $s-16; height: $s-16; min-width: $s-16; @@ -462,15 +533,18 @@ background-color: var(--input-checkbox-background-color-rest); border: $s-1 solid var(--input-checkbox-border-color-rest); border-radius: $br-4; + svg { width: $s-16; height: $s-16; display: none; stroke: var(--input-checkbox-inactive-foreground-color); } + &:hover { border-color: var(--input-checkbox-border-color-hover); } + &:focus { border-color: var(--input-checkbox-border-color-focus); } @@ -478,8 +552,10 @@ &:global(.checked) { border-color: var(--input-checkbox-border-color-active); background-color: var(--input-checkbox-background-color-active); + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--input-checkbox-foreground-color-active); } } @@ -487,8 +563,10 @@ &:global(.intermediate) { background-color: var(--input-checkbox-background-color-intermediate); border-color: var(--input-checkbox-border-color-intermediate); + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--input-checkbox-foreground-color-intermediate); } } @@ -496,28 +574,34 @@ &:global(.unchecked) { background-color: var(--input-checkbox-background-color-rest); border: $s-1 solid var(--input-checkbox-background-color-rest); + svg { display: none; } } } -.input-checkbox { +%input-checkbox { display: flex; align-items: center; + label { - @include bodySmallTypography; + @include body-small-typography; + display: flex; align-items: center; gap: $s-6; cursor: pointer; color: var(--input-checkbox-text-foreground-color); + span { - @extend .checkbox-icon; + @extend %checkbox-icon; } + input { margin: 0; } + &:hover { span { border-color: var(--input-checkbox-border-color-hover); @@ -533,11 +617,13 @@ } } -.input-with-label { +%input-with-label { display: flex; flex-direction: column; + label { - @include bodySmallTypography; + @include body-small-typography; + display: flex; flex-direction: column; justify-content: flex-start; @@ -546,8 +632,9 @@ } input { - @extend .input-base; - @include bodySmallTypography; + @extend %input-base; + @include body-small-typography; + border-radius: $br-8; height: $s-32; min-height: $s-32; @@ -555,17 +642,20 @@ background-color: var(--input-background-color); border: $s-1 solid var(--input-border-color); color: var(--input-foreground-color-active); + &:focus-within, &:active { input { color: var(--input-foreground-color-active); } + background-color: var(--input-background-color-active); border: $s-1 solid var(--input-border-color-active); } } + &:global(.disabled) { - @extend .disabled-input; + @extend %disabled-input; } &:global(.invalid) { @@ -575,9 +665,10 @@ } } -//MODALS -.modal-background { - @include menuShadow; +// MODALS +%modal-background { + @include menu-shadow; + position: absolute; display: flex; flex-direction: column; @@ -588,8 +679,9 @@ background-color: var(--modal-background-color); } -.modal-overlay-base { - @include flexCenter; +%modal-overlay-base { + @include flex-center; + position: fixed; left: 0; top: 0; @@ -599,7 +691,7 @@ background-color: var(--overlay-color); } -.modal-container-base { +%modal-container-base { position: relative; padding: $s-32; border-radius: $br-8; @@ -611,52 +703,58 @@ max-height: $s-512; } -.modal-close-btn-base { - @extend .button-tertiary; +%modal-close-btn-base { + @extend %button-tertiary; + position: absolute; top: $s-8; right: $s-6; height: $s-32; width: $s-28; + svg { - @extend .button-icon; + @extend %button-icon; } } .modal-hint-base { - @include bodySmallTypography; + @include body-small-typography; + color: var(--modal-title-foreground-color); border-top: $s-1 solid var(--modal-hint-border-color); border-bottom: $s-1 solid var(--modal-hint-border-color); } -.modal-action-btns { +%modal-action-btns { display: flex; justify-content: flex-end; gap: $s-16; } -.modal-cancel-btn { - @extend .button-secondary; - @include uppercaseTitleTipography; +%modal-cancel-btn { + @extend %button-secondary; + @include uppercase-title-typography; + padding: $s-8 $s-24; border-radius: $br-8; height: $s-32; margin: 0; } -.modal-accept-btn { - @extend .button-primary; - @include uppercaseTitleTipography; +%modal-accept-btn { + @extend %button-primary; + @include uppercase-title-typography; + padding: $s-8 $s-24; border-radius: $br-8; height: $s-32; margin: 0; } -.modal-danger-btn { - @extend .button-primary; - @include uppercaseTitleTipography; +%modal-danger-btn { + @extend %button-primary; + @include uppercase-title-typography; + padding: $s-8 $s-24; border-radius: $br-8; height: $s-32; @@ -670,8 +768,9 @@ // FIXME: This is used multiple times accross the app. We should design this in // the DS and create a proper component for it. -.asset-element { - @include bodySmallTypography; +%asset-element { + @include body-small-typography; + display: flex; align-items: center; height: $s-32; @@ -679,29 +778,33 @@ padding: $s-8 $s-12; background-color: var(--assets-item-background-color); color: var(--assets-item-name-foreground-color-hover); + &:hover { background-color: var(--assets-item-background-color-hover); color: var(--assets-item-name-foreground-color-hover); } } -.shortcut-base { - @include flexCenter; +%shortcut-base { + @include flex-center; + gap: $s-2; color: var(--menu-shortcut-foreground-color); } -.shortcut-key-base { - @include bodySmallTypography; - @include flexCenter; +%shortcut-key-base { + @include body-small-typography; + @include flex-center; + height: $s-20; padding: $s-2 $s-6; border-radius: $br-6; background-color: var(--menu-shortcut-background-color); } -.mixed-bar { - @include bodySmallTypography; +%mixed-bar { + @include body-small-typography; + display: flex; align-items: center; flex-grow: 1; @@ -712,7 +815,7 @@ color: var(--input-foreground-color-active); } -.link { +%link { background: unset; border: none; color: var(--link-foreground-color); @@ -720,7 +823,7 @@ text-decoration: none; } -.colorpicker-handler { +%colorpicker-handler { position: absolute; left: 50%; top: 50%; @@ -730,25 +833,31 @@ border-radius: $br-circle; transform: translate(calc(-1 * $s-12), calc(-1 * $s-12)); z-index: $z-index-1; + &:hover, &:active { border-color: var(--colorpicker-details-color-selected); } } -.attr-title { +%attr-title { div { margin-left: 0; color: var(--entry-foreground-color-hover); } + button { - @extend .button-tertiary; + @extend %button-tertiary; + display: none; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } + &:hover { button { display: flex; @@ -756,15 +865,17 @@ } } -.attr-row { +%attr-row { display: grid; grid-template-areas: "name content"; grid-template-columns: 1fr 3fr; gap: $s-4; height: $s-32; + :global(.attr-label) { - @include bodySmallTypography; - @include twoLineTextEllipsis; + @include body-small-typography; + @include two-line-text-ellipsis; + width: $s-92; margin: auto 0; color: var(--entry-foreground-color); @@ -775,17 +886,20 @@ grid-area: content; display: flex; color: var(--entry-foreground-color-hover); - @include bodySmallTypography; + + @include body-small-typography; } } -.copy-button-children { - @include bodySmallTypography; +%copy-button-children { + @include body-small-typography; + color: var(--color-foreground-primary); text-align: left; margin: 0; padding: 0; height: fit-content; + &:hover { div { color: var(--entry-foreground-color-hover); @@ -794,9 +908,10 @@ } // SELECTS AND DROPDOWNS -.menu-dropdown { - @include menuShadow; - @include flexColumn; +%menu-dropdown { + @include menu-shadow; + @include flex-column; + position: absolute; padding: $s-4; border-radius: $br-8; @@ -807,8 +922,9 @@ margin: 0; } -.menu-item-base { - @include bodySmallTypography; +%menu-item-base { + @include body-small-typography; + display: flex; align-items: center; justify-content: space-between; @@ -817,13 +933,15 @@ padding: $s-6; border-radius: $br-8; cursor: pointer; + &:hover { background-color: var(--menu-background-color-hover); } } -.dropdown-element-base { - @include bodySmallTypography; +%dropdown-element-base { + @include body-small-typography; + display: flex; align-items: center; gap: $s-8; @@ -834,24 +952,29 @@ color: var(--menu-foreground-color-rest); span { - @include flexCenter; - @include textEllipsis; + @include flex-center; + @include text-ellipsis; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } + &:hover { background-color: var(--menu-background-color-hover); color: var(--menu-foreground-color); - span svg { + + svg { stroke: var(--menu-foreground-color-hover); } } } -.dropdown-wrapper { - @include menuShadow; +%dropdown-wrapper { + @include menu-shadow; + position: absolute; top: $s-32; left: 0; @@ -862,15 +985,15 @@ margin-top: $s-1; border-radius: $br-8; z-index: $z-index-4; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; background-color: var(--menu-background-color); color: var(--menu-foreground-color); border: $s-2 solid var(--panel-border-color); } -.select-wrapper { - @include bodySmallTypography; +%select-wrapper { + @include body-small-typography; + position: relative; display: flex; align-items: center; diff --git a/frontend/resources/styles/common/refactor/color-defs.scss b/frontend/resources/styles/common/refactor/color-defs.scss index f3f1df5e20..47f933d991 100644 --- a/frontend/resources/styles/common/refactor/color-defs.scss +++ b/frontend/resources/styles/common/refactor/color-defs.scss @@ -11,53 +11,47 @@ // Dark background --db-primary-60: #{color.change(#18181a, $alpha: 0.6)}; // used on overlay dark mode - //Dark foreground + // Dark foreground --df-secondary: #8f9da3; // Used on button disabled background dark mode, grid metadata and some svg --df-secondary-40: #{color.change(#8f9da3, $alpha: 0.4)}; // Used on button disabled foreground dark mode - //Dark accent + // Dark accent --da-tertiary-10: #{color.change(#00d1b8, $alpha: 0.1)}; // selection rect dark mode --da-tertiary-70: #{color.change(#00d1b8, $alpha: 0.7)}; // selection rect background dark mode // LIGHT // Light background - --lb-primary-60: #{color.change(#ffffff, $alpha: 0.6)}; // overlay color light mode + --lb-primary-60: #{color.change(#fff, $alpha: 0.6)}; // overlay color light mode --lb-quaternary: #eef0f2; // background disabled token - //Light foreground + // Light foreground --lf-secondary-40: #{color.change(#495e74, $alpha: 0.4)}; // foreground disabled token - //Light accent + // Light accent --la-tertiary-10: #{color.change(#8c33eb, $alpha: 0.1)}; // selection rect light mode --la-tertiary-70: #{color.change(#8c33eb, $alpha: 0.7)}; // selection rect background light mode // STATUS COLOR --status-color-success-200: #a7e8d9; // Used on Register confirmation text --status-color-success-500: #2d9f8f; // Used on accept icon, and status widget - --status-color-warning-500: #f5a91b; // Used on status widget, some buttons and warnings icons and elements - --status-color-error-500: #ff3277; // Used on discard icon, some borders and svg, and on status widget - --status-color-info-500: #0e9be9; // used on pixel grid and status widget // APP COLORS - --app-white: #ffffff; // Used in several places + --app-white: #fff; // Used in several places --app-black: #000; // Used on interactions, measurements and editor files // SOCIAL LOGIN BUTTONS --google-login-background: #4285f4; --google-login-background-hover: #{color.adjust(#4285f4, $lightness: -15%)}; --google-login-foreground: var(--app-white); - --github-login-background: #4c4c4c; --github-login-background-hover: #{color.adjust(#4c4c4c, $lightness: -15%)}; --github-login-foreground: var(--app-white); - --oidc-login-background: #b3b3b3; --oidc-login-background-hover: #{color.adjust(#b3b3b3, $lightness: -15%)}; --oidc-login-foreground: var(--app-white); - --gitlab-login-background: #fc6d26; --gitlab-login-background-hover: #{color.adjust(#fc6d26, $lightness: -15%)}; --gitlab-login-foreground: var(--app-white); diff --git a/frontend/resources/styles/common/refactor/common-dashboard.scss b/frontend/resources/styles/common/refactor/common-dashboard.scss index ed30f20a2e..75f52ad936 100644 --- a/frontend/resources/styles/common/refactor/common-dashboard.scss +++ b/frontend/resources/styles/common/refactor/common-dashboard.scss @@ -29,6 +29,7 @@ .btn-secondary { flex-shrink: 0; height: $s-32; + svg { height: $s-16; width: $s-16; @@ -57,6 +58,7 @@ height: $s-40; padding: $s-4 $s-24; font-weight: $fw400; + &:hover { color: var(--color-background-secondary); text-decoration: none; @@ -124,10 +126,12 @@ font-size: $s-16; color: var(--color-foreground-secondary); border-color: transparent; + &:hover { color: var(--color-foreground-primary); } } + &.active { a { color: var(--color-foreground-primary); @@ -138,14 +142,16 @@ } .btn-primary { - @extend .button-primary; + @extend %button-primary; + text-transform: uppercase; font-size: $fs-14; font-weight: $fw400; } .btn-secondary { - @extend .button-secondary; + @extend %button-secondary; + color: var(--color-foreground-primary); font-size: $fs-12; text-transform: uppercase; diff --git a/frontend/resources/styles/common/refactor/common-refactor.scss b/frontend/resources/styles/common/refactor/common-refactor.scss index a6098ee978..173fd6c7da 100644 --- a/frontend/resources/styles/common/refactor/common-refactor.scss +++ b/frontend/resources/styles/common/refactor/common-refactor.scss @@ -4,17 +4,17 @@ // // Copyright (c) KALEIDOS INC -//################################################# +// ################################################# // MAIN STYLES -//################################################# +// ################################################# -@forward "./fonts.scss"; -@forward "./spacing.scss"; -@forward "./borders.scss"; -@forward "./opacity.scss"; -@forward "./shadows.scss"; -@forward "./z-index.scss"; -@forward "./mixins.scss"; -@forward "./focus.scss"; -@forward "./animations.scss"; -@forward "./basic-rules.scss"; +@forward "./fonts"; +@forward "./spacing"; +@forward "./borders"; +@forward "./opacity"; +@forward "./shadows"; +@forward "./z-index"; +@forward "./mixins"; +@forward "./focus"; +@forward "./animations"; +@forward "./basic-rules"; diff --git a/frontend/resources/styles/common/refactor/design-tokens.scss b/frontend/resources/styles/common/refactor/design-tokens.scss index 2acb81398a..9d894e65a0 100644 --- a/frontend/resources/styles/common/refactor/design-tokens.scss +++ b/frontend/resources/styles/common/refactor/design-tokens.scss @@ -10,11 +10,9 @@ // BASE COLORS --canvas-background-color: var(--color-background-primary); --canvas-fill-color: var(--color-canvas); - --scrollbar-background-color: var(--color-foreground-secondary); --panel-background-color: var(--color-background-primary); --panel-border-color: var(--color-background-quaternary); - --app-background: var(--color-background-primary); --loader-background: var(--color-background-primary); @@ -26,7 +24,6 @@ --button-foreground-color-disabled: var(--color-foreground-disabled); --button-background-color-disabled: var(--color-background-quaternary); --button-border-color-disabled: var(--color-background-quaternary); - --button-primary-background-color-rest: var(--color-accent-primary); --button-primary-border-color-rest: var(--color-accent-primary); --button-primary-foreground-color-rest: var(--color-background-secondary); @@ -39,7 +36,6 @@ --button-primary-background-color-focus: var(--color-background-tertiary); --button-primary-border-color-focus: var(--color-accent-primary); --button-primary-foreground-color-focus: var(--color-foreground-secondary); - --button-secondary-background-color-rest: var(--color-background-tertiary); --button-secondary-border-color-rest: var(--color-background-tertiary); --button-secondary-foreground-color-rest: var(--color-foreground-secondary); @@ -52,7 +48,6 @@ --button-secondary-background-color-focus: var(--color-background-tertiary); --button-secondary-border-color-focus: var(--color-accent-primary); --button-secondary-foreground-color-focus: var(--color-foreground-secondary); - --button-tertiary-foreground-color-rest: var(--color-foreground-secondary); --button-tertiary-background-color-hover: var(--color-background-quaternary); --button-tertiary-border-color-hover: var(--color-background-quaternary); @@ -63,16 +58,13 @@ --button-tertiary-background-color-focus: var(--color-background-tertiary); --button-tertiary-border-color-focus: var(--color-accent-primary); --button-tertiary-foreground-color-focus: var(--color-foreground-primary); - --expand-button-icon-border-width: 0; --expand-button-icon-border-width-selected: 0; - --button-icon-foreground-color: var(--color-foreground-secondary); --button-icon-foreground-color-hover: var(--color-foreground-secondary); --button-icon-background-color-selected: var(--color-background-quaternary); --button-icon-foreground-color-selected: var(--color-accent-primary); --button-icon-border-color-selected: var(--color-background-quaternary); - --button-radio-background-color-rest: var(--color-background-tertiary); --button-radio-border-color-rest: var(--color-background-tertiary); --button-radio-foreground-color-rest: var(--color-foreground-secondary); @@ -84,20 +76,16 @@ --button-radio-background-color-focus: var(--color-background-tertiary); --button-radio-border-color-focus: var(--color-accent-primary); --button-radio-foreground-color-focus: var(--color-foreground-secondary); - --button-warning-background-color-rest: var(--status-color-warning-500); --button-warning-border-color-rest: var(--status-color-warning-500); --button-warning-foreground-color-rest: var(--color-background-secondary); - --button-disabled-background-color-rest: var(--color-background-disabled); --button-disabled-border-color-rest: var(--color-background-disabled); --button-disabled-foreground-color-rest: var(--color-foreground-disabled); - --button-constraint-background-color-rest: var(--color-foreground-secondary); --button-constraint-border-color-rest: var(--color-background-tertiary); --button-constraint-border-color-hover: var(--color-accent-primary-muted); --button-constraint-background-color-hover: var(--color-accent-primary); - --constraint-widget-background-color: var(--color-background-tertiary); --constraint-center-area-background-color: var(--color-background-primary); @@ -144,7 +132,6 @@ --palette-button-shadow-initial: var(--color-background-primary); --palette-button-shadow-final: transparent; --palette-handler-background-color: var(--color-background-quaternary); - --color-bullet-background-color: var(--app-white); // We don't want this color to change with palette --color-bullet-border-color: var(--color-background-quaternary); --color-bullet-border-color-selected: var(--color-accent-primary); @@ -183,7 +170,6 @@ --input-border-color-error: var(--status-color-error-500); --input-border-color-success: var(--color-accent-primary); --input-details-color: var(--color-background-primary); - --input-checkbox-background-color-rest: var(--color-background-quaternary); --input-checkbox-border-color-rest: var(--color-foreground-secondary); --input-checkbox-border-color-active: var(--color-background-quaternary); @@ -200,7 +186,6 @@ --input-checkbox-background-color-active: var(--color-accent-primary); --input-checkbox-foreground-color-active: var(--color-background-primary); --input-checkbox-text-foreground-color: var(--color-foreground-secondary); - --menu-background-color: var(--color-background-tertiary); --menu-foreground-color: var(--color-foreground-primary); --menu-icon-foreground-color: var(--color-foreground-secondary); @@ -219,7 +204,6 @@ --menu-background-color-disabled: var(--color-background-primary); --menu-foreground-color-disabled: var(--color-foreground-secondary); --menu-border-color-disabled: var(--color-background-quaternary); - --context-menu-background-color: var(--color-background-tertiary); --context-menu-foreground-color: var(--color-foreground-secondary); --context-menu-background-color-selected: var(--color-background-quaternary); @@ -243,34 +227,27 @@ --assets-component-border-selected: var(--color-accent-tertiary); --assets-component-second-border-selected: var(--color-background-primary); --assets-component-hightlight: var(--color-accent-secondary); - --radio-btns-background-color: var(--color-background-tertiary); --radio-btn-background-color-selected: var(--color-background-quaternary); --radio-btn-foreground-color: var(--color-foreground-secondary); --radio-btn-foreground-color-selected: var(--color-accent-primary); --radio-btn-border-color: var(--color-background-tertiary); --radio-btn-border-color-selected: var(--color-background-quaternary); - --library-name-foreground-color: var(--color-foreground-primary); --library-content-foreground-color: var(--color-foreground-secondary); - --dropdown-background-color: var(--color-background-tertiary); --dropdown-separator-color: var(--color-background-primary); --profile-drowpdown-background-color: var(--color-background-primary); - --not-found-background-color: var(--color-background-tertiary); --not-found-foreground-color: var(--color-foreground-secondary); - --entry-foreground-color: var(--color-foreground-secondary); --entry-background-color: var(--color-background-tertiary); --entry-background-color-disabled: var(--color-background-primary); --entry-border-color-disabled: var(--color-background-quaternary); --entry-foreground-color-hover: var(--color-foreground-primary); --entry-background-color-hover: var(--color-background-quaternary); - --empty-message-background-color: var(--color-background-tertiary); --empty-message-foreground-color: var(--color-foreground-secondary); - --user-count-background-color: var(--color-accent-primary); --user-count-foreground-color: var(--color-background-secondary); @@ -323,32 +300,25 @@ --alert-text-foreground-color-default: var(--color-foreground-primary); --alert-icon-foreground-color-default: var(--color-foreground-primary); --alert-border-color-default: var(--color-background-quaternary); - --alert-background-color-success: var(--color-background-success); --alert-text-foreground-color-success: var(--color-foreground-primary); --alert-icon-foreground-color-success: var(--color-accent-success); --alert-border-color-success: var(--color-accent-success); - --alert-background-color-warning: var(--color-background-warning); --alert-text-foreground-color-warning: var(--color-foreground-primary); --alert-icon-foreground-color-warning: var(--color-accent-warning); --alert-border-color-warning: var(--color-accent-warning); - --alert-background-color-error: var(--color-background-error); --alert-text-foreground-color-error: var(--color-foreground-primary); --alert-icon-foreground-color-error: var(--color-accent-error); --alert-border-color-error: var(--color-accent-error); - --alert-background-color-info: var(--color-background-info); --alert-text-foreground-color-info: var(--color-foreground-primary); --alert-icon-foreground-color-info: var(--color-accent-info); --alert-border-color-info: var(--color-accent-info); - --alert-text-foreground-color-focus: var(--color-accent-primary); --alert-border-color-focus: var(--color-accent-primary); - --notification-foreground-color-default: var(--color-foreground-secondary); - --element-foreground-warning: var(--status-color-warning-500); --element-foreground-error: var(--status-color-error-500); @@ -368,21 +338,16 @@ --search-bar-foreground-color: var(--color-foreground-primary); --search-bar-icon-foreground-color: var(--color-foreground-secondary); --search-bar-icon-foreground-color-hover: var(--color-accent-primary); - --pill-background-color: var(--color-background-tertiary); --pill-foreground-color: var(--color-foreground-primary); - --link-foreground-color: var(--color-accent-primary); - --register-confirmation-color: var(--status-color-success-200); //TODO: review this color - + --register-confirmation-color: var(--status-color-success-200); // TODO: review this color --resize-area-background-color: var(--color-background-primary); --resize-area-border-color: var(--color-background-quaternary); - --profile-section-background-color: var(--color-background-tertiary); --dashboard-list-background-color: var(--color-background-tertiary); --dashboard-list-foreground-color: var(--color-foreground-primary); --dashboard-list-text-foreground-color: var(--color-foreground-secondary); - --communication-tag-background-color: var(--color-foreground-primary); --communication-tag-foreground-color: var(--color-background-tertiary); @@ -404,7 +369,7 @@ // TODO: we should not put these functional tokens here, but rather in the components they belong to --new-team-button-background-color: var(--color-background-primary); - //DASHBOARD + // DASHBOARD --sidebar-element-foreground-color: var(--color-foreground-secondary); --sidebar-element-background-color-hover: var(--color-background-secondary); --sidebar-element-foreground-color-hover: var(--color-accent-primary); @@ -422,21 +387,16 @@ --tab-background-color-selected: var(--color-background-primary); --tab-border-color: var(--color-background-tertiary); --tab-border-color-selected: var(--color-background-secondary); - --radio-btns-background-color: var(--color-background-tertiary); --radio-btn-background-color-selected: var(--color-background-primary); --radio-btn-foreground-color: var(--color-foreground-secondary); --radio-btn-foreground-color-selected: var(--color-accent-primary); --radio-btn-border-color: var(--color-background-tertiary); --radio-btn-border-color-selected: var(--color-background-secondary); - --button-icon-background-color-selected: var(--color-background-primary); --button-icon-border-color-selected: var(--color-background-secondary); - --assets-item-name-foreground-color: var(--color-foreground-primary); - --text-editor-selection-background-color: var(--la-tertiary-70); --expand-button-icon-border-width-selected: 2px; - --colorpicker-background-color: var(--color-background-primary); } diff --git a/frontend/resources/styles/common/refactor/focus.scss b/frontend/resources/styles/common/refactor/focus.scss index 0ac2dde780..8e01cab247 100644 --- a/frontend/resources/styles/common/refactor/focus.scss +++ b/frontend/resources/styles/common/refactor/focus.scss @@ -6,44 +6,46 @@ @use "./spacing.scss" as *; -@mixin focusType($type) { - $realType: ""; +@mixin focus-type($type) { + $real-type: ""; + @if $type { - $realType: $type + "-"; + $real-type: $type + "-"; } + &:focus-visible { outline: none; - background-color: var(--button-#{$realType}background-color-focus); - border: $s-1 solid var(--button-#{$realType}border-color-focus); - color: var(--button-#{$realType}foreground-color-focus); - svg, - span svg { - stroke: var(--button-#{$realType}foreground-color-focus); + background-color: var(--button-#{$real-type}background-color-focus); + border: $s-1 solid var(--button-#{$real-type}border-color-focus); + color: var(--button-#{$real-type}foreground-color-focus); + + svg { + stroke: var(--button-#{$real-type}foreground-color-focus); } } } -@mixin focusPrimary { - @include focusType(primary); +@mixin focus-primary { + @include focus-type(primary); } -@mixin focusSecondary { - @include focusType(secondary); +@mixin focus-secondary { + @include focus-type(secondary); } -@mixin focusTertiary { - @include focusType(tertiary); +@mixin focus-tertiary { + @include focus-type(tertiary); } -@mixin focusRadio { - @include focusType(radio); +@mixin focus-radio { + @include focus-type(radio); } @mixin focus { - @include focusType(null); + @include focus-type(null); } -@mixin focusInput { +@mixin focus-input { &:focus-within { color: var(--input-foreground-color-active); background-color: var(--input-background-color-active); diff --git a/frontend/resources/styles/common/refactor/fonts.scss b/frontend/resources/styles/common/refactor/fonts.scss index 015555225a..86f95cc303 100644 --- a/frontend/resources/styles/common/refactor/fonts.scss +++ b/frontend/resources/styles/common/refactor/fonts.scss @@ -8,7 +8,6 @@ // Typography scale $fs-base: 16; - $fs-10: math.div(10, $fs-base) + rem; $fs-11: 0.688rem; $fs-12: math.div(12, $fs-base) + rem; diff --git a/frontend/resources/styles/common/refactor/mixins.scss b/frontend/resources/styles/common/refactor/mixins.scss index c4d07d09e2..9ec8d1996b 100644 --- a/frontend/resources/styles/common/refactor/mixins.scss +++ b/frontend/resources/styles/common/refactor/mixins.scss @@ -7,37 +7,37 @@ @use "./fonts.scss" as *; @use "./spacing.scss" as *; -@mixin flexCenter { +@mixin flex-center { display: flex; justify-content: center; align-items: center; } -@mixin flexColumn($gap: $s-4) { +@mixin flex-column($gap: $s-4) { display: flex; flex-direction: column; gap: #{$gap}; } -@mixin flexRow { +@mixin flex-row { display: flex; align-items: center; gap: $s-4; } -@mixin buttonStyle { +@mixin button-style { border: none; background: none; cursor: pointer; } -@mixin removeInputStyle { +@mixin remove-input-style { border: none; background: none; outline: none; } -@mixin uppercaseTitleTipography { +@mixin uppercase-title-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-11; font-weight: $fw500; @@ -45,28 +45,28 @@ text-transform: uppercase; } -@mixin bigTitleTipography { +@mixin big-title-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-24; font-weight: $fw400; line-height: 1.2; } -@mixin medTitleTipography { +@mixin med-title-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-20; font-weight: $fw400; line-height: 1.2; } -@mixin smallTitleTipography { +@mixin small-title-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-14; font-weight: $fw400; line-height: 1.2; } -@mixin headlineLargeTypography { +@mixin headline-large-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-18; line-height: 1.2; @@ -74,7 +74,7 @@ font-weight: $fw400; } -@mixin headlineMediumTypography { +@mixin headline-medium-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-16; line-height: 1.4; @@ -82,7 +82,7 @@ font-weight: $fw400; } -@mixin headlineSmallTypography { +@mixin headline-small-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-12; line-height: 1.2; @@ -90,35 +90,35 @@ font-weight: $fw500; } -@mixin bodyLargeTypography { +@mixin body-large-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-16; line-height: 1.5; font-weight: $fw400; } -@mixin bodyMediumTypography { +@mixin body-medium-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-14; line-height: 1.4; font-weight: $fw400; } -@mixin bodySmallTypography { +@mixin body-small-typography { font-family: "worksans", "vazirmatn", sans-serif; font-size: $fs-12; font-weight: $fw400; line-height: 1.4; } -@mixin codeTypography { +@mixin code-typography { font-family: "robotomono", monospace; font-size: $fs-12; font-weight: $fw400; line-height: 1.2; } -@mixin textEllipsis { +@mixin text-ellipsis { display: block; max-width: 99%; overflow: hidden; @@ -126,7 +126,7 @@ white-space: nowrap; } -@mixin twoLineTextEllipsis { +@mixin two-line-text-ellipsis { max-width: 99%; overflow: hidden; text-overflow: ellipsis; @@ -135,8 +135,9 @@ -webkit-box-orient: vertical; } -@mixin inspectValue { - @include bodySmallTypography; +@mixin inspect-value { + @include body-small-typography; + display: inline-block; width: fit-content; padding: 0; @@ -145,7 +146,7 @@ color: var(--menu-foreground-color); } -@mixin copyWrapperBase { +@mixin copy-wrapper-base { position: relative; min-height: $s-32; width: $s-144; @@ -154,7 +155,7 @@ box-sizing: border-box; } -@mixin hiddenElement { +@mixin hidden-element { cursor: default; pointer-events: none; box-sizing: border-box; @@ -167,6 +168,7 @@ 0% { transform: rotate(0deg); } + 100% { transform: rotate(359deg); } diff --git a/frontend/resources/styles/common/refactor/shadows.scss b/frontend/resources/styles/common/refactor/shadows.scss index c936ca115d..ee825fa4c5 100644 --- a/frontend/resources/styles/common/refactor/shadows.scss +++ b/frontend/resources/styles/common/refactor/shadows.scss @@ -6,10 +6,6 @@ @use "./spacing.scss" as *; -@mixin menuShadow { - box-shadow: 0px 0px $s-12 0px var(--menu-shadow-color); -} - -@mixin alertShadow { - box-shadow: 0px $s-4 $s-4 var(--menu-shadow-color); +@mixin menu-shadow { + box-shadow: 0 0 $s-12 0 var(--menu-shadow-color); } diff --git a/frontend/resources/styles/common/refactor/themes.scss b/frontend/resources/styles/common/refactor/themes.scss index 9a5a9a1e64..cb4ab93a0f 100644 --- a/frontend/resources/styles/common/refactor/themes.scss +++ b/frontend/resources/styles/common/refactor/themes.scss @@ -4,5 +4,5 @@ // // Copyright (c) KALEIDOS INC -@forward "./themes/default-theme.scss"; -@forward "./themes/light-theme.scss"; +@forward "./themes/default-theme"; +@forward "./themes/light-theme"; diff --git a/frontend/resources/styles/common/refactor/themes/default-theme.scss b/frontend/resources/styles/common/refactor/themes/default-theme.scss index f7d092338a..11ac2c8e89 100644 --- a/frontend/resources/styles/common/refactor/themes/default-theme.scss +++ b/frontend/resources/styles/common/refactor/themes/default-theme.scss @@ -10,6 +10,5 @@ --color-background-disabled: var(--df-secondary); --color-foreground-disabled: var(--df-secondary-40); --color-accent-tertiary-muted: var(--da-tertiary-10); // selection rect - --overlay-color: var(--db-primary-60); } diff --git a/frontend/resources/styles/common/refactor/themes/light-theme.scss b/frontend/resources/styles/common/refactor/themes/light-theme.scss index 8baec1aa94..69e6259a0a 100644 --- a/frontend/resources/styles/common/refactor/themes/light-theme.scss +++ b/frontend/resources/styles/common/refactor/themes/light-theme.scss @@ -10,6 +10,5 @@ --color-background-disabled: var(--lb-quaternary); --color-foreground-disabled: var(--lf-secondary-40); --color-accent-tertiary-muted: var(--la-tertiary-10); - --overlay-color: var(--lb-primary-60); } diff --git a/frontend/resources/styles/debug.scss b/frontend/resources/styles/debug.scss index be3edc5228..227b18941f 100644 --- a/frontend/resources/styles/debug.scss +++ b/frontend/resources/styles/debug.scss @@ -10,9 +10,9 @@ // debugging. body { - color: yellow; + color: rgb(255 255 0); } .deprecated-icon { - fill: red !important; + fill: rgb(255 0 0) !important; } diff --git a/frontend/resources/styles/main-default.scss b/frontend/resources/styles/main-default.scss index 5b6c1cb247..d9048c610c 100644 --- a/frontend/resources/styles/main-default.scss +++ b/frontend/resources/styles/main-default.scss @@ -4,29 +4,28 @@ // // Copyright (c) KALEIDOS INC -//################################################# +// ################################################# // MAIN STYLES -//################################################# +// ################################################# @forward "common/dependencies/reset"; -@forward "common/refactor/color-defs.scss"; +@forward "common/refactor/color-defs"; @forward "common/dependencies/fonts"; @forward "common/dependencies/animations"; -@forward "common/dependencies/highlight.scss"; -@forward "common/dependencies/storybook.scss"; +@forward "common/dependencies/highlight"; +@forward "common/dependencies/storybook"; +@forward "common/refactor/themes"; +@forward "common/refactor/design-tokens"; -@forward "common/refactor/themes.scss"; -@forward "common/refactor/design-tokens.scss"; - -//################################################# +// ################################################# // Layouts -//################################################# +// ################################################# @forward "common/base"; -//################################################# +// ################################################# // Commons -//################################################# +// ################################################# // TODO: remove this stylesheet once the new text editor is in place // https: //tree.taiga.io/project/penpot/us/8165 diff --git a/frontend/resources/styles/main/partials/texts.scss b/frontend/resources/styles/main/partials/texts.scss index aab38a4966..ad945dc69b 100644 --- a/frontend/resources/styles/main/partials/texts.scss +++ b/frontend/resources/styles/main/partials/texts.scss @@ -2,7 +2,7 @@ .rich-text { color: var(--app-black); height: 100%; - font-family: sourcesanspro; + font-family: sans-serif, "sourcesanspro"; div { line-height: inherit; diff --git a/frontend/src/app/config.cljs b/frontend/src/app/config.cljs index 058e265bd2..79487fbfc6 100644 --- a/frontend/src/app/config.cljs +++ b/frontend/src/app/config.cljs @@ -157,6 +157,7 @@ true)))) (def terms-of-service-uri (obj/get global "penpotTermsOfServiceURI")) +(def oidc-name (obj/get global "penpotOIDCName")) (def privacy-policy-uri (obj/get global "penpotPrivacyPolicyURI")) (def flex-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) (def grid-help-uri (obj/get global "penpotGridHelpURI" "https://help.penpot.app/user-guide/flexible-layouts/")) diff --git a/frontend/src/app/main/data/changes.cljs b/frontend/src/app/main/data/changes.cljs index a2d493f1b8..e91fcf0f4e 100644 --- a/frontend/src/app/main/data/changes.cljs +++ b/frontend/src/app/main/data/changes.cljs @@ -23,10 +23,11 @@ [potok.v2.core :as ptk])) ;; Change this to :info :debug or :trace to debug this module -(log/set-level! :info) +(log/set-level! :warn) (def page-change? #{:add-page :mod-page :del-page :mov-page}) + (def update-layout-attr? #{:hidden}) @@ -123,7 +124,7 @@ "Create a commit event instance" [{:keys [commit-id redo-changes undo-changes origin save-undo? features file-id file-revn file-vern undo-group tags stack-undo? source ignore-wasm? - translation?]}] + selected-before translation?]}] (assert (cpc/check-changes redo-changes) "expect valid vector of changes for redo-changes") @@ -150,6 +151,7 @@ :tags tags :stack-undo? stack-undo? :ignore-wasm? ignore-wasm? + :selected-before selected-before :translation? translation?}] (ptk/reify ::commit @@ -208,16 +210,19 @@ ;; Prevent commit changes by a viewer team member (it really should never happen) (when (:can-edit permissions) - (rx/of (-> params - (assoc :undo-group undo-group) - (assoc :features features) - (assoc :tags tags) - (assoc :stack-undo? stack-undo?) - (assoc :save-undo? save-undo?) - (assoc :file-id file-id) - (assoc :file-revn (resolve-file-revn state file-id)) - (assoc :file-vern (resolve-file-vern state file-id)) - (assoc :undo-changes uchg) - (assoc :redo-changes rchg) - (assoc :translation? translation?) - (commit)))))))) + (log/trace :hint "commit-changes" :redo-changes redo-changes) + (let [selected (dm/get-in state [:workspace-local :selected])] + (rx/of (-> params + (assoc :undo-group undo-group) + (assoc :features features) + (assoc :tags tags) + (assoc :stack-undo? stack-undo?) + (assoc :save-undo? save-undo?) + (assoc :file-id file-id) + (assoc :file-revn (resolve-file-revn state file-id)) + (assoc :file-vern (resolve-file-vern state file-id)) + (assoc :undo-changes uchg) + (assoc :redo-changes rchg) + (assoc :selected-before selected) + (assoc :translation? translation?) + (commit))))))))) diff --git a/frontend/src/app/main/data/common.cljs b/frontend/src/app/main/data/common.cljs index fb55df73de..3ac4f1eee6 100644 --- a/frontend/src/app/main/data/common.cljs +++ b/frontend/src/app/main/data/common.cljs @@ -199,8 +199,10 @@ (ptk/reify ::change-team-role ptk/WatchEvent - (watch [_ _ _] - (rx/of (ntf/info (get-change-role-msg role)))) + (watch [_ state _] + (let [current-team-id (:current-team-id state)] + (when (= team-id current-team-id) + (rx/of (ntf/info (get-change-role-msg role)))))) ptk/UpdateEvent (update [_ state] @@ -459,6 +461,17 @@ (let [page-id (or page-id (:current-page-id state)) file-id (or file-id (:current-file-id state)) section (or section :interactions) + selected (get-in state [:workspace-local :selected]) + objects (dsh/lookup-page-objects state file-id page-id) + frame-id (or frame-id + (reduce + (fn [_ id] + (let [obj (get objects id)] + (when (and obj + (= :frame (:type obj))) + (reduced (:id obj))))) + nil + selected)) params {:file-id file-id :page-id page-id :section section diff --git a/frontend/src/app/main/data/dashboard.cljs b/frontend/src/app/main/data/dashboard.cljs index a5ce2cd2c3..7810d15a95 100644 --- a/frontend/src/app/main/data/dashboard.cljs +++ b/frontend/src/app/main/data/dashboard.cljs @@ -13,6 +13,7 @@ [app.common.logging :as log] [app.common.schema :as sm] [app.common.time :as ct] + [app.common.types.organization :as co] [app.common.types.project :refer [valid-project?]] [app.common.uuid :as uuid] [app.config :as cf] @@ -23,6 +24,7 @@ [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] [app.main.data.notifications :as ntf] + [app.main.data.team :as dtm] [app.main.data.websocket :as dws] [app.main.repo :as rp] [app.main.store :as st] @@ -685,16 +687,71 @@ (modal/hide))))) (defn handle-change-team-org - [{:keys [team-id organization-id organization-name]}] + [{:keys [team notification]}] (ptk/reify ::handle-change-team-org + ptk/WatchEvent + (watch [_ state _] + (let [current-team-id (:current-team-id state) + organization (:organization team)] + (when (and (contains? cf/flags :nitrate) + notification + (= (:id team) current-team-id)) + (rx/of (ntf/show {:content (tr notification (:name organization)) + :type :toast + :level :info + :timeout nil}))))) ptk/UpdateEvent (update [_ state] (if (contains? cf/flags :nitrate) - (d/update-in-when state [:teams team-id] assoc - :organization-id organization-id - :organization-name organization-name) + (let [team-id (:id team) + team-name (:name team) + organization (:organization team)] + (d/update-in-when state [:teams team-id] + (fn [team] + (cond-> (co/apply-organization team organization) + team-name (assoc :name team-name))))) state)))) +(defn- handle-user-org-change + [{:keys [organization-id organization-name notification]}] + (ptk/reify ::handle-user-org-change + ptk/WatchEvent + (watch [_ state _] + (when (and notification (contains? cf/flags :nitrate)) + (let [team-id (:current-team-id state) + team (dm/get-in state [:teams team-id])] + (rx/of (ntf/show {:content (tr notification organization-name) + :type :toast + :level :info + :timeout nil}) + (dtm/fetch-teams) + ;; When the user is currently on a team of the org + (when (= organization-id (:organization-id team)) + (dcm/go-to-dashboard-recent {:team-id :default})))))))) + + +(defn- handle-organization-deleted + [{:keys [organization-name teams deleted-teams]}] + (ptk/reify ::handle-organization-deleted + ptk/WatchEvent + (watch [_ state _] + (when (contains? cf/flags :nitrate) + (let [team-id (:current-team-id state) + teams-set (set teams) + notify? (contains? teams-set team-id) + fetch? (some (:teams state) teams) + go-to-default? (some #{team-id} deleted-teams)] + (rx/concat + (when go-to-default? ;; If the user is currently on one of the deleted teams + (rx/of (dcm/go-to-dashboard-recent {:team-id :default}))) + + (when notify? ;; If the user is currently on one of the org teams + (rx/of (ntf/show {:content (tr "dashboard.org-deleted" organization-name) + :type :toast + :level :info + :timeout nil}))) + (when fetch? ;; If the user belonged to the org + (rx/of (dtm/fetch-teams))))))))) (defn- process-message [{:keys [type] :as msg}] @@ -703,6 +760,8 @@ :team-role-change (handle-change-team-role msg) :team-membership-change (dcm/team-membership-change msg) :team-org-change (handle-change-team-org msg) + :user-org-change (handle-user-org-change msg) + :organization-deleted (handle-organization-deleted msg) nil)) diff --git a/frontend/src/app/main/data/exports/assets.cljs b/frontend/src/app/main/data/exports/assets.cljs index 8ab85b5228..143dec67d4 100644 --- a/frontend/src/app/main/data/exports/assets.cljs +++ b/frontend/src/app/main/data/exports/assets.cljs @@ -65,6 +65,9 @@ (dsh/lookup-shapes state selected) (reverse (dsh/filter-shapes state #(pos? (count (:exports %)))))) + page (dsh/lookup-page state) + page-name (:name page) + exports (for [shape shapes export (:exports shape)] (-> export @@ -76,10 +79,12 @@ (assoc :name (:name shape))))] (rx/of (modal/show :export-shapes - {:exports (vec exports) :origin origin})))))) + {:exports (vec exports) + :origin origin + :name page-name})))))) (defn show-viewer-export-dialog - [{:keys [shapes page-id file-id share-id exports]}] + [{:keys [shapes page-id file-id share-id exports name]}] (ptk/reify ::show-viewer-export-dialog ptk/WatchEvent (watch [_ _ _] @@ -93,27 +98,32 @@ (assoc :shape (dissoc shape :exports)) (assoc :name (:name shape)) (cond-> share-id (assoc :share-id share-id))))] - (rx/of (modal/show :export-shapes {:exports (vec exports) :origin "viewer"})))))) #_TODO + (rx/of (modal/show :export-shapes {:exports (vec exports) + :origin "viewer" + :name name})))))) #_TODO (defn show-workspace-export-frames-dialog [frames] (ptk/reify ::show-workspace-export-frames-dialog ptk/WatchEvent (watch [_ state _] - (let [file-id (:current-file-id state) - page-id (:current-page-id state) - exports (mapv (fn [frame] - {:enabled true - :page-id page-id - :file-id file-id - :object-id (:id frame) - :shape frame - :name (:name frame)}) - frames)] + (let [file-id (:current-file-id state) + page-id (:current-page-id state) + page (dsh/lookup-page state) + page-name (:name page) + exports (mapv (fn [frame] + {:enabled true + :page-id page-id + :file-id file-id + :object-id (:id frame) + :shape frame + :name (:name frame)}) + frames)] (rx/of (modal/show :export-frames {:exports exports - :origin "workspace:menu"})))))) + :origin "workspace:menu" + :name page-name})))))) (defn- initialize-export-status [exports cmd resource] @@ -197,7 +207,7 @@ (rx/throw cause))))))))))) (defn request-multiple-export - [{:keys [exports cmd] + [{:keys [exports cmd name] :or {cmd :export-shapes} :as params}] (ptk/reify ::request-multiple-export @@ -206,14 +216,17 @@ (let [resource-id (volatile! nil) profile-id (:profile-id state) ws-conn (:ws-conn state) - params {:exports exports - :cmd cmd - :profile-id profile-id - :force-multiple true - :is-wasm - (and - (features/active-feature? state "render-wasm/v1") - (contains? cf/flags :wasm-export))} + params (cond-> + {:exports exports + :cmd cmd + :profile-id profile-id + :force-multiple true + :is-wasm + (and + (features/active-feature? state "render-wasm/v1") + (contains? cf/flags :wasm-export))} + (some? name) + (assoc :name name)) progress-stream (->> (ws/get-rcv-stream ws-conn) diff --git a/frontend/src/app/main/data/fonts.cljs b/frontend/src/app/main/data/fonts.cljs index d72cde8436..9a49711e85 100644 --- a/frontend/src/app/main/data/fonts.cljs +++ b/frontend/src/app/main/data/fonts.cljs @@ -60,9 +60,9 @@ (prepare-font-variant [item] {:id (str (:font-style item) "-" (:font-weight item)) - :name (str (cm/font-weight->name (:font-weight item)) - (when (not= "normal" (:font-style item)) - (str " " (str/capital (:font-style item))))) + :name (cm/font-display-variant (:variant-name item) + (:font-weight item) + (:font-style item)) :style (:font-style item) :weight (str (:font-weight item)) ::fonts/woff1-file-id (:woff1-file-id item) @@ -140,6 +140,7 @@ :font-family (or family "") :font-weight (cm/parse-font-weight variant) :font-style (cm/parse-font-style variant) + :variant-name variant :height-warning? height-warning?}) ;; Font could not be parsed (woff2), extract metadata from filename (let [base-name (str/replace name #"\.[^.]+$" "") diff --git a/frontend/src/app/main/data/nitrate.cljs b/frontend/src/app/main/data/nitrate.cljs index d9743c3543..77374a0a0f 100644 --- a/frontend/src/app/main/data/nitrate.cljs +++ b/frontend/src/app/main/data/nitrate.cljs @@ -3,35 +3,76 @@ [app.common.data.macros :as dm] [app.common.uri :as u] [app.config :as cf] + [app.main.data.common :as dcm] [app.main.data.modal :as modal] + [app.main.data.notifications :as ntf] + [app.main.data.team :as dt] [app.main.repo :as rp] [app.main.router :as rt] [app.main.store :as st] + [app.util.i18n :refer [tr]] + [app.util.storage :as storage] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) +(def ^:private nitrate-entry-active-key ::nitrate-entry-active) +(def ^:private nitrate-entry-pending-popup-key ::nitrate-entry-pending-popup) + +(defn activate-nitrate-entry-popup! + [] + (binding [storage/*sync* true] + (swap! storage/storage assoc + nitrate-entry-active-key true + nitrate-entry-pending-popup-key true))) + +(defn nitrate-entry-active? + [] + (true? (get storage/storage nitrate-entry-active-key))) + +(defn nitrate-entry-popup-pending? + [] + (true? (get storage/storage nitrate-entry-pending-popup-key))) + +(defn consume-nitrate-entry-popup! + [] + (binding [storage/*sync* true] + (swap! storage/storage dissoc + nitrate-entry-active-key + nitrate-entry-pending-popup-key))) + (defn show-nitrate-popup - [popup-type] - (ptk/reify ::show-nitrate-popup - ptk/WatchEvent - (watch [_ _ _] - (->> (rp/cmd! ::get-nitrate-connectivity {}) - (rx/map (fn [connectivity] - (modal/show popup-type (or connectivity {})))))))) + ([popup-type] (show-nitrate-popup popup-type {})) + ([popup-type extra-props] + (ptk/reify ::show-nitrate-popup + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! ::get-nitrate-connectivity {}) + (rx/map (fn [connectivity] + (modal/show popup-type (merge (or connectivity {}) extra-props))))))))) (defn go-to-nitrate-cc ([] (st/emit! (rt/nav-raw :href "/control-center/"))) ([{:keys [organization-id organization-slug]}] - (let [href (dm/str "/control-center/org/" - (u/percent-encode organization-slug) - "/" - (u/percent-encode (str organization-id)))] - (st/emit! (rt/nav-raw :href href))))) + (if (and organization-id organization-slug) + (let [href (dm/str "/control-center/org/" + (u/percent-encode organization-slug) + "/" + (u/percent-encode (str organization-id)) + "/people/")] + (st/emit! (rt/nav-raw :href href))) + (st/emit! (rt/nav-raw :href "/control-center/"))))) + +(defn go-to-nitrate-cc-create-org + [] + (st/emit! (rt/nav-raw :href "/control-center/?action=create-org"))) + +(def go-to-subscription-url (u/join cf/public-uri "#/settings/subscriptions")) (defn go-to-nitrate-billing [] - (st/emit! (rt/nav-raw :href "/control-center/licenses/billing"))) + (let [href (dm/str "/control-center/licenses/billing?callback=" (js/encodeURIComponent go-to-subscription-url))] + (st/emit! (rt/nav-raw :href href)))) (defn go-to-buy-nitrate-license ([subscription] @@ -42,8 +83,6 @@ href (dm/str "/control-center/licenses/start?" (u/map->query-string params))] (st/emit! (rt/nav-raw :href href))))) -(def go-to-subscription-url (u/join cf/public-uri "#/settings/subscriptions")) - (defn is-valid-license? [profile] (and (contains? cf/flags :nitrate) @@ -51,4 +90,47 @@ (contains? #{"active" "past_due" "trialing"} (dm/get-in profile [:subscription :status])))) +(defn leave-org + [{:keys [id name default-team-id teams-to-delete teams-to-leave on-error] :as params}] + (ptk/reify ::leave-org + ptk/WatchEvent + (watch [_ state _] + (let [profile-team-id (dm/get-in state [:profile :default-team-id])] + (->> (rp/cmd! ::leave-org {:id id + :name name + :default-team-id default-team-id + :teams-to-delete teams-to-delete + :teams-to-leave teams-to-leave}) + (rx/mapcat + (fn [_] + (rx/of + (dt/fetch-teams) + (dcm/go-to-dashboard-recent :team-id profile-team-id) + (modal/hide) + (ntf/show {:content (tr "dasboard.leave-org.toast" name) + :type :toast + :level :success})))) + (rx/catch on-error)))))) + + +(defn remove-team-from-org + [{:keys [team-id organization-id organization-name] :as params}] + (ptk/reify ::remove-team-from-org + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! ::remove-team-from-org {:team-id team-id :organization-id organization-id :organization-name organization-name}) + (rx/mapcat + (fn [_] + (rx/of (modal/hide)))))))) + + +(defn add-team-to-org + [{:keys [team-id organization-id] :as params}] + (ptk/reify ::add-team-to-org + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! ::add-team-to-organization {:team-id team-id :organization-id organization-id}) + (rx/mapcat + (fn [_] + (rx/of (modal/hide)))))))) diff --git a/frontend/src/app/main/data/persistence.cljs b/frontend/src/app/main/data/persistence.cljs index adcc70cbb3..c90c423f96 100644 --- a/frontend/src/app/main/data/persistence.cljs +++ b/frontend/src/app/main/data/persistence.cljs @@ -121,8 +121,10 @@ :features features} permissions (:permissions state)] - ;; Prevent commit changes by a team member without edition permission - (when (:can-edit permissions) + ;; Prevent saving changes when in version preview (read-only) mode + ;; or when the user does not have edition permission. + (when (and (:can-edit permissions) + (not (get-in state [:workspace-global :read-only?]))) (->> (rp/cmd! :update-file params) (rx/mapcat (fn [{:keys [revn lagged] :as response}] (log/debug :hint "changes persisted" :commit-id (dm/str commit-id) :lagged (count lagged)) diff --git a/frontend/src/app/main/data/profile.cljs b/frontend/src/app/main/data/profile.cljs index 4233e8a826..29e96585a8 100644 --- a/frontend/src/app/main/data/profile.cljs +++ b/frontend/src/app/main/data/profile.cljs @@ -354,6 +354,23 @@ (rx/map (constantly (refresh-profile))) (rx/catch on-error)))))) +(def delete-photo + (ptk/reify ::delete-photo + ev/Event + (-data [_] {}) + + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:profile :photo-id] nil)) + + ptk/WatchEvent + (watch [_ _ _] + (->> (rp/cmd! :delete-profile-photo {}) + (rx/map (constantly (refresh-profile))) + (rx/catch (fn [cause] + (js/console.error "delete-photo failed" cause) + (rx/of (refresh-profile)))))))) + (defn fetch-file-comments-users [{:keys [team-id]}] (assert (uuid? team-id) "expected a valid uuid for `team-id`") diff --git a/frontend/src/app/main/data/team.cljs b/frontend/src/app/main/data/team.cljs index 60846d88bd..f3bb5207bf 100644 --- a/frontend/src/app/main/data/team.cljs +++ b/frontend/src/app/main/data/team.cljs @@ -41,10 +41,19 @@ ptk/UpdateEvent (update [_ state] - (reduce (fn [state {:keys [id] :as team}] - (update-in state [:teams id] merge team)) - state - teams)))) + (let [team-ids (map :id teams) + ;; Delete old teams from state + state (update state :teams #(select-keys % team-ids))] + (reduce (fn [state {:keys [id organization-id] :as team}] + (let [team-updated (cond-> (merge (dm/get-in state [:teams id]) team) + (not organization-id) (dissoc :organization-id + :organization-name + :organization-slug + :organization-owner-id + :organization-avatar-bg-url))] + (update state :teams assoc id team-updated))) + state + teams))))) (defn fetch-teams [] @@ -255,7 +264,7 @@ (-deref [_] team))) (defn create-team - [{:keys [name] :as params}] + [{:keys [name organization-id] :as params}] (dm/assert! (string? name)) (ptk/reify ::create-team ptk/WatchEvent @@ -264,7 +273,8 @@ :or {on-success identity on-error rx/throw}} (meta params) features features/global-enabled-features - params {:name name :features features}] + params (cond-> {:name name :features features} + organization-id (assoc :organization-id organization-id))] (->> (rp/cmd! :create-team (with-meta params (meta it))) (rx/tap on-success) (rx/map team-created) @@ -581,3 +591,12 @@ (rx/map shared-files-fetched))))))) +(defn team->organization [team] + {:id (:organization-id team) + :slug (:organization-slug team) + :owner-id (:organization-owner-id team) + :avatar-bg-url (:organization-avatar-bg-url team) + :custom-photo (:organization-custom-photo team) + :name (:organization-name team) + :default-team-id (:id team)}) + diff --git a/frontend/src/app/main/data/workspace.cljs b/frontend/src/app/main/data/workspace.cljs index faccfb5750..4bc744174a 100644 --- a/frontend/src/app/main/data/workspace.cljs +++ b/frontend/src/app/main/data/workspace.cljs @@ -483,12 +483,13 @@ (rx/filter dch/commit?) (rx/map deref) (rx/mapcat - (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo?]}] + (fn [{:keys [save-undo? undo-changes redo-changes undo-group tags stack-undo? selected-before]}] (if (and save-undo? (seq undo-changes)) (let [entry {:undo-changes undo-changes :redo-changes redo-changes :undo-group undo-group - :tags tags}] + :tags tags + :selected-before selected-before}] (rx/of (dwu/append-undo entry stack-undo?))) (rx/empty)))))) @@ -515,7 +516,8 @@ :workspace-persistence :workspace-presence :workspace-tokens - :workspace-undo) + :workspace-undo + :workspace-versions) (update :workspace-global dissoc :read-only?) (assoc-in [:workspace-global :options-mode] :design) (update :files d/update-vals #(dissoc % :data)))) @@ -1206,6 +1208,16 @@ (-> params (assoc :kind :grid-cells :grid grid :cells cells)))))))) +(defn show-guide-context-menu + [{:keys [position guide] :as params}] + (dm/assert! (gpt/point? position)) + (ptk/reify ::show-guide-context-menu + ptk/WatchEvent + (watch [_ _ _] + (rx/of (show-context-menu + (-> params (assoc :kind :guide + :guide guide))))))) + (def hide-context-menu (ptk/reify ::hide-context-menu ptk/UpdateEvent @@ -1249,6 +1261,24 @@ (pcb/mod-page {:background (:color color)}))] (rx/of (dch/commit-changes changes))))))) +(defn change-pixel-grid-color + "Update the pixel grid color (and optional alpha) for the given page. + Mirrors `change-canvas-color` — stored on the page so the choice + travels with the file and persists across sessions." + ([color] + (change-pixel-grid-color nil color)) + ([page-id color] + (ptk/reify ::change-pixel-grid-color + ptk/WatchEvent + (watch [it state _] + (let [page-id (or page-id (:current-page-id state)) + page (dsh/lookup-page state page-id) + changes (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/mod-page {:pixel-grid-color (:color color) + :pixel-grid-opacity (:opacity color)}))] + (rx/of (dch/commit-changes changes))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -1431,6 +1461,19 @@ (update [_ state] (assoc-in state [:workspace-global :clipboard-style] style)))) +(defn open-layers-search + [mode] + (ptk/reify ::open-layers-search + ptk/UpdateEvent + (update [_ state] + (assoc-in state [:workspace-local :layers-panel-search] mode)))) + +(def clear-layers-search + (ptk/reify ::clear-layers-search + ptk/UpdateEvent + (update [_ state] + (update state :workspace-local dissoc :layers-panel-search)))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; Exports ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; diff --git a/frontend/src/app/main/data/workspace/clipboard.cljs b/frontend/src/app/main/data/workspace/clipboard.cljs index 4c3e60f7d4..e5d6dbd19b 100644 --- a/frontend/src/app/main/data/workspace/clipboard.cljs +++ b/frontend/src/app/main/data/workspace/clipboard.cljs @@ -18,6 +18,7 @@ [app.common.geom.shapes :as gsh] [app.common.geom.shapes.grid-layout :as gslg] [app.common.logic.libraries :as cll] + [app.common.logic.shapes :as cls] [app.common.schema :as sm] [app.common.transit :as t] [app.common.types.component :as ctc] @@ -260,7 +261,7 @@ :allowHTMLPaste (features/active-feature? @st/state "text-editor/v2-html-paste")}) (defn- create-paste-from-blob - [in-viewport?] + [in-viewport? replace?] (fn [blob] (let [type (.-type blob)] (cond @@ -281,7 +282,9 @@ (rx/filter map?) (rx/map (fn [pdata] - (assoc pdata :in-viewport in-viewport?))) + (-> pdata + (assoc :in-viewport in-viewport?) + (assoc :replace replace?)))) (rx/mapcat (fn [pdata] (case (:type pdata) @@ -293,8 +296,6 @@ (->> (rx/from (.text blob)) (rx/map paste-text)))))) -(def default-paste-from-blob (create-paste-from-blob false)) - (defn- clipboard-permission-error? "Check if the given error is a clipboard permission error (NotAllowedError DOMException)." @@ -313,14 +314,15 @@ (defn paste-from-clipboard "Perform a `paste` operation using the Clipboard API." - [] - (ptk/reify ::paste-from-clipboard - ptk/WatchEvent - (watch [_ _ _] - (->> (clipboard/from-navigator default-options) - (rx/mapcat default-paste-from-blob) - (rx/take 1) - (rx/catch on-clipboard-permission-error))))) + ([] (paste-from-clipboard nil)) + ([{:keys [replace?]}] + (ptk/reify ::paste-from-clipboard + ptk/WatchEvent + (watch [_ _ _] + (->> (clipboard/from-navigator default-options) + (rx/mapcat (create-paste-from-blob false (boolean replace?))) + (rx/take 1) + (rx/catch on-clipboard-permission-error)))))) (defn paste-from-event "Perform a `paste` operation from user emmited event." @@ -337,7 +339,7 @@ (if is-editing? (rx/empty) (->> (clipboard/from-synthetic-clipboard-event event default-options) - (rx/mapcat (create-paste-from-blob in-viewport?)))))))) + (rx/mapcat (create-paste-from-blob in-viewport? false)))))))) (defn copy-selected-svg [] @@ -356,7 +358,9 @@ shapes (mapv maybe-translate selected) svg-formatted (svg/generate-formatted-markup objects shapes)] - (clipboard/to-clipboard svg-formatted))))) + (clipboard/to-clipboard-multi + {"image/svg+xml" svg-formatted + "text/plain" svg-formatted}))))) (defn copy-selected-css [] @@ -543,8 +547,8 @@ (defn- frame-same-size? [paste-obj frame-obj] (and - (= (:heigth (:selrect (first (vals paste-obj)))) - (:heigth (:selrect frame-obj))) + (= (:height (:selrect (first (vals paste-obj)))) + (:height (:selrect frame-obj))) (= (:width (:selrect (first (vals paste-obj)))) (:width (:selrect frame-obj))))) @@ -722,7 +726,7 @@ (update change :obj process-rchange-shape media-idx) change)) - (calculate-paste-position [state pobjects selected position] + (calculate-paste-position [state pobjects selected position replace-id] (let [page-objects (dsh/lookup-page-objects state) selected-objs (map (d/getf pobjects) selected) first-selected-obj (first selected-objs) @@ -736,9 +740,20 @@ tree-root (get-tree-root-shapes pobjects) only-one-root-shape? (and (< 1 (count pobjects)) - (= 1 (count tree-root)))] + (= 1 (count tree-root))) + replaced (some->> replace-id (get page-objects))] (cond + ;; Paste in place: center pasted content on the replaced shape and + ;; reparent to its container. The replaced shape is deleted below + ;; so the new content takes its z-index slot. + (some? replaced) + (let [delta (gpt/subtract (gsh/shape->center replaced) + (grc/rect->center wrapper)) + parent-id (:parent-id replaced) + target-index (cfh/get-position-on-parent page-objects replace-id)] + [parent-id delta target-index]) + ;; Paste next to selected frame, if selected is itself or of the same size as the copied (and (selected-frame? state) (or (any-same-frame-from-selected? state (keys pobjects)) @@ -854,10 +869,17 @@ position (deref ms/mouse-position) + ;; Replace mode is only valid with a single selected shape. + ;; In that case we drop the pasted content at its position and + ;; delete it in the same transaction. + page-selected (dsh/lookup-selected state) + replace-id (when (and (:replace pdata) (= 1 (count page-selected))) + (first page-selected)) + ;; Calculate position for the pasted elements [candidate-parent-id delta - index] (calculate-paste-position state objects selected position) + index] (calculate-paste-position state objects selected position replace-id) page-objects (:objects page) @@ -899,6 +921,10 @@ (map :id) (pcb/resize-parents changes)) + changes (if (some? replace-id) + (second (cls/generate-delete-shapes changes #{replace-id} {})) + changes) + orig-shapes (map (d/getf all-objects) selected) children-after (-> (pcb/get-objects changes) diff --git a/frontend/src/app/main/data/workspace/collapse.cljs b/frontend/src/app/main/data/workspace/collapse.cljs index 1143a6f4d8..b5b4998c6b 100644 --- a/frontend/src/app/main/data/workspace/collapse.cljs +++ b/frontend/src/app/main/data/workspace/collapse.cljs @@ -49,3 +49,19 @@ (update [_ state] (update state :workspace-local dissoc :expanded)))) +(defn expand-subtree + "Recursively expand the layer subtree rooted at `id`, marking the shape + and all of its descendants as expanded in the Layers sidebar. + + Closes the gap with `collapse-all`: there was no symmetric way to + open every nested level of a single subtree, so unfolding a deep + shape required clicking each disclosure indicator one by one + (O(siblings × depth) clicks)." + [id objects] + (ptk/reify ::expand-subtree + ptk/UpdateEvent + (update [_ state] + (let [ids (cfh/get-children-ids-with-self objects id) + expansions (into {} (map (fn [descendant-id] [descendant-id true])) ids)] + (update-in state [:workspace-local :expanded] merge expansions))))) + diff --git a/frontend/src/app/main/data/workspace/guides.cljs b/frontend/src/app/main/data/workspace/guides.cljs index 4ef75ee613..3946e3efbe 100644 --- a/frontend/src/app/main/data/workspace/guides.cljs +++ b/frontend/src/app/main/data/workspace/guides.cljs @@ -6,6 +6,7 @@ (ns app.main.data.workspace.guides (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.changes-builder :as pcb] [app.common.geom.point :as gpt] @@ -77,6 +78,36 @@ guides (-> (select-keys guides ids) (vals))] (rx/from (mapv remove-guide guides)))))) +(defn remove-frame-guides + [frame-ids] + + (assert (every? uuid? frame-ids) "expected a coll of uuids") + + (ptk/reify ::remove-frame-guides + ptk/UpdateEvent + (update [_ state] + (let [{:keys [guides]} (dsh/lookup-page state) + frame-ids-set (set frame-ids) + guide-ids (into #{} + (comp (filter #(contains? frame-ids-set (:frame-id %))) + d/xf:map-id) + (vals guides))] + (update-in state [:workspace-guides :hover] + (fn [hover] (reduce disj (or hover #{}) guide-ids))))) + + ptk/WatchEvent + (watch [it state _] + (let [{:keys [guides] :as page} (dsh/lookup-page state) + frame-ids-set (set frame-ids) + to-remove (filter #(contains? frame-ids-set (:frame-id %)) (vals guides)) + changes (reduce + (fn [acc {:keys [id]}] + (pcb/set-guide acc id nil)) + (-> (pcb/empty-changes it) + (pcb/with-page page)) + to-remove)] + (rx/of (dwc/commit-changes changes)))))) + (defmethod ptk/resolve ::move-frame-guides [_ args] (dm/assert! @@ -121,6 +152,23 @@ (map build-move-event) (rx/from)))))) +(defn update-guide-color + [guide-id color] + (ptk/reify ::update-guide-color + ptk/WatchEvent + (watch [it state _] + (let [{:keys [guides] :as page} (dsh/lookup-page state) + guide (get guides guide-id)] + (when (some? guide) + (let [updated-guide (if (some? color) + (assoc guide :color color) + (dissoc guide :color)) + changes + (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/set-guide guide-id updated-guide))] + (rx/of (dwc/commit-changes changes)))))))) + (defn set-hover-guide [id hover?] (ptk/reify ::set-hover-guide diff --git a/frontend/src/app/main/data/workspace/layout.cljs b/frontend/src/app/main/data/workspace/layout.cljs index 44cd36e5ce..fad7a91802 100644 --- a/frontend/src/app/main/data/workspace/layout.cljs +++ b/frontend/src/app/main/data/workspace/layout.cljs @@ -25,6 +25,7 @@ :element-options :rulers :display-guides + :lock-guides :snap-guides :scale-text :dynamic-alignment diff --git a/frontend/src/app/main/data/workspace/pages.cljs b/frontend/src/app/main/data/workspace/pages.cljs index 5865cb969d..222a4a5c0e 100644 --- a/frontend/src/app/main/data/workspace/pages.cljs +++ b/frontend/src/app/main/data/workspace/pages.cljs @@ -328,11 +328,24 @@ (ptk/reify ::rename-page ptk/WatchEvent (watch [it state _] - (let [page (dsh/lookup-page state id) - changes (-> (pcb/empty-changes it) - (pcb/with-page page) - (pcb/mod-page page {:name name}))] - (rx/of (dch/commit-changes changes)))))) + (let [page (dsh/lookup-page state id) + changes (-> (pcb/empty-changes it) + (pcb/with-page page) + (pcb/mod-page page {:name name})) + pages (-> (dsh/lookup-file-data state) :pages) + index (d/index-of pages id) + prev-id (when (and (some? index) (pos? index)) + (nth pages (dec index) nil)) + next-id (when (some? index) + (nth pages (inc index) nil)) + fallback-page-id (or prev-id next-id) + separator? (= "---" (str/trim name))] + (rx/concat + (rx/of (dch/commit-changes changes)) + (when (and separator? + (= id (:current-page-id state)) + (some? fallback-page-id)) + (rx/of (dcm/go-to-workspace :page-id fallback-page-id)))))))) (defn- delete-page-components [changes page] diff --git a/frontend/src/app/main/data/workspace/selection.cljs b/frontend/src/app/main/data/workspace/selection.cljs index 45c323a860..d0ce0c5c2f 100644 --- a/frontend/src/app/main/data/workspace/selection.cljs +++ b/frontend/src/app/main/data/workspace/selection.cljs @@ -173,13 +173,17 @@ current (get objects first-selected) parent (get objects (:parent-id current)) sibling-ids (:shapes parent) - current-index (d/index-of sibling-ids first-selected) - sibling (if (= (dec (count sibling-ids)) current-index) - (first sibling-ids) - (nth sibling-ids (inc current-index)))] + ;; `index-of` is nil when the shape is not listed under the parent (stale + ;; selection or inconsistent tree). Do not call `nth` with `(dec nil)` — in + ;; ClojureScript that is -1 and throws (see penpot#7064). + current-index (some-> sibling-ids (d/index-of first-selected)) + sibling (when (some? current-index) + (if (= (dec (count sibling-ids)) current-index) + (first sibling-ids) + (nth sibling-ids (inc current-index) nil)))] (cond - (= 1 count-selected) + (and (= 1 count-selected) (some? sibling)) (rx/of (select-shape sibling)) (> count-selected 1) @@ -198,12 +202,13 @@ current (get objects first-selected) parent (get objects (:parent-id current)) sibling-ids (:shapes parent) - current-index (d/index-of sibling-ids first-selected) - sibling (if (= 0 current-index) - (last sibling-ids) - (nth sibling-ids (dec current-index)))] + current-index (some-> sibling-ids (d/index-of first-selected)) + sibling (when (some? current-index) + (if (= 0 current-index) + (last sibling-ids) + (nth sibling-ids (dec current-index) nil)))] (cond - (= 1 count-selected) + (and (= 1 count-selected) (some? sibling)) (rx/of (select-shape sibling)) (> count-selected 1) diff --git a/frontend/src/app/main/data/workspace/shape_layout.cljs b/frontend/src/app/main/data/workspace/shape_layout.cljs index 163195f11f..bbff39663a 100644 --- a/frontend/src/app/main/data/workspace/shape_layout.cljs +++ b/frontend/src/app/main/data/workspace/shape_layout.cljs @@ -787,3 +787,135 @@ (dch/commit-changes changes) (ptk/data-event :layout/update {:ids [layout-id]}) (dwu/commit-undo-transaction undo-id)))))) + +(defn complete-rows? + "Check if the selected cells cover complete row(s) — all columns must be included." + [grid cells] + (let [{:keys [first-column last-column]} (ctl/cells-coordinates cells) + num-columns (count (:layout-grid-columns grid))] + (and (= first-column 1) + (= last-column num-columns)))) + +(defn complete-columns? + "Check if the selected cells cover complete column(s) — all rows must be included." + [grid cells] + (let [{:keys [first-row last-row]} (ctl/cells-coordinates cells) + num-rows (count (:layout-grid-rows grid))] + (and (= first-row 1) + (= last-row num-rows)))) + +(defn copy-grid-tracks + "Store the selected track indices for later paste. Works for both + complete rows and complete columns." + [grid-id type] + (assert (#{:row :column} type)) + (ptk/reify ::copy-grid-tracks + ptk/UpdateEvent + (update [_ state] + (let [objects (dsh/lookup-page-objects state) + grid (get objects grid-id) + selected (get-in state [:workspace-grid-edition grid-id :selected]) + cells (->> selected (map #(get-in grid [:layout-grid-cells %]))) + {:keys [first-row last-row first-column last-column]} (ctl/cells-coordinates cells) + ;; Convert 1-indexed cell positions to 0-indexed track indices + track-indices (if (= type :row) + (vec (range (dec first-row) last-row)) + (vec (range (dec first-column) last-column)))] + (assoc-in state [:workspace-grid-edition grid-id :copied-tracks] + {:track-indices track-indices + :type type + :grid-id grid-id}))))) + +(defn paste-grid-tracks + "Paste previously copied tracks at the end of the grid. + Each source track is duplicated and appended after the last + existing track. All operations are grouped in a single undo + transaction. Follows the same pattern as `duplicate-layout-track`." + [grid-id] + (ptk/reify ::paste-grid-tracks + ptk/WatchEvent + (watch [it state _] + (let [file-id (:current-file-id state) + page (dsh/lookup-page state) + objects (:objects page) + libraries (dsh/lookup-libraries state) + library-data (dsh/lookup-file state file-id) + grid (get objects grid-id) + + copied (get-in state [:workspace-grid-edition grid-id :copied-tracks]) + track-indices (:track-indices copied) + type (:type copied) + undo-id (js/Symbol)] + + (when (and (seq track-indices) (some? type)) + (let [shapes-by-track-fn + (if (= type :row) + ctl/shapes-by-row + ctl/shapes-by-column) + + ;; Collect shapes from all source tracks + all-shapes + (->> track-indices + (mapcat #(shapes-by-track-fn grid % false)) + (set)) + + ;; Generate duplication changes for all shapes at once + changes + (-> (pcb/empty-changes it) + (cll/generate-duplicate-changes objects page all-shapes (gpt/point 0 0) libraries library-data file-id) + (cll/generate-duplicate-changes-update-indices objects all-shapes)) + + ;; Build ids-map: old-shape-id -> new-shape-id + ids-map + (->> changes + :redo-changes + (filter #(= (:type %) :add-obj)) + (filter #(all-shapes (:old-id %))) + (map #(vector (:old-id %) (get-in % [:obj :id]))) + (into {})) + + duplicate-at-fn + (if (= type :row) + ctl/duplicate-row-at + ctl/duplicate-column-at) + + tracks-prop + (if (= type :row) + :layout-grid-rows + :layout-grid-columns) + + ;; Sort source indices ascending — we'll append each + ;; copy at the end in order, preserving the original + ;; track ordering in the appended block. + sorted-indices (vec (sort track-indices)) + + changes + (-> changes + (pcb/update-shapes + [grid-id] + (fn [shape objects] + ;; Restore grid structure (duplication may have altered it) + (let [shape (merge shape (select-keys grid [:layout-grid-cells :layout-grid-columns :layout-grid-rows]))] + ;; Append each source track at the end. + ;; Process in ascending order so the copies + ;; appear in the same order as the originals. + ;; Each insertion adds one track, so both the + ;; target index and the source index (if it + ;; comes after the target) shift by 1. + (reduce + (fn [s [offset src-idx]] + (let [;; Source tracks don't shift because we + ;; append after them (target > source). + actual-src src-idx + ;; Append at the end (which grows by + ;; one with each iteration). + target-idx (+ (count (get grid tracks-prop)) offset)] + (duplicate-at-fn s objects actual-src target-idx ids-map))) + shape + (map-indexed vector sorted-indices)))) + {:with-objects? true}))] + + (rx/of (dwu/start-undo-transaction undo-id) + (dch/commit-changes changes) + (ptk/data-event :layout/update {:ids [grid-id]}) + (dwu/commit-undo-transaction undo-id)))))))) diff --git a/frontend/src/app/main/data/workspace/shortcuts.cljs b/frontend/src/app/main/data/workspace/shortcuts.cljs index 4f4d9296cc..e7ff9a99ed 100644 --- a/frontend/src/app/main/data/workspace/shortcuts.cljs +++ b/frontend/src/app/main/data/workspace/shortcuts.cljs @@ -104,6 +104,11 @@ :subsections [:edit] :fn (constantly nil)} + :paste-replace {:tooltip (ds/meta (ds/shift "V")) + :command (ds/c-mod "shift+v") + :subsections [:edit] + :fn #(emit-when-no-readonly (dw/paste-from-clipboard {:replace? true}))} + :copy-props {:tooltip (ds/meta (ds/alt "c")) :command (ds/c-mod "alt+c") :subsections [:edit] @@ -146,6 +151,11 @@ :subsections [:edit] :fn #(st/emit! esc-pressed)} + :find {:tooltip (ds/meta "F") :command (ds/c-mod "f") :subsections [:edit] + :fn #(st/emit! (dw/open-layers-search :find))} + :find-and-replace {:tooltip (ds/meta "H") :command (ds/c-mod "h") :subsections [:edit] + :fn #(st/emit! (dw/open-layers-search :find-and-replace))} + ;; MODIFY LAYERS :rename {:tooltip (ds/alt "N") @@ -504,17 +514,17 @@ :fn #(st/emit! (dw/decrease-zoom))} :reset-zoom {:tooltip (ds/shift "0") - :command "shift+0" + :command ["shift+0" "shift+num0"] :subsections [:zoom-workspace] :fn #(st/emit! dw/reset-zoom)} :fit-all {:tooltip (ds/shift "1") - :command "shift+1" + :command ["shift+1" "shift+num1"] :subsections [:zoom-workspace] :fn #(st/emit! dw/zoom-to-fit-all)} :zoom-selected {:tooltip (ds/shift "2") - :command ["shift+2" "@" "\""] + :command ["shift+2" "shift+num2" "@" "\""] :subsections [:zoom-workspace] :fn #(st/emit! dw/zoom-to-selected-shape)} @@ -616,7 +626,7 @@ (range 10) (map (fn [n] [(keyword (str "opacity-" n)) {:tooltip (str n) - :command (str n) + :command [(str n) (str "num" n)] :subsections [:modify-layers] :fn #(emit-when-no-readonly (dwly/pressed-opacity n))}]))))) diff --git a/frontend/src/app/main/data/workspace/texts.cljs b/frontend/src/app/main/data/workspace/texts.cljs index fe12f88804..bb604b3f2f 100644 --- a/frontend/src/app/main/data/workspace/texts.cljs +++ b/frontend/src/app/main/data/workspace/texts.cljs @@ -905,50 +905,61 @@ "A higher level version of dwl/add-typography, and has mainly two responsabilities: add the typography to the library and apply it to the currently selected text shapes (being aware of the open text - editors." - [file-id] - (ptk/reify ::add-typography - ptk/WatchEvent - (watch [_ state _] - (let [selected (dsh/lookup-selected state) - objects (dsh/lookup-page-objects state) + editors. + Optionally accepts a group-path to place the new typography inside + a specific group." + ([file-id] (add-typography file-id nil)) + ([file-id group-path] + (ptk/reify ::add-typography + ptk/WatchEvent + (watch [_ state _] + (let [selected (dsh/lookup-selected state) + objects (dsh/lookup-page-objects state) - xform (comp (keep (d/getf objects)) - (filter cfh/text-shape?)) - shapes (into [] xform selected) - shape (first shapes) + xform (comp (keep (d/getf objects)) + (filter cfh/text-shape?)) + shapes (into [] xform selected) + shape (first shapes) - values (current-text-values - {:editor-state (dm/get-in state [:workspace-editor-state (:id shape)]) - :shape shape - :attrs txt/text-node-attrs}) + values (current-text-values + {:editor-state (dm/get-in state [:workspace-editor-state (:id shape)]) + :shape shape + :attrs txt/text-node-attrs}) - multiple? (or (> 1 (count shapes)) - (d/seek (partial = :multiple) - (vals values))) + multiple? (or (> 1 (count shapes)) + (d/seek (partial = :multiple) + (vals values))) - values (-> (d/without-nils values) - (select-keys - (d/concat-vec txt/text-font-attrs - txt/text-spacing-attrs - txt/text-transform-attrs))) + values (-> (d/without-nils values) + (select-keys + (d/concat-vec txt/text-font-attrs + txt/text-spacing-attrs + txt/text-transform-attrs))) + values (cond-> values + (number? (:line-height values)) + (update :line-height str) - typ-id (uuid/next) - typ (-> (if multiple? - txt/default-typography - (merge txt/default-typography values)) - (generate-typography-name) - (assoc :id typ-id))] + (number? (:letter-spacing values)) + (update :letter-spacing str)) - (rx/concat - (rx/of (dwl/add-typography typ) - (ptk/event ::ev/event {::ev/name "add-asset-to-library" - :asset-type "typography"})) + typ-id (uuid/next) + typ (-> (if multiple? + txt/default-typography + (merge txt/default-typography values)) + (generate-typography-name) + (assoc :id typ-id) + (cond-> (string? group-path) + (update :name #(str group-path " / " %))))] - (when (not multiple?) - (rx/of (update-attrs (:id shape) - {:typography-ref-id typ-id - :typography-ref-file file-id})))))))) + (rx/concat + (rx/of (dwl/add-typography typ) + (ptk/event ::ev/event {::ev/name "add-asset-to-library" + :asset-type "typography"})) + + (when (not multiple?) + (rx/of (update-attrs (:id shape) + {:typography-ref-id typ-id + :typography-ref-file file-id}))))))))) ;; -- Text Editor v2 @@ -1095,16 +1106,15 @@ content-has-text? has-prev-content?) (dissoc :prev-content)) + (cond-> (and (not new-shape?) prev-content-has-text? (not content-has-text?) (not finalize?)) (assoc :prev-content prev-content)) + (cond-> (and update-name? (some? name)) - (assoc :name name)) - (cond-> (some? new-size) - (gsh/transform-shape - (ctm/change-size shape (:width new-size) (:height new-size)))))) + (assoc :name name)))) {:save-undo? finalize-save-undo-first? :stack-undo? effective-stack-undo? :undo-group (when new-shape? id)}) @@ -1167,6 +1177,35 @@ (gsh/transform-shape (ctm/change-size shape width height)))))) {:undo-group (when new-shape? id)}))))))) +(defn replace-layer-names-in-shapes + [ids search replacement] + (ptk/reify ::replace-layer-names-in-shapes + ptk/WatchEvent + (watch [_ _ _] + (let [undo-group (uuid/next)] + (rx/of + (dwsh/update-shapes + ids + (fn [shape] (update shape :name txt/replace-all-case-insensitive search replacement)) + {:attrs #{:name} :undo-group undo-group})))))) + +(defn replace-text-in-shapes + [ids search replacement] + (ptk/reify ::replace-text-in-shapes + ptk/WatchEvent + (watch [_ _ _] + (let [undo-group (uuid/next)] + (rx/of + (dwsh/update-shapes + ids + (fn [shape] + (if (and (= :text (:type shape)) (some? (:content shape))) + (let [new-content (txt/replace-text-in-content (:content shape) search replacement) + new-name (txt/generate-shape-name (txt/content->text new-content))] + (-> shape (assoc :content new-content) (assoc :name new-name))) + shape)) + {:attrs #{:content :name} :undo-group undo-group})))))) + ;; -- Text Editor v3 ;; @see texts_v3.cljs diff --git a/frontend/src/app/main/data/workspace/tokens/application.cljs b/frontend/src/app/main/data/workspace/tokens/application.cljs index 3ee7758284..89cccdd869 100644 --- a/frontend/src/app/main/data/workspace/tokens/application.cljs +++ b/frontend/src/app/main/data/workspace/tokens/application.cljs @@ -656,6 +656,7 @@ this is useful for applying a single attribute from an attributes set while removing other applied tokens from this set." [{:keys [attributes attributes-to-remove token shape-ids on-update-shape]}] + (assert (ctob/token? token) "apply-token event requires a valid token") (ptk/reify ::apply-token ptk/WatchEvent (watch [_ state _] @@ -667,9 +668,10 @@ text-editing? (and (some? edition) (= :text (:type (get objects edition))))] (if (and (empty? (get state :workspace-editor-state)) + (some? token) (not text-editing?)) (let [attributes-to-remove - ;; Remove atomic typography tokens when applying composite and vice-verca + ;; Remove atomic typography tokens when applying composite and vice-versa (cond (ctt/typography-token-keys (:type token)) (set/union attributes-to-remove ctt/typography-keys) (ctt/typography-keys (:type token)) (set/union attributes-to-remove ctt/typography-token-keys) @@ -696,7 +698,7 @@ shape-ids (d/nilv (keys shapes) []) any-variant? (->> shapes vals (some ctk/is-variant?) boolean) - resolved-value (get-in resolved-tokens [(cfo/token-identifier token) :resolved-value]) + resolved-value (get-in resolved-tokens [(:name token) :resolved-value]) resolved-value (if (contains? cf/flags :tokenscript) (ts/tokenscript-symbols->penpot-unit resolved-value) resolved-value) @@ -822,9 +824,50 @@ :shape-ids shape-ids :on-update-shape on-update-shape})))))))) -(defn apply-token-on-selected +(defn apply-token-from-input + [{:keys [token attrs shape-ids expand-with-children]}] + (ptk/reify ::apply-token-from-input + ptk/WatchEvent + (watch [_ state _] + (let [objects (dsh/lookup-page-objects state) + shapes (into [] (keep (d/getf objects)) shape-ids) + + shapes + (if expand-with-children + (into [] + (mapcat (fn [shape] + (if (= (:type shape) :group) + (keep objects (:shapes shape)) + [shape]))) + shapes) + shapes) + + {:keys [attributes _ on-update-shape]} + (get token-properties (:type token)) + + on-update-shape + (if (seq attrs) + (or (get attr->shape-update (first attrs)) on-update-shape) + on-update-shape)] + + (rx/of + (cond + (and (= (:type token) :spacing) + (nil? attrs)) + (apply-spacing-token-separated {:token token + :attr attrs + :shapes shapes}) + + :else + (apply-token {:attributes (if (empty? attrs) attributes attrs) + :token token + :shape-ids shape-ids + :on-update-shape on-update-shape}))))))) + + +(defn apply-token-on-color-selected [color-operations token] - (ptk/reify ::apply-token-on-selected + (ptk/reify ::apply-token-on-color-selected ptk/WatchEvent (watch [_ _ _] (let [undo-id (js/Symbol)] diff --git a/frontend/src/app/main/data/workspace/tokens/errors.cljs b/frontend/src/app/main/data/workspace/tokens/errors.cljs index 30ab2e30b9..7338663b2d 100644 --- a/frontend/src/app/main/data/workspace/tokens/errors.cljs +++ b/frontend/src/app/main/data/workspace/tokens/errors.cljs @@ -12,109 +12,109 @@ (def error-codes {:error.import/json-parse-error {:error/code :error.import/json-parse-error - :error/fn #(tr "workspace.tokens.error-parse")} + :error/fn #(tr "errors.tokens.error-parse")} :error.import/no-token-files-found {:error/code :error.import/no-token-files-found - :error/fn #(tr "workspace.tokens.no-token-files-found")} + :error/fn #(tr "errors.tokens.no-token-files-found")} :error.import/invalid-json-data {:error/code :error.import/invalid-json-data - :error/fn #(tr "workspace.tokens.invalid-json")} + :error/fn #(tr "errors.tokens.invalid-json")} :error.import/invalid-token-name {:error/code :error.import/invalid-token-name - :error/fn #(tr "workspace.tokens.invalid-json-token-name") - :error/detail #(tr "workspace.tokens.invalid-json-token-name-detail" %)} + :error/fn #(tr "errors.tokens.invalid-json-token-name") + :error/detail #(tr "errors.tokens.invalid-json-token-name-detail" %)} :error.import/style-dictionary-reference-errors {:error/code :error.import/style-dictionary-reference-errors - :error/fn #(str (tr "workspace.tokens.import-error") "\n\n" (first %)) + :error/fn #(str (tr "errors.tokens.import-error") "\n\n" (first %)) :error/detail #(str/join "\n\n" (rest %))} :error.import/style-dictionary-unknown-error {:error/code :error.import/style-dictionary-reference-errors - :error/fn #(tr "workspace.tokens.import-error")} + :error/fn #(tr "errors.tokens.import-error")} :error.token/empty-input {:error/code :error.token/empty-input - :error/fn #(tr "workspace.tokens.empty-input")} + :error/fn #(tr "errors.tokens.empty-input")} :error.token/direct-self-reference {:error/code :error.token/direct-self-reference - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} :error.token/invalid-color {:error/code :error.token/invalid-color - :error/fn #(str (tr "workspace.tokens.invalid-color" %))} + :error/fn #(str (tr "errors.tokens.invalid-color" %))} :error.token/number-too-large {:error/code :error.token/number-too-large - :error/fn #(str (tr "workspace.tokens.number-too-large" %))} + :error/fn #(str (tr "errors.tokens.number-too-large" %))} :error.style-dictionary/missing-reference {:error/code :error.style-dictionary/missing-reference - :error/fn #(str (tr "workspace.tokens.missing-references") (str/join " " %))} + :error/fn #(str (tr "errors.tokens.missing-references") (str/join " " %))} :error.style-dictionary/invalid-token-value {:error/code :error.style-dictionary/invalid-token-value - :error/fn #(str (tr "workspace.tokens.invalid-value" %))} + :error/fn #(str (tr "errors.tokens.invalid-value" %))} :error.style-dictionary/value-with-units {:error/code :error.style-dictionary/value-with-units - :error/fn #(str (tr "workspace.tokens.value-with-units"))} + :error/fn #(str (tr "errors.tokens.value-with-units"))} :error.style-dictionary/value-with-percent {:error/code :error.style-dictionary/value-with-percent - :error/fn #(str (tr "workspace.tokens.value-with-percent"))} + :error/fn #(str (tr "errors.tokens.value-with-percent"))} :error.style-dictionary/invalid-token-value-opacity {:error/code :error.style-dictionary/invalid-token-value-opacity - :error/fn #(str/join "\n" [(str (tr "workspace.tokens.invalid-value" %) ".") (tr "workspace.tokens.opacity-range")])} + :error/fn #(str/join "\n" [(str (tr "errors.tokens.invalid-value" %) ".") (tr "errors.tokens.opacity-range")])} :error.style-dictionary/invalid-token-value-stroke-width {:error/code :error.style-dictionary/invalid-token-value-stroke-width - :error/fn #(str/join "\n" [(str (tr "workspace.tokens.invalid-value" %) ".") (tr "workspace.tokens.stroke-width-range")])} + :error/fn #(str/join "\n" [(str (tr "errors.tokens.invalid-value" %) ".") (tr "errors.tokens.stroke-width-range")])} :error.style-dictionary/invalid-token-value-text-case {:error/code :error.style-dictionary/invalid-token-value-text-case - :error/fn #(tr "workspace.tokens.invalid-text-case-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-text-case-token-value" %)} :error.style-dictionary/invalid-token-value-text-decoration {:error/code :error.style-dictionary/invalid-token-value-text-decoration - :error/fn #(tr "workspace.tokens.invalid-text-decoration-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-text-decoration-token-value" %)} :error.style-dictionary/invalid-token-value-font-weight {:error/code :error.style-dictionary/invalid-token-value-font-weight - :error/fn #(tr "workspace.tokens.invalid-font-weight-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-font-weight-token-value" %)} :error.style-dictionary/invalid-token-value-font-family {:error/code :error.style-dictionary/invalid-token-value-font-family - :error/fn #(tr "workspace.tokens.invalid-font-family-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-font-family-token-value" %)} :error.style-dictionary/invalid-token-value-typography {:error/code :error.style-dictionary/invalid-token-value-typography - :error/fn #(tr "workspace.tokens.invalid-token-value-typography" %)} + :error/fn #(tr "errors.tokens.invalid-token-value-typography" %)} :error.style-dictionary/composite-line-height-needs-font-size {:error/code :error.style-dictionary/composite-line-height-needs-font-size - :error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size" %)} + :error/fn #(tr "errors.tokens.composite-line-height-needs-font-size" %)} :error.style-dictionary/invalid-token-value-shadow-type {:error/code :error.style-dictionary/invalid-token-value-shadow-type - :error/fn #(tr "workspace.tokens.invalid-shadow-type-token-value" %)} + :error/fn #(tr "errors.tokens.invalid-shadow-type-token-value" %)} :error.style-dictionary/invalid-token-value-shadow-blur {:error/code :error.style-dictionary/invalid-token-value-shadow-blur - :error/fn #(tr "workspace.tokens.shadow-blur-range")} + :error/fn #(tr "errors.tokens.shadow-blur-range")} :error.style-dictionary/invalid-token-value-shadow-spread {:error/code :error.style-dictionary/invalid-token-value-shadow-spread - :error/fn #(tr "workspace.tokens.shadow-spread-range")} + :error/fn #(tr "errors.tokens.shadow-spread-range")} :error.style-dictionary/invalid-token-value-shadow {:error/code :error.style-dictionary/invalid-token-value-shadow - :error/fn #(tr "workspace.tokens.invalid-token-value-shadow" %)} + :error/fn #(tr "errors.tokens.invalid-token-value-shadow" %)} :error/unknown {:error/code :error/unknown diff --git a/frontend/src/app/main/data/workspace/tokens/import_export.cljs b/frontend/src/app/main/data/workspace/tokens/import_export.cljs index 32ba61fd70..f9793d38c8 100644 --- a/frontend/src/app/main/data/workspace/tokens/import_export.cljs +++ b/frontend/src/app/main/data/workspace/tokens/import_export.cljs @@ -7,6 +7,7 @@ (ns app.main.data.workspace.tokens.import-export (:require [app.common.json :as json] + [app.common.logging :as l] [app.common.path-names :as cpn] [app.common.types.tokens-lib :as ctob] [app.config :as cf] @@ -15,7 +16,7 @@ [app.main.data.tokenscript :as ts] [app.main.data.workspace.tokens.errors :as wte] [app.main.store :as st] - [app.util.i18n :refer [tr]] + [app.util.i18n :as i18n] [beicon.v2.core :as rx] [cuerdas.core :as str])) @@ -44,10 +45,20 @@ (defn- show-unknown-types-warning [unknown-tokens] (let [type->tokens (group-by-value unknown-tokens)] - (ntf/show {:content (tr "workspace.tokens.unknown-token-type-message") - :detail (->> (for [[token-type tokens] type->tokens] - (tr "workspace.tokens.unknown-token-type-section" token-type (count tokens))) - (str/join "
")) + (l/wrn :hint "unsupported token types found during import" + :tokens (str/join ", " (map (fn [[path type]] (str path " (" type ")")) unknown-tokens))) + (ntf/show {:content (i18n/tr "workspace.tokens.unknown-token-type-message") + :detail (->> (for [[token-type token-paths] type->tokens] + (str (i18n/tr "workspace.tokens.unknown-token-type-section" + token-type + (i18n/tr "labels.warning-count" (i18n/c (count token-paths)))) + "
    " + (->> token-paths + (sort) + (map #(str "
  • " % "
  • ")) + (str/join "")) + "
")) + (str/join "")) :type :toast :level :info}))) diff --git a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs index 4daccd05b8..4de3430986 100644 --- a/frontend/src/app/main/data/workspace/tokens/library_edit.cljs +++ b/frontend/src/app/main/data/workspace/tokens/library_edit.cljs @@ -12,6 +12,7 @@ [app.common.geom.point :as gpt] [app.common.logic.tokens :as clt] [app.common.path-names :as cpn] + [app.common.test-helpers.ids-map :as cthi] [app.common.types.shape :as cts] [app.common.types.tokens-lib :as ctob] [app.common.uuid :as uuid] @@ -22,6 +23,7 @@ [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.tokens.propagation :as dwtp] [app.util.i18n :refer [tr]] + [app.util.storage :as storage] [beicon.v2.core :as rx] [cuerdas.core :as str] [potok.v2.core :as ptk])) @@ -62,52 +64,147 @@ (watch [_ _ _] (rx/of (dwsh/update-shapes [id] #(merge % attrs))))))) - ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -;; Toggle tree nodes +;; TOKENS TREE - Type folders ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; -(defn- remove-paths-recursively +;; Helper functions for localStorage persistence +(defn- get-unfolded-token-types-from-storage + [file-id set-id] + (get-in storage/user [:app.main.ui.workspace.tokens/unfolded-token-types file-id set-id] #{})) + +(defn- save-unfolded-token-types-in-storage + [file-id set-id types] + (swap! storage/user update :app.main.ui.workspace.tokens/unfolded-token-types + assoc-in [file-id set-id] (vec types))) + +;; Helper functions for app state persistence +(defn- make-unfolded-token-types-state + [file-id set-id types] + {:file-id file-id + :set-id set-id + :types (set (or types #{}))}) + +(defn- get-unfolded-token-types-from-state + [state] + (let [value (get-in state [:workspace-tokens :unfolded-token-types])] + (or (:types value) #{}))) + +(defn restore-unfolded-token-types + "Loads unfolded token types from localStorage for the current file and set" + [] + (ptk/reify ::restore-unfolded-token-types + ptk/UpdateEvent + (update [_ state] + (let [file-id (:current-file-id state) + set-id (get-in state [:workspace-tokens :selected-token-set-id]) + stored (get-unfolded-token-types-from-storage file-id set-id)] + (assoc-in state + [:workspace-tokens :unfolded-token-types] + (make-unfolded-token-types-state file-id set-id stored)))))) + +(defn open-token-type + ([types type] + (conj (or types #{}) type)) + ([type] + (ptk/reify ::open-token-type + ptk/UpdateEvent + (update [_ state] + (let [file-id (:current-file-id state) + set-id (get-in state [:workspace-tokens :selected-token-set-id]) + types (get-unfolded-token-types-from-state state) + new-types (open-token-type types type) + new-state (assoc-in state + [:workspace-tokens :unfolded-token-types] + (make-unfolded-token-types-state file-id set-id new-types))] + (save-unfolded-token-types-in-storage file-id set-id + new-types) + new-state))))) + +(defn close-token-type + ([types type] + (disj (or types #{}) type)) + ([type] + (ptk/reify ::close-token-type + ptk/UpdateEvent + (update [_ state] + (let [file-id (:current-file-id state) + set-id (get-in state [:workspace-tokens :selected-token-set-id]) + types (get-unfolded-token-types-from-state state) + new-types (close-token-type types type) + new-state (assoc-in state + [:workspace-tokens :unfolded-token-types] + (make-unfolded-token-types-state file-id set-id new-types))] + (save-unfolded-token-types-in-storage file-id set-id + new-types) + new-state))))) + +(defn + toggle-token-type + [type] + (ptk/reify ::toggle-token-type + ptk/UpdateEvent + (update [_ state] + (let [file-id (:current-file-id state) + set-id (get-in state [:workspace-tokens :selected-token-set-id]) + types (get-unfolded-token-types-from-state state) + new-types (if (contains? types type) + (close-token-type types type) + (open-token-type types type)) + new-state (assoc-in state + [:workspace-tokens :unfolded-token-types] + (make-unfolded-token-types-state file-id set-id new-types))] + (save-unfolded-token-types-in-storage file-id set-id + new-types) + new-state)))) + +(defn clear-tokens-types + [] + (ptk/reify ::clear-tokens-types + ptk/UpdateEvent + (update [_ state] + (let [file-id (:current-file-id state) + set-id (get-in state [:workspace-tokens :selected-token-set-id])] + (save-unfolded-token-types-in-storage file-id set-id #{}) + (assoc-in state + [:workspace-tokens :unfolded-token-types] + (make-unfolded-token-types-state file-id set-id #{})))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; TOKENS TREE - Toggle tree nodes +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- remove-path [path paths] (->> paths - (remove #(str/starts-with? % (str path))) + (remove #(= % path)) vec)) (defn add-path [path paths] - (let [split-path (cpn/split-path path :separator ".") - partial-paths (->> split-path - (reduce - (fn [acc segment] - (let [new-acc (if (empty? acc) - segment - (str (last acc) "." segment))] - (conj acc new-acc))) - []))] - (->> paths - (into partial-paths) - distinct - vec))) + (vec (conj paths path))) (defn clear-tokens-paths [] (ptk/reify ::clear-tokens-paths ptk/UpdateEvent (update [_ state] - (assoc-in state [:workspace-tokens :unfolded-token-paths] [])))) + (assoc-in state [:workspace-tokens :folded-token-paths] [])))) (defn toggle-token-path [path] (ptk/reify ::toggle-token-path ptk/UpdateEvent (update [_ state] - (update-in state [:workspace-tokens :unfolded-token-paths] + (update-in state [:workspace-tokens :folded-token-paths] (fn [paths] (let [paths (or paths [])] (if (some #(= % path) paths) - (remove-paths-recursively path paths) + (remove-path path paths) (add-path path paths)))))))) + + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; TOKENS Actions ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -430,6 +527,37 @@ (rx/of (create-token-with-set token))))))) +(defn bulk-create-tokens + [set-id token-ids type node new-node-name] + (assert (uuid? set-id) "expected uuid for `set-id`") + (assert (every? uuid? token-ids) "expected a collection of uuids for `token-ids`") + (assert (keyword? type) "expected keyword for `type`") + (assert (string? new-node-name) "expected string for `new-node-name`") + + (ptk/reify ::bulk-create-tokens + ptk/WatchEvent + (watch [it state _] + (let [token-set (lookup-token-set state set-id) + data (dsh/lookup-file-data state) + changes (reduce (fn [changes token-id] + (let [token (-> (get-tokens-lib state) + (ctob/get-token (ctob/get-id token-set) token-id)) + new-name (-> + (cpn/split-path (:name token) :separator ".") + (assoc (:depth node) new-node-name) + (cpn/join-path :separator "." :with-spaces? false)) + token' (->> (merge token {:name new-name + :id (cthi/new-id! (:name new-name))}) + (into {}) + (ctob/make-token))] + (pcb/set-token changes (ctob/get-id token-set) (:id token') token'))) + (-> (pcb/empty-changes it) + (pcb/with-library-data data)) + token-ids)] + (rx/of + (dch/commit-changes changes) + (ptk/data-event ::ev/event {::ev/name "bulk-create-tokens" :type type})))))) + (defn update-token ([id params] (update-token nil id params)) ([set-id id params] @@ -456,6 +584,34 @@ (rx/of (dch/commit-changes changes) (ptk/data-event ::ev/event {::ev/name "edit-token" :type token-type}))))))) +(defn bulk-update-tokens + [set-id token-ids type old-path new-path] + (dm/assert! (uuid? set-id)) + (dm/assert! (every? uuid? token-ids)) + (ptk/reify ::bulk-update-tokens + ptk/WatchEvent + (watch [it state _] + (let [token-set (if set-id + (lookup-token-set state set-id) + (lookup-token-set state)) + data (dsh/lookup-file-data state) + changes (reduce (fn [changes token-id] + (let [token (-> (get-tokens-lib state) + (ctob/get-token (ctob/get-id token-set) token-id)) + new-name (str/replace (:name token) old-path new-path) + token' (->> (merge token {:name new-name}) + (into {}) + (ctob/make-token))] + (pcb/set-token changes (ctob/get-id token-set) token-id token'))) + (-> (pcb/empty-changes it) + (pcb/with-library-data data)) + + token-ids)] + (toggle-token-path (str (name type) "." old-path)) + (toggle-token-path (str (name type) "." new-path)) + (rx/of (dch/commit-changes changes) + (ptk/data-event ::ev/event {::ev/name "bulk-update-tokens" :type type})))))) + (defn delete-token [set-id token-id] (dm/assert! (uuid? set-id)) @@ -566,7 +722,12 @@ (ptk/reify ::set-selected-token-set-id ptk/UpdateEvent (update [_ state] - (update state :workspace-tokens assoc :selected-token-set-id id)))) + (let [file-id (:current-file-id state) + stored (get-unfolded-token-types-from-storage file-id id)] + (-> state + (update :workspace-tokens assoc :selected-token-set-id id) + (assoc-in [:workspace-tokens :unfolded-token-types] + (make-unfolded-token-types-state file-id id stored))))))) (defn start-token-set-edition [edition-id] diff --git a/frontend/src/app/main/data/workspace/tokens/remapping.cljs b/frontend/src/app/main/data/workspace/tokens/remapping.cljs index fac4eeb40e..0992501f4c 100644 --- a/frontend/src/app/main/data/workspace/tokens/remapping.cljs +++ b/frontend/src/app/main/data/workspace/tokens/remapping.cljs @@ -150,6 +150,18 @@ (rx/of (dch/commit-changes token-changes)))))) +(defn bulk-remap-tokens + "Helper function to remap a batch of tokens, used for node renaming" + [tokens-in-path new-tokens] + (ptk/reify ::bulk-remap-tokens + ptk/WatchEvent + (watch [_ _ _] + (rx/concat + (map (fn [old-token new-token] + (remap-tokens (:name old-token) (:name new-token))) + tokens-in-path + new-tokens))))) + (defn validate-token-remapping "Validate that a token remapping operation is safe to perform" [old-name new-name] diff --git a/frontend/src/app/main/data/workspace/tokens/warnings.cljs b/frontend/src/app/main/data/workspace/tokens/warnings.cljs index f59047e600..6f3d4161aa 100644 --- a/frontend/src/app/main/data/workspace/tokens/warnings.cljs +++ b/frontend/src/app/main/data/workspace/tokens/warnings.cljs @@ -12,11 +12,11 @@ (def warning-codes {:warning.style-dictionary/invalid-referenced-token-value-opacity {:warning/code :warning.style-dictionary/invalid-referenced-token-value-opacity - :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "workspace.tokens.opacity-range")]))} + :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "errors.tokens.opacity-range")]))} :warning.style-dictionary/invalid-referenced-token-value-stroke-width {:warning/code :warning.style-dictionary/invalid-referenced-token-value-stroke-width - :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "workspace.tokens.stroke-width-range")]))} + :warning/fn (fn [value] (str/join "\n" [(str (tr "workspace.tokens.resolved-value" value) ".") (tr "errors.tokens.stroke-width-range")]))} :warning/unknown {:warning/code :warning/unknown diff --git a/frontend/src/app/main/data/workspace/undo.cljs b/frontend/src/app/main/data/workspace/undo.cljs index 2b2c6f048b..2296aed447 100644 --- a/frontend/src/app/main/data/workspace/undo.cljs +++ b/frontend/src/app/main/data/workspace/undo.cljs @@ -60,7 +60,9 @@ [:undo-changes [:vector cpc/schema:change]] [:redo-changes [:vector cpc/schema:change]] [:undo-group ::sm/uuid] - [:tags [:set :keyword]]]) + [:tags [:set :keyword]] + [:selected-before {:optional true} [:maybe [:set ::sm/uuid]]] + [:selected-after {:optional true} [:maybe [:set ::sm/uuid]]]]) (def check-undo-entry (sm/check-fn schema:undo-entry)) @@ -103,24 +105,28 @@ (defn- stack-undo-entry "Extends the current undo entry in the workspace with new changes if it exists, or creates a new entry if it doesn't." - [state {:keys [undo-changes redo-changes] :as entry}] + [state {:keys [undo-changes redo-changes selected-after] :as entry}] (let [index (get-in state [:workspace-undo :index] -1)] (if (>= index 0) (update-in state [:workspace-undo :items index] (fn [item] (-> item (update :undo-changes #(into undo-changes %)) - (update :redo-changes #(into % redo-changes))))) + (update :redo-changes #(into % redo-changes)) + (assoc :selected-after selected-after)))) (add-undo-entry state entry)))) (defn- accumulate-undo-entry "Extends the current undo transaction with new changes." - [state {:keys [undo-changes redo-changes undo-group tags]}] + [state {:keys [undo-changes redo-changes undo-group tags selected-before selected-after]}] (-> state (update-in [:workspace-undo :transaction :undo-changes] #(into undo-changes %)) (update-in [:workspace-undo :transaction :redo-changes] #(into % redo-changes)) (cond-> (nil? (get-in state [:workspace-undo :transaction :undo-group])) (assoc-in [:workspace-undo :transaction :undo-group] undo-group)) + (cond-> (nil? (get-in state [:workspace-undo :transaction :selected-before])) + (assoc-in [:workspace-undo :transaction :selected-before] selected-before)) + (assoc-in [:workspace-undo :transaction :selected-after] selected-after) (assoc-in [:workspace-undo :transaction :tags] tags))) (defn append-undo @@ -137,18 +143,20 @@ (ptk/reify ::append-undo ptk/UpdateEvent (update [_ state] - (cond - (and (get-in state [:workspace-undo :transaction]) - (or (not stack?) - (d/not-empty? (get-in state [:workspace-undo :transaction :undo-changes])) - (d/not-empty? (get-in state [:workspace-undo :transaction :redo-changes])))) - (accumulate-undo-entry state entry) + (let [selected-after (dm/get-in state [:workspace-local :selected]) + entry (assoc entry :selected-after selected-after)] + (cond + (and (get-in state [:workspace-undo :transaction]) + (or (not stack?) + (d/not-empty? (get-in state [:workspace-undo :transaction :undo-changes])) + (d/not-empty? (get-in state [:workspace-undo :transaction :redo-changes])))) + (accumulate-undo-entry state entry) - stack? - (stack-undo-entry state entry) + stack? + (stack-undo-entry state entry) - :else - (add-undo-entry state entry))))) + :else + (add-undo-entry state entry)))))) (def empty-tx {:undo-changes [] :redo-changes []}) @@ -234,6 +242,16 @@ (rx/map first) (rx/map commit-undo-transaction)))))) +(defn- restore-selection + "Restores the selection state from an undo entry." + [selected-ids] + (ptk/reify ::restore-selection + ptk/UpdateEvent + (update [_ state] + (if (some? selected-ids) + (assoc-in state [:workspace-local :selected] selected-ids) + state)))) + (defn undo-to-index "Repeat undoing or redoing until dest-index is reached." [dest-index] @@ -302,12 +320,15 @@ (find-first-group-idx index))] (if undo-group - (rx/of (undo-to-index (dec undo-group-index))) + (let [first-item (get items undo-group-index)] + (rx/of (undo-to-index (dec undo-group-index)) + (restore-selection (:selected-before first-item)))) (rx/of (materialize-undo changes (dec index)) (dch/commit-changes {:redo-changes changes :undo-changes [] :save-undo? false :origin it}) + (restore-selection (:selected-before item)) (assure-valid-current-page))))))))))) (def redo @@ -337,12 +358,15 @@ redo-group-index (when undo-group (find-last-group-idx (inc index)))] (if undo-group - (rx/of (undo-to-index redo-group-index)) + (let [last-item (get items redo-group-index)] + (rx/of (undo-to-index redo-group-index) + (restore-selection (:selected-after last-item)))) (rx/of (materialize-undo changes (inc index)) (dch/commit-changes {:redo-changes changes :undo-changes [] :origin it - :save-undo? false}))))))))))) + :save-undo? false}) + (restore-selection (:selected-after item)))))))))))) (defn- assure-valid-current-page [] diff --git a/frontend/src/app/main/data/workspace/versions.cljs b/frontend/src/app/main/data/workspace/versions.cljs index 85630cfccb..b942add4d8 100644 --- a/frontend/src/app/main/data/workspace/versions.cljs +++ b/frontend/src/app/main/data/workspace/versions.cljs @@ -8,23 +8,28 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.logging :as log] [app.common.schema :as sm] [app.common.time :as ct] [app.main.data.event :as ev] + [app.main.data.helpers :as dsh] [app.main.data.notifications :as ntf] [app.main.data.persistence :as dwp] [app.main.data.workspace :as dw] [app.main.data.workspace.pages :as dwpg] [app.main.data.workspace.thumbnails :as th] + [app.main.features :as features] [app.main.refs :as refs] [app.main.repo :as rp] + [app.util.i18n :refer [tr]] [beicon.v2.core :as rx] [potok.v2.core :as ptk])) (defonce default-state {:status :loading :data nil - :editing nil}) + :editing nil + :preview-id nil}) (declare fetch-versions) @@ -122,32 +127,6 @@ (rx/take 1) (rx/mapcat #(rp/cmd! :restore-file-snapshot {:file-id file-id :id snapshot-id})))) -(defn restore-version - [id origin] - (assert (uuid? id) "expected valid uuid for `id`") - (ptk/reify ::restore-version - ptk/WatchEvent - (watch [_ state _] - (let [file-id (:current-file-id state) - team-id (:current-team-id state) - event-name (case origin - :version "restore-pin-version" - :snapshot "restore-autosave" - :plugin "restore-version-plugin")] - - (rx/concat - (rx/of ::dwp/force-persist - (dw/remove-layout-flag :document-history)) - - (->> (wait-for-persistence file-id id) - (rx/map #(initialize-version))) - - (if event-name - (rx/of (ev/event {::ev/name event-name - :file-id file-id - :team-id team-id})) - (rx/empty))))))) - (defn delete-version [id] (assert (uuid? id) "expected valid uuid for `id`") @@ -193,6 +172,145 @@ (->> (rp/cmd! :unlock-file-snapshot {:id id}) (rx/map fetch-versions))))) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; RESTORE VERSION EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- restore-version + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::restore-version + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state)] + (rx/concat + (rx/of ::dwp/force-persist + (dw/remove-layout-flag :document-history)) + + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version)))))))) + +(defn enter-restore + [id] + (assert (uuid? id) "expected valid uuid for `id`") + (ptk/reify ::enter-restore + ptk/WatchEvent + (watch [_ _ _] + (let [output-s (rx/subject)] + (rx/merge + output-s + (rx/of (ntf/dialog + :content (tr "workspace.versions.restore-warning") + :controls :inline-actions + :cancel {:label (tr "workspace.updates.dismiss") + :callback #(do + (rx/push! output-s (ntf/hide :tag :restore-dialog)) + (rx/end! output-s))} + :accept {:label (tr "labels.restore") + :callback #(do + (rx/push! output-s (restore-version id)) + (rx/end! output-s))} + :tag :restore-dialog))))))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; PREVIEW VERSION EVENTS +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- apply-snapshot + "Swap the file data in app state with the provided snapshot-file + response. Used by the version preview feature to show historical + file content without modifying the database" + [{:keys [id] :as snapshot}] + (ptk/reify ::apply-snapshot-data + ptk/UpdateEvent + (update [_ state] + (update state :files assoc id snapshot)))) + +(defn exit-preview + "Exit from preview mode and reload the live file data" + [] + (ptk/reify ::exit-preview + ptk/UpdateEvent + (update [_ state] + (let [backup (dm/get-in state [:workspace-versions :backup])] + (-> state + (update :workspace-versions dissoc :backup) + (update :workspace-global dissoc :read-only? :preview-id) + (update :files assoc (:id backup) backup)))) + + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state) + page-id (:current-page-id state)] + + (rx/of (dwpg/initialize-page file-id page-id)))))) + +(defn enter-preview + "Load a snapshot into the workspace for read-only preview without + modifying any database state. Sets a read-only flag so no changes + are persisted while previewing and enter on the preview mode" + [id] + (assert (uuid? id) "expected valid uuid for `id`") + + (ptk/reify ::enter-preview + ptk/UpdateEvent + (update [_ state] + (let [file (dsh/lookup-file state)] + (-> state + (update :workspace-versions assoc :backup file) + (update :workspace-global assoc :read-only? true :preview-id id)))) + + ptk/WatchEvent + (watch [_ state _] + (let [file-id (:current-file-id state) + page-id (:current-page-id state) + team-id (:current-team-id state) + features (features/get-enabled-features state team-id) + snapshot (->> (dm/get-in state [:workspace-versions :data]) + (d/seek #(= id (:id %)))) + label (or (:label snapshot) + (tr "workspace.versions.preview.unnamed")) + output-s (rx/subject)] + (rx/merge + output-s + + (rx/of (ntf/dialog + :content (tr "workspace.versions.preview-banner-title" label) + :controls :inline-actions + :cancel {:label (tr "labels.exit") + :callback #(do + (rx/push! output-s (ntf/hide)) + (rx/push! output-s (exit-preview)) + (rx/end! output-s))} + :accept {:label (tr "labels.restore") + :callback #(do + (rx/push! output-s (ntf/hide)) + (rx/push! output-s (restore-version id)) + (rx/end! output-s))} + :tag :preview-dialog)) + + (->> (rp/cmd! :get-file-snapshot + {:file-id file-id + :id id + :features features}) + (rx/mapcat + (fn [snapshot] + (rx/of + ;; Swap the file data in state with snapshot content. + ;; Passing id sets workspace-file-version-id, which + ;; causes the WASM viewport to reload its shape buffer. + (apply-snapshot snapshot) + ;; Re-initialize the page to rebuild its search index + ;; and page-local state with the new snapshot + ;; objects. + (dwpg/initialize-page file-id page-id)))) + + (rx/catch (fn [err] + ;; On error roll back the read-only flag so the + ;; user is not stuck in a broken preview state. + (log/error :hint "failed to load snapshot" :cause err :file-id file-id :snapshot-id id) + (rx/of (exit-preview)))))))))) + ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; ;; PLUGINS SPECIFIC EVENTS ;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; @@ -241,25 +359,28 @@ (rx/empty)))))))) (defn restore-version-from-plugin - [file-id id resolve _reject] + [file-id id resolve reject] (assert (uuid? id) "expected valid uuid for `id`") (ptk/reify ::restore-version-from-plugins ptk/WatchEvent - (watch [_ state _] - (let [team-id (:current-team-id state)] - (rx/concat - (rx/of (ev/event {::ev/name "restore-version-plugin" - :file-id file-id - :team-id team-id}) - ::dwp/force-persist) + (watch [_ _ _] + (->> (rx/concat + (rx/of (ev/event {::ev/name "restore-version" + ::ev/origin "plugins"}) + ::dwp/force-persist) - (->> (wait-for-persistence file-id id) - (rx/map #(initialize-version))) + (->> (wait-for-persistence file-id id) + (rx/map #(initialize-version))) - (->> (rx/of 1) - (rx/tap resolve) - (rx/ignore))))))) + (->> (rx/of 1) + (rx/tap resolve) + (rx/ignore))) + + ;; On error reject the promise and empty the stream + (rx/catch (fn [error] + (reject error) + (rx/empty))))))) diff --git a/frontend/src/app/main/errors.cljs b/frontend/src/app/main/errors.cljs index 85f1334cc9..eaaebedff2 100644 --- a/frontend/src/app/main/errors.cljs +++ b/frontend/src/app/main/errors.cljs @@ -481,7 +481,6 @@ (and (= (.-name ^js cause) "NotFoundError") (str/includes? message "removeChild"))))) - (defn- from-plugin? "Check if the error is marked as originating from plugin code. The plugin runtime tracks plugin errors in a WeakMap, which works even diff --git a/frontend/src/app/main/refs.cljs b/frontend/src/app/main/refs.cljs index c61307cf93..870e659746 100644 --- a/frontend/src/app/main/refs.cljs +++ b/frontend/src/app/main/refs.cljs @@ -303,6 +303,9 @@ (def workspace-page-flows (l/derived #(-> % :flows not-empty) workspace-page)) +(def workspace-page-guides + (l/derived :guides workspace-page)) + (defn workspace-page-object-by-id [page-id shape-id] (l/derived #(dsh/lookup-shape % page-id shape-id) st/state =)) diff --git a/frontend/src/app/main/render.cljs b/frontend/src/app/main/render.cljs index d60366592c..a53971c452 100644 --- a/frontend/src/app/main/render.cljs +++ b/frontend/src/app/main/render.cljs @@ -484,6 +484,46 @@ [:& ff/fontfaces-style {:fonts fonts}] [:& shape-wrapper {:shape object}]]]])) +(mf/defc objects-svg + {::mf/wrap [mf/memo]} + [{:keys [objects object-ids embed] :or {embed false} :as props}] + (let [shapes + (->> object-ids + (keep #(get objects %)) + (mapv (fn [object] + (cond-> object + (:hide-fill-on-export object) + (assoc :fills []))))) + + bounds + (->> shapes + (map #(gsb/get-object-bounds objects % {:ignore-margin? false})) + (grc/join-rects)) + + {:keys [width height]} bounds + vbox (format-viewbox bounds) + fonts (->> shapes + (mapcat #(ff/shape->fonts % objects)) + (distinct)) + + shape-wrapper + (mf/with-memo [objects] + (shape-wrapper-factory objects))] + + [:& (mf/provider export/include-metadata-ctx) {:value false} + [:& (mf/provider embed/context) {:value embed} + [:svg {:view-box vbox + :width (ust/format-precision width viewbox-decimal-precision) + :height (ust/format-precision height viewbox-decimal-precision) + :version "1.1" + :xmlns "http://www.w3.org/2000/svg" + :xmlnsXlink "http://www.w3.org/1999/xlink" + :style {:-webkit-print-color-adjust :exact} + :fill "none"} + [:& ff/fontfaces-style {:fonts fonts}] + (for [shape shapes] + [:& shape-wrapper {:key (dm/str (:id shape)) :shape shape}])]]])) + (defn render-to-canvas [objects canvas bounds scale object-id on-render] (let [width (.-width canvas) diff --git a/frontend/src/app/main/ui.cljs b/frontend/src/app/main/ui.cljs index 67795c2ff3..39ff4493dd 100644 --- a/frontend/src/app/main/ui.cljs +++ b/frontend/src/app/main/ui.cljs @@ -10,6 +10,7 @@ [app.common.uuid :as uuid] [app.config :as cf] [app.main.data.common :as dcm] + [app.main.data.nitrate :as dnt] [app.main.data.team :as dtm] [app.main.errors :as errors] [app.main.refs :as refs] @@ -23,6 +24,7 @@ [app.main.ui.error-boundary :refer [error-boundary*]] [app.main.ui.exports.files] [app.main.ui.frame-preview :as frame-preview] + [app.main.ui.nitrate.entry :as nitrate-entry] [app.main.ui.notifications :as notifications] [app.main.ui.onboarding.questions :refer [questions-modal]] [app.main.ui.onboarding.team-choice :refer [onboarding-team-modal]] @@ -152,21 +154,25 @@ props (get profile :props) section (get data :name) team (mf/deref refs/team) + nitrate-entry-active? (dnt/nitrate-entry-active?) show-question-modal? (and (contains? cf/flags :onboarding) + (not nitrate-entry-active?) (not (:onboarding-viewed props)) (not (contains? props :onboarding-questions))) show-team-modal? (and (contains? cf/flags :onboarding) + (not nitrate-entry-active?) (not (:onboarding-viewed props)) (not (contains? props :onboarding-team-id)) (:is-default team)) show-release-modal? (and (contains? cf/flags :onboarding) + (not nitrate-entry-active?) (not (contains? cf/flags :hide-release-modal)) (:onboarding-viewed props) (not= (:release-notes-viewed props) (:main cf/version)) @@ -185,6 +191,9 @@ :auth-verify-token [:? [:& verify-token-page* {:route route}]] + :nitrate-entry + [:> nitrate-entry/nitrate-entry-page* {:profile profile}] + (:settings-profile :settings-password :settings-options diff --git a/frontend/src/app/main/ui/alert.scss b/frontend/src/app/main/ui/alert.scss index f50ee50d41..b3d0144fc1 100644 --- a/frontend/src/app/main/ui/alert.scss +++ b/frontend/src/app/main/ui/alert.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; &.transparent { background-color: transparent; @@ -15,7 +15,7 @@ } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; } .modal-header { @@ -23,39 +23,42 @@ } .modal-title { - @include deprecated.headlineMediumTypography; + @include deprecated.headline-medium-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + margin-bottom: deprecated.$s-24; } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } .modal-scd-msg, .modal-subtitle, .modal-msg { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); line-height: 1.5; } diff --git a/frontend/src/app/main/ui/auth.scss b/frontend/src/app/main/ui/auth.scss index 62ba1a1830..40f2076fec 100644 --- a/frontend/src/app/main/ui/auth.scss +++ b/frontend/src/app/main/ui/auth.scss @@ -18,7 +18,7 @@ width: 100%; overflow: auto; - @media (max-width: 992px) { + @media (width <= 992px) { display: flex; justify-content: center; } @@ -53,7 +53,7 @@ height: auto; justify-self: center; - @media (max-width: 992px) { + @media (width <= 992px) { display: none; } } diff --git a/frontend/src/app/main/ui/auth/common.scss b/frontend/src/app/main/ui/auth/common.scss index eedfa34da1..dc438a1d97 100644 --- a/frontend/src/app/main/ui/auth/common.scss +++ b/frontend/src/app/main/ui/auth/common.scss @@ -11,6 +11,7 @@ padding-block-end: 0; display: grid; gap: deprecated.$s-12; + form { display: flex; flex-direction: column; @@ -32,17 +33,20 @@ } .auth-title { - @include deprecated.bigTitleTipography; + @include deprecated.big-title-typography; + color: var(--title-foreground-color-hover); } .auth-subtitle { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + color: var(--title-foreground-color); } .auth-tagline { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + margin: 0; color: var(--title-foreground-color); } @@ -60,8 +64,9 @@ .login-button, .login-ldap-button { - @extend .button-primary; - @include deprecated.uppercaseTitleTipography; + @extend %button-primary; + @include deprecated.uppercase-title-typography; + height: deprecated.$s-40; width: 100%; } @@ -75,8 +80,9 @@ } .go-back-link { - @extend .button-secondary; - @include deprecated.uppercaseTitleTipography; + @extend %button-secondary; + @include deprecated.uppercase-title-typography; + height: deprecated.$s-40; } @@ -99,7 +105,8 @@ .account-text, .recovery-text, .demo-account-text { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + text-align: right; color: var(--title-foreground-color); } @@ -109,7 +116,8 @@ .recovery-link, .forgot-pass-link, .demo-account-link { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + text-align: left; background-color: transparent; border: none; @@ -129,14 +137,16 @@ .submit-btn, .register-btn, .recover-btn { - @extend .button-primary; - @include deprecated.uppercaseTitleTipography; + @extend %button-primary; + @include deprecated.uppercase-title-typography; + height: deprecated.$s-40; width: 100%; } .login-btn { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + display: flex; align-items: center; gap: deprecated.$s-6; @@ -144,6 +154,7 @@ border-radius: deprecated.$br-8; background-color: var(--button-secondary-background-color-rest); color: var(--button-foreground-color-focus); + span { padding-block-start: deprecated.$s-2; } diff --git a/frontend/src/app/main/ui/auth/login.cljs b/frontend/src/app/main/ui/auth/login.cljs index 51c59f0c71..4382f95327 100644 --- a/frontend/src/app/main/ui/auth/login.cljs +++ b/frontend/src/app/main/ui/auth/login.cljs @@ -44,12 +44,12 @@ (st/emit! (da/create-demo-profile))) (defn- store-login-redirect - [] + [callback-url] (binding [s/*sync* true] ;; Save the current login raw uri for later redirect user back to ;; the same page, we need it to be synchronous because the user is ;; going to be redirected instantly to the oidc provider uri - (swap! s/session assoc :login-redirect (rt/get-current-href)))) + (swap! s/session assoc :login-redirect (or callback-url (rt/get-current-href))))) (defn- clear-login-redirect [] @@ -74,6 +74,7 @@ error (mf/use-state false) form (fm/use-form :schema schema:login-form :initial initial) + callback-url (:callback-url params) on-error (fn [cause] (let [cause (ex-data cause)] @@ -156,9 +157,9 @@ #(st/emit! (rt/nav :auth-recovery-request)))] - (mf/with-effect [handle-redirect] - (if handle-redirect - (store-login-redirect) + (mf/with-effect [handle-redirect callback-url] + (if (or handle-redirect callback-url) + (store-login-redirect callback-url) (clear-login-redirect))) [:* @@ -238,7 +239,7 @@ (when (contains? cf/flags :login-with-oidc) [:& bl/button-link {:on-click login-with-oidc :icon deprecated-icon/brand-openid - :label (tr "auth.login-with-oidc-submit") + :label (or (not-empty cf/oidc-name) (tr "auth.login-with-oidc-submit")) :class (stl/css :login-btn :btn-oidc-auth)}])])) (mf/defc login-dialog* diff --git a/frontend/src/app/main/ui/auth/login.scss b/frontend/src/app/main/ui/auth/login.scss index b0002114f9..4f4aa3dd9d 100644 --- a/frontend/src/app/main/ui/auth/login.scss +++ b/frontend/src/app/main/ui/auth/login.scss @@ -4,4 +4,4 @@ // // Copyright (c) KALEIDOS INC -@use "./common.scss"; +@use "./common"; diff --git a/frontend/src/app/main/ui/auth/recovery.scss b/frontend/src/app/main/ui/auth/recovery.scss index a89055b061..6da351a238 100644 --- a/frontend/src/app/main/ui/auth/recovery.scss +++ b/frontend/src/app/main/ui/auth/recovery.scss @@ -5,7 +5,7 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; -@use "./common.scss"; +@use "./common"; .submit-btn { margin-top: deprecated.$s-16; diff --git a/frontend/src/app/main/ui/auth/recovery_request.scss b/frontend/src/app/main/ui/auth/recovery_request.scss index c774a575a3..b4c053b104 100644 --- a/frontend/src/app/main/ui/auth/recovery_request.scss +++ b/frontend/src/app/main/ui/auth/recovery_request.scss @@ -5,14 +5,15 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; -@use "./common.scss"; +@use "./common"; .fields-row { margin-bottom: deprecated.$s-8; } .notification-text-email { - @include deprecated.medTitleTipography; + @include deprecated.med-title-typography; + font-size: deprecated.$fs-20; color: var(--register-confirmation-color); margin-inline: deprecated.$s-36; diff --git a/frontend/src/app/main/ui/auth/register.cljs b/frontend/src/app/main/ui/auth/register.cljs index 917b272dd9..60fd3e0167 100644 --- a/frontend/src/app/main/ui/auth/register.cljs +++ b/frontend/src/app/main/ui/auth/register.cljs @@ -221,6 +221,7 @@ :class (stl/css :demo-account-link)} (tr "auth.create-demo-account")]]])]]) + ;; --- PAGE: register success page (mf/defc register-success-page* diff --git a/frontend/src/app/main/ui/auth/register.scss b/frontend/src/app/main/ui/auth/register.scss index 182dfddbaa..47445a3633 100644 --- a/frontend/src/app/main/ui/auth/register.scss +++ b/frontend/src/app/main/ui/auth/register.scss @@ -5,7 +5,7 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; -@use "./common.scss"; +@use "./common"; .accept-terms-and-privacy-wrapper { :global(a) { @@ -25,8 +25,9 @@ .register-success { gap: deprecated.$s-24; + .auth-title { - @include deprecated.medTitleTipography; + @include deprecated.med-title-typography; } } @@ -35,6 +36,7 @@ display: flex; justify-content: center; margin-bottom: deprecated.$s-32; + svg { width: deprecated.$s-92; height: deprecated.$s-92; @@ -42,12 +44,14 @@ } .notification-text { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--title-foreground-color); } .notification-text-email { - @include deprecated.medTitleTipography; + @include deprecated.med-title-typography; + font-size: deprecated.$fs-20; color: var(--register-confirmation-color); margin-inline: deprecated.$s-36; @@ -55,6 +59,7 @@ .logo-btn { height: deprecated.$s-40; + svg { width: deprecated.$s-120; height: deprecated.$s-40; @@ -70,7 +75,8 @@ } .terms-register { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; gap: deprecated.$s-4; justify-content: center; @@ -84,6 +90,7 @@ .auth-link { color: var(--link-foreground-color); + &:hover { text-decoration: underline; } diff --git a/frontend/src/app/main/ui/auth/verify_token.cljs b/frontend/src/app/main/ui/auth/verify_token.cljs index 16e818e4b2..a93954ace3 100644 --- a/frontend/src/app/main/ui/auth/verify_token.cljs +++ b/frontend/src/app/main/ui/auth/verify_token.cljs @@ -43,19 +43,22 @@ (st/emit! (da/login-from-token tdata))) (defmethod handle-token :team-invitation - [tdata] - (case (:state tdata) + [{:keys [state team-id org-team-id organization-name invitation-token] :as tdata}] + (case state :created - (let [team-id (:team-id tdata)] + (if org-team-id (st/emit! - (ntf/success (tr "auth.notifications.team-invitation-accepted")) (du/refresh-profile) - (dcm/go-to-dashboard-recent :team-id team-id))) + (dcm/go-to-dashboard-recent :team-id org-team-id) + (ntf/success (tr "auth.notifications.org-invitation-accepted" organization-name))) + (st/emit! + (du/refresh-profile) + (dcm/go-to-dashboard-recent :team-id team-id) + (ntf/success (tr "auth.notifications.team-invitation-accepted")))) :pending - (let [token (:invitation-token tdata) - route-id (:redirect-to tdata :auth-register)] - (st/emit! (rt/nav route-id {:invitation-token token}))))) + (let [route-id (:redirect-to tdata :auth-register)] + (st/emit! (rt/nav route-id {:invitation-token invitation-token}))))) (defmethod handle-token :default [_tdata] @@ -65,8 +68,15 @@ (mf/defc verify-token* [{:keys [route]}] - (let [token (get-in route [:query-params :token]) - bad-token (mf/use-state false)] + (let [token (get-in route [:query-params :token]) + ;; Holds the specific failure reason when the token fails, or + ;; nil while still loading / on success. Any non-nil keyword is + ;; truthy, so this single state replaces the previous pair of + ;; (bad-token? + bad-token-reason) hooks. Reasons: + ;; :token-expired -> JWT past its :exp + ;; :email-mismatch -> invitation email != logged-in email + ;; :invalid-token -> corrupted / unknown / fallback + bad-token-reason (mf/use-state nil)] (mf/with-effect [] (dom/set-html-title (tr "title.default")) @@ -75,12 +85,25 @@ (fn [tdata] (handle-token tdata)) (fn [cause] - (let [{:keys [type code] :as error} (ex-data cause)] + (let [{:keys [type code team-id reason] :as error} (ex-data cause)] (cond + (= :invalid-token-already-member code) + (st/emit! + (rt/nav :dashboard-recent {:team-id team-id})) + + (= :org-not-found code) + (st/emit! + (rt/nav :dashboard-recent {:team-id team-id}) + (ntf/error (tr "errors.org-not-found"))) + (or (= :validation type) (= :invalid-token code) - (= :token-expired (:reason error))) - (reset! bad-token true) + (= :token-expired reason)) + (reset! bad-token-reason + (cond + (= :token-expired reason) :token-expired + (= :email-mismatch reason) :email-mismatch + :else :invalid-token)) (= :email-already-exists code) (let [msg (tr "errors.email-already-exists")] @@ -97,8 +120,8 @@ (ts/schedule 100 #(st/emit! (ntf/error msg))) (st/emit! (rt/nav :auth-login))))))))) - (if @bad-token - [:> static/invalid-token {}] + (if @bad-token-reason + [:> static/invalid-token {:reason @bad-token-reason}] [:> loader* {:title (tr "labels.loading") :overlay true}]))) diff --git a/frontend/src/app/main/ui/comments.cljs b/frontend/src/app/main/ui/comments.cljs index cc12398c62..3093a11287 100644 --- a/frontend/src/app/main/ui/comments.cljs +++ b/frontend/src/app/main/ui/comments.cljs @@ -45,20 +45,34 @@ (def mentions-context (mf/create-context nil)) (def r-mentions-split #"@\[[^\]]*\]\([^\)]*\)") (def r-mentions #"@\[([^\]]*)\]\(([^\)]*)\)") +(def r-url-split #"https?://[^\s\)\]]+[^\s\)\]\.,;:!?]") (def zero-width-space \u200B) -(defn- parse-comment - "Parse a comment into its elements (texts and mentions)" - [comment] - (d/interleave-all - (->> (str/split comment r-mentions-split) - (map #(hash-map :type :text :content %))) +(defn- parse-urls + "Split a text element into text and url sub-elements" + [element] + (if (= (:type element) :text) + (let [text (:content element) + parts (str/split text r-url-split) + urls (re-seq r-url-split text)] + (d/interleave-all + (map #(hash-map :type :text :content %) parts) + (map #(hash-map :type :url :content %) urls))) + [element])) - (->> (re-seq r-mentions comment) - (map (fn [[_ user id]] - {:type :mention - :content user - :data {:id id}}))))) +(defn- parse-comment + "Parse a comment into its elements (texts, mentions and urls)" + [comment] + (->> (d/interleave-all + (->> (str/split comment r-mentions-split) + (map #(hash-map :type :text :content %))) + + (->> (re-seq r-mentions comment) + (map (fn [[_ user id]] + {:type :mention + :content user + :data {:id id}})))) + (mapcat parse-urls))) (defn- parse-nodes "Parse the nodes to format a comment" @@ -146,7 +160,13 @@ [{:keys [content]}] (let [comment-elements (mf/use-memo (mf/deps content) #(parse-comment content))] (for [[idx {:keys [type content]}] (d/enumerate comment-elements)] - (case type + (if (= type :url) + [:a {:key idx + :href content + :target "_blank" + :rel "noopener noreferrer" + :class (stl/css :comment-link)} + content] [:span {:key idx :class (stl/css-case @@ -177,6 +197,7 @@ (doseq [{:keys [type content data]} (parse-comment value)] (case type :text (dom/append-child! node (create-text-node content)) + :url (dom/append-child! node (create-text-node content)) :mention (dom/append-child! node (create-mention-node (:id data) content)) nil))))) diff --git a/frontend/src/app/main/ui/comments.scss b/frontend/src/app/main/ui/comments.scss index 9da4eef616..9da2078d38 100644 --- a/frontend/src/app/main/ui/comments.scss +++ b/frontend/src/app/main/ui/comments.scss @@ -23,7 +23,8 @@ } .error-text { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--color-foreground-error); } @@ -39,11 +40,12 @@ } .location-text { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; } .author { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; gap: deprecated.$s-8; @@ -54,12 +56,14 @@ } .author-fullname { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + color: var(--comment-title-color); } .author-timeago { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + color: var(--comment-subtitle-color); } @@ -112,11 +116,12 @@ } .avatar-darken { - background: rgba(0, 0, 0, 0.5); + background: rgb(0 0 0 / 0.5); } .cover { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + cursor: pointer; display: flex; flex-direction: column; @@ -126,16 +131,17 @@ } .item { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--color-foreground-primary); - word-wrap: break-word; overflow-wrap: break-word; hyphens: auto; white-space: pre-wrap; } .replies { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; gap: deprecated.$s-8; } @@ -143,6 +149,7 @@ .replies-total { color: var(--color-foreground-secondary); } + .replies-unread { color: var(--color-accent-primary); } @@ -168,15 +175,18 @@ --translate-x: 0%; --translate-y: 0%; + transform: translate(var(--translate-x), var(--translate-y)); &.left { --translate-x: -100%; + flex-direction: row-reverse; } &.top { --translate-y: -100%; + align-items: flex-end; } } @@ -214,10 +224,13 @@ --translate-x: 0%; --translate-y: 0%; + transform: translate(var(--translate-x), var(--translate-y)); + &.left { --translate-x: -100%; } + &.top { --translate-y: -100%; } @@ -232,7 +245,8 @@ } .floating-thread-header-left { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--color-foreground-primary); } @@ -257,22 +271,25 @@ display: flex; flex-direction: column; gap: deprecated.$s-8; - @include deprecated.bodySmallTypography; + + @include deprecated.body-small-typography; } .checkbox-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-16; height: deprecated.$s-24; margin-right: deprecated.$s-8; } .checkbox { - @extend .checkbox-icon; + @extend %checkbox-icon; } .dropdown-menu { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + position: absolute; width: fit-content; max-width: deprecated.$s-200; @@ -282,7 +299,7 @@ } .dropdown-menu-option { - @extend .dropdown-element-base; + @extend %dropdown-element-base; } .form { @@ -364,8 +381,8 @@ } .comment-input { - @include deprecated.bodySmallTypography; - white-space: pre-line; + @include deprecated.body-small-typography; + background: var(--input-background-color); border-radius: deprecated.$br-8; border: deprecated.$s-1 solid var(--input-border-color); @@ -401,6 +418,12 @@ color: var(--color-accent-primary); } +.comment-link { + color: var(--color-accent-primary); + text-decoration: underline; + cursor: pointer; +} + .comments-mentions-empty { font-size: deprecated.$fs-12; color: var(--color-foreground-secondary); diff --git a/frontend/src/app/main/ui/components/button_link.scss b/frontend/src/app/main/ui/components/button_link.scss index bb58dcc4a5..b3693cbdb0 100644 --- a/frontend/src/app/main/ui/components/button_link.scss +++ b/frontend/src/app/main/ui/components/button_link.scss @@ -18,7 +18,6 @@ padding: 0 1rem; transition: all 0.4s; text-decoration: none !important; - height: 40px; svg { diff --git a/frontend/src/app/main/ui/components/code_block.scss b/frontend/src/app/main/ui/components/code_block.scss index 69b4658f0f..dd8d79680e 100644 --- a/frontend/src/app/main/ui/components/code_block.scss +++ b/frontend/src/app/main/ui/components/code_block.scss @@ -9,6 +9,7 @@ .code-display { @include t.use-typography("code-font"); + user-select: text; border-radius: $br-8; margin-top: var(--sp-s); diff --git a/frontend/src/app/main/ui/components/color_bullet.cljs b/frontend/src/app/main/ui/components/color_bullet.cljs index d94938d147..55d9e4c57c 100644 --- a/frontend/src/app/main/ui/components/color_bullet.cljs +++ b/frontend/src/app/main/ui/components/color_bullet.cljs @@ -7,24 +7,33 @@ (ns app.main.ui.components.color-bullet (:require-macros [app.main.style :as stl]) (:require + [app.common.math :as mth] [app.config :as cfg] [app.util.color :as uc] [app.util.i18n :refer [tr]] [cuerdas.core :as str] [rumext.v2 :as mf])) +(defn- format-color-with-alpha + [color opacity] + (if (and (number? opacity) (< opacity 1)) + (str color " " (mth/round (* opacity 100)) "%") + color)) + (defn- color-title [color-item] (let [{:keys [name path]} (meta color-item) path-and-name (if path (str path " / " name) name) gradient (:gradient color-item) image (:image color-item) - color (:color color-item)] + opacity (:opacity color-item) + color (:color color-item) + color-str (when color (format-color-with-alpha color opacity))] (if (some? name) (cond (some? color) - (str/ffmt "% (%)" path-and-name color) + (str/ffmt "% (%)" path-and-name color-str) (some? gradient) (str/ffmt "% (%)" path-and-name (uc/gradient-type->string (:type gradient))) @@ -37,7 +46,7 @@ (cond (some? color) - color + color-str (some? gradient) (uc/gradient-type->string (:type gradient)) diff --git a/frontend/src/app/main/ui/components/color_bullet.scss b/frontend/src/app/main/ui/components/color_bullet.scss index 52fc242fac..7972780483 100644 --- a/frontend/src/app/main/ui/components/color_bullet.scss +++ b/frontend/src/app/main/ui/components/color_bullet.scss @@ -16,9 +16,11 @@ min-height: var(--bullet-size, deprecated.$s-24); border: deprecated.$s-2 solid var(--color-bullet-border-color); border-radius: deprecated.$br-circle; + &.grid-area { grid-area: color; } + &.mini { width: var(--bullet-size, deprecated.$s-16); height: var(--bullet-size, deprecated.$s-16); @@ -31,24 +33,29 @@ &.is-not-library-color { overflow: hidden; border-radius: deprecated.$br-8; + & .color-bullet-wrapper { clip-path: none; } + &.mini { border-radius: deprecated.$br-4; } } + &.is-gradient { background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAExJREFUSIljvHnz5n8GLEBNTQ2bMMOtW7ewiuNSz4RVlIpg1IKBt4Dx////WFMRqakFl/qhH0SjFhAELNRKLaNl0Qi2YLQsGrWAcgAA0gAgQPhT2rAAAAAASUVORK5CYII=") left center; background-color: var(--color-bullet-background-color); transform: rotate(-90deg); } + &.is-transparent { background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABgAAAAYCAYAAADgdz34AAAAAXNSR0IArs4c6QAAAExJREFUSIljvHnz5n8GLEBNTQ2bMMOtW7ewiuNSz4RVlIpg1IKBt4Dx////WFMRqakFl/qhH0SjFhAELNRKLaNl0Qi2YLQsGrWAcgAA0gAgQPhT2rAAAAAASUVORK5CYII=") left center; background-color: var(--color-bullet-background-color); } + .color-bullet-wrapper { display: flex; flex-direction: row; @@ -59,33 +66,39 @@ background-repeat: no-repeat; background-position: center; } + .color-bullet-wrapper > * { width: 100%; height: 100%; background-color: var(--color-bullet-background-color); } + &:hover:not(.read-only) { border: deprecated.$s-2 solid var(--color-bullet-border-color-selected); } } .color-text { - @include deprecated.twoLineTextEllipsis; - @include deprecated.bodySmallTypography; + @include deprecated.two-line-text-ellipsis; + @include deprecated.body-small-typography; + width: deprecated.$s-80; text-align: center; margin-top: deprecated.$s-2; max-height: deprecated.$s-28; color: var(--palette-text-color); + &.small-text { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + max-height: deprecated.$s-16; } } .big-text { - @include deprecated.inspectValue; - @include deprecated.twoLineTextEllipsis; + @include deprecated.inspect-value; + @include deprecated.two-line-text-ellipsis; + line-height: 1; color: var(--palette-text-color); text-align: center; diff --git a/frontend/src/app/main/ui/components/context_menu_a11y.scss b/frontend/src/app/main/ui/components/context_menu_a11y.scss index 787941b595..5297f75422 100644 --- a/frontend/src/app/main/ui/components/context_menu_a11y.scss +++ b/frontend/src/app/main/ui/components/context_menu_a11y.scss @@ -25,7 +25,8 @@ } .context-menu-items { - @include deprecated.menuShadow; + @include deprecated.menu-shadow; + position: absolute; top: deprecated.$s-12; left: calc(-1 * deprecated.$s-6); @@ -50,7 +51,8 @@ display: flex; .context-menu-action { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; justify-content: flex-start; @@ -70,7 +72,8 @@ margin-left: 0.5rem; svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--menu-foreground-color); } } @@ -85,7 +88,8 @@ cursor: pointer; .submenu-icon-back svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--menu-foreground-color); transform: rotate(180deg); } @@ -141,12 +145,14 @@ } .selected-icon { - @extend .button-tag; + @extend %button-tag; + border-radius: deprecated.$br-8; height: 100%; svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--menu-foreground-color-focus); } } @@ -155,7 +161,7 @@ .is-selected .context-menu-action { padding-left: deprecated.$s-28; - background-image: url(/images/icons/tick.svg); + background-image: url("/images/icons/tick.svg"); background-repeat: no-repeat; background-position: 5% 48%; background-size: deprecated.$s-12; diff --git a/frontend/src/app/main/ui/components/copy_button.scss b/frontend/src/app/main/ui/components/copy_button.scss index 0239900938..63f2f5069b 100644 --- a/frontend/src/app/main/ui/components/copy_button.scss +++ b/frontend/src/app/main/ui/components/copy_button.scss @@ -7,20 +7,25 @@ @use "refactor/common-refactor.scss" as deprecated; .copy-button { - @include deprecated.buttonStyle; + @include deprecated.button-style; + width: 100%; height: deprecated.$s-32; border: deprecated.$s-1 solid transparent; border-radius: deprecated.$br-8; background-color: transparent; box-sizing: border-box; + .icon-btn { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-32; min-width: deprecated.$s-28; width: deprecated.$s-28; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } @@ -29,18 +34,21 @@ background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); border: deprecated.$s-1 solid var(--color-background-tertiary); + .icon-btn { svg { stroke: var(--button-tertiary-foreground-color-active); } } } + &:focus, &:focus-visible { outline: none; border: deprecated.$s-1 solid var(--button-tertiary-border-color-focus); background-color: transparent; color: var(--button-tertiary-foreground-color-focus); + .icon-btn svg { stroke: var(--button-tertiary-foreground-color-active); } @@ -48,29 +56,36 @@ } .copy-wrapper { - @include deprecated.buttonStyle; - @include deprecated.copyWrapperBase; + @include deprecated.button-style; + @include deprecated.copy-wrapper-base; + width: 100%; height: fit-content; text-align: left; border: deprecated.$s-1 solid transparent; + .icon-btn { - @include deprecated.flexCenter; + @include deprecated.flex-center; + position: absolute; top: 0; right: 0; height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--button-tertiary-foreground-color-focus); display: none; } } + &:hover { background-color: var(--button-tertiary-background-color-focus); color: var(--button-tertiary-foreground-color-focus); border: deprecated.$s-1 solid var(--button-tertiary-background-color-focus); + .icon-btn svg { display: flex; } diff --git a/frontend/src/app/main/ui/components/editable_label.scss b/frontend/src/app/main/ui/components/editable_label.scss index 29a57d551d..46ff586f95 100644 --- a/frontend/src/app/main/ui/components/editable_label.scss +++ b/frontend/src/app/main/ui/components/editable_label.scss @@ -11,6 +11,7 @@ .editable-label-input { @include t.use-typography("body-small"); + outline: none; width: 100%; height: 100%; @@ -23,6 +24,7 @@ .editable-label-text { @include t.use-typography("body-small"); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; diff --git a/frontend/src/app/main/ui/components/editable_select.scss b/frontend/src/app/main/ui/components/editable_select.scss index ef874df8c0..07a1272095 100644 --- a/frontend/src/app/main/ui/components/editable_select.scss +++ b/frontend/src/app/main/ui/components/editable_select.scss @@ -4,18 +4,17 @@ // // Copyright (c) KALEIDOS INC -// FIXME: we need this import for .asset-element +// FIXME: we need this import for %asset-element @use "refactor/basic-rules.scss" as deprecated; - @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @use "ds/spacing.scss" as *; .editable-select { - @extend .asset-element; + @extend %asset-element; + margin: 0; - padding: 0; border: $b-1 solid var(--input-border-color); position: relative; display: flex; @@ -24,27 +23,34 @@ padding: var(--sp-s); border-radius: $br-8; cursor: pointer; + .dropdown-button { display: flex; place-content: center; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + transform: rotate(90deg); stroke: var(--icon-foreground); } } .custom-select-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + width: fit-content; max-height: px2rem(320); // TODO: when this gets addressed in the DS, use a token .separator { margin: 0; height: $sz-12; } + .dropdown-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + color: var(--menu-foreground-color-rest); + .label { flex-grow: 1; width: 100%; @@ -53,8 +59,10 @@ .check-icon { display: flex; place-content: center; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + visibility: hidden; stroke: var(--icon-foreground); } @@ -62,14 +70,17 @@ &.is-selected { color: var(--menu-foreground-color); + .check-icon svg { stroke: var(--menu-foreground-color); visibility: visible; } } + &:hover { background-color: var(--menu-background-color-hover); color: var(--menu-foreground-color-hover); + .check-icon svg { stroke: var(--menu-foreground-color-hover); } diff --git a/frontend/src/app/main/ui/components/forms.cljs b/frontend/src/app/main/ui/components/forms.cljs index e2bb4cd5cf..17a1708196 100644 --- a/frontend/src/app/main/ui/components/forms.cljs +++ b/frontend/src/app/main/ui/components/forms.cljs @@ -92,6 +92,15 @@ (when-not (get-in @form [:touched input-name]) (swap! form assoc-in [:touched input-name] true))) + on-clear + (fn [event] + (dom/prevent-default event) + (swap! form (fn [state] + (-> state + (assoc-in [:data input-name] "") + (assoc-in [:touched input-name] false)))) + (some-> (mf/ref-val input-ref) (dom/focus!))) + on-key-press (mf/use-fn (mf/deps input-ref) @@ -158,7 +167,10 @@ deprecated-icon/tick]) (when show-invalid? - [:span {:class (stl/css :invalid-icon)} + [:button {:class (stl/css :invalid-icon) + :type "button" + :tab-index "-1" + :on-click on-clear} deprecated-icon/close])])] (some? children) diff --git a/frontend/src/app/main/ui/components/forms.scss b/frontend/src/app/main/ui/components/forms.scss index 6139098e5f..a8705671c6 100644 --- a/frontend/src/app/main/ui/components/forms.scss +++ b/frontend/src/app/main/ui/components/forms.scss @@ -5,64 +5,82 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; +@use "ds/z-index.scss" as *; +@use "ds/mixins.scss" as *; // INPUT .input-wrapper { --input-icon-padding: var(--sp-l); + display: flex; flex-direction: column; align-items: center; position: relative; + &.valid { input { - border: deprecated.$s-1 solid var(--input-border-color-success); - @extend .disabled-input; + @extend %disabled-input; + + border: $b-1 solid var(--input-border-color-success); + &:hover, &:focus { - border: deprecated.$s-1 solid var(--input-border-color-success); + border: $b-1 solid var(--input-border-color-success); } } } + &.invalid { input { - border: deprecated.$s-1 solid var(--input-border-color-error); - @extend .disabled-input; + @extend %disabled-input; + + border: $b-1 solid var(--input-border-color-error); + &:hover, &:focus { - border: deprecated.$s-1 solid var(--input-border-color-error); + border: $b-1 solid var(--input-border-color-error); } } } + &.valid .help-icon, &.invalid .help-icon { - right: deprecated.$s-40; + inset-inline-end: $sz-40; } } .input-with-label-form { - @include deprecated.flexColumn; - gap: deprecated.$s-8; + display: flex; + flex-direction: column; + gap: var(--sp-s); justify-content: flex-start; align-items: flex-start; - height: 100%; - width: 100%; + block-size: 100%; + inline-size: 100%; padding: 0; cursor: pointer; color: var(--modal-title-foreground-color); text-transform: uppercase; + input { - @extend .input-element; + @extend %input-element; + color: var(--input-foreground-color-active); - margin-top: 0; - width: 100%; - max-width: 100%; - height: 100%; - padding: 0 deprecated.$s-8; + margin-block-start: 0; + inline-size: 100%; + max-inline-size: 100%; + block-size: 100%; + padding: 0 var(--sp-s); &:focus { outline: none; - border: deprecated.$s-1 solid var(--input-border-color-focus); - border-radius: deprecated.$br-8; + border: $b-1 solid var(--input-border-color-focus); + border-radius: var(--sp-s); } } @@ -72,9 +90,9 @@ input:-webkit-autofill:focus, input:-webkit-autofill:active { -webkit-text-fill-color: var(--input-foreground-color-active); - -webkit-box-shadow: inset 0 0 20px 20px var(--input-background-color); - border: deprecated.$s-1 solid var(--input-border-color); - -webkit-background-clip: text; + box-shadow: inset 0 0 20px 20px var(--input-background-color); + border: $b-1 solid var(--input-border-color); + background-clip: text; transition: background-color 5000s ease-in-out 0s; caret-color: var(--input-foreground-color-active); } @@ -82,56 +100,63 @@ .input-and-icon { position: relative; - width: var(--input-width, calc(100% - deprecated.$s-1)); - min-width: var(--input-min-width); - height: var(--input-height, deprecated.$s-32); + inline-size: var(--input-width, calc(100% - deprecated.$s-1)); + min-inline-size: var(--input-min-width); + block-size: var(--input-height, $sz-32); } .help-icon { cursor: pointer; position: absolute; - right: deprecated.$s-16; - top: calc(50% - deprecated.$s-8); + inset-inline-end: var(--sp-l); + inset-block-start: calc(50% - var(--sp-s)); + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--color-foreground-secondary); - width: deprecated.$s-16; - height: deprecated.$s-16; + inline-size: $sz-16; + block-size: $sz-16; } } .invalid-icon { - width: deprecated.$s-16; - height: deprecated.$s-16; + inline-size: $sz-16; + block-size: $sz-16; + padding: 0; + border: none; background: var(--input-border-color-error); - border-radius: 50%; + border-radius: $br-circle; display: flex; align-items: center; justify-content: center; position: absolute; - right: var(--input-icon-padding); - top: calc(50% - deprecated.$s-8); + inset-inline-end: var(--input-icon-padding); + inset-block-start: calc(50% - var(--sp-s)); + cursor: pointer; + svg { - width: deprecated.$s-12; - height: deprecated.$s-12; + inline-size: $sz-12; + block-size: $sz-12; stroke: var(--input-background-color); } } .valid-icon { - width: deprecated.$s-16; - height: deprecated.$s-16; + inline-size: $sz-16; + block-size: $sz-16; background: var(--input-border-color-success); - border-radius: 50%; + border-radius: $br-circle; display: flex; align-items: center; justify-content: center; position: absolute; - right: deprecated.$s-16; - top: calc(50% - deprecated.$s-8); + inset-inline-end: var(--sp-l); + inset-block-start: calc(50% - var(--sp-s)); + svg { - width: deprecated.$s-12; - height: deprecated.$s-12; + inline-size: $sz-12; + block-size: $sz-12; fill: var(--input-border-color-success); stroke: var(--input-background-color); } @@ -139,38 +164,45 @@ .error { color: var(--input-border-color-error); - width: 100%; + inline-size: 100%; font-size: deprecated.$fs-14; } .hint { - @include deprecated.bodySmallTypography; - width: 99%; - margin-block-start: deprecated.$s-8; + @include t.use-typography("body-small"); + + inline-size: 99%; + margin-block-start: var(--sp-s); color: var(--modal-text-foreground-color); } .checkbox { - @extend .input-checkbox; + @extend %input-checkbox; + .checkbox-label { - @include deprecated.bodySmallTypography; + @include t.use-typography("body-small"); + display: flex; align-items: center; flex-direction: row-reverse; - gap: deprecated.$s-6; - min-height: deprecated.$s-32; + gap: px2rem(6); + min-block-size: var(--sp-xxxl); cursor: pointer; + span { - @extend .checkbox-icon; + @extend %checkbox-icon; } + input { display: none !important; } + &:hover { span { border-color: var(--input-checkbox-border-color-hover); } } + a { // Need for terms and conditions links on register checkbox color: var(--link-foreground-color); @@ -180,43 +212,60 @@ // SELECT .custom-select { - @extend .select-wrapper; - height: deprecated.$s-32; + @extend %select-wrapper; + + block-size: $sz-32; + .input-container { - @include deprecated.flexRow; - height: deprecated.$s-32; - width: 100%; - border-radius: deprecated.$br-8; - border: deprecated.$s-1 solid var(--input-border-color); + display: flex; + align-items: center; + gap: var(--sp-xs); + block-size: $sz-32; + inline-size: 100%; + border-radius: var(--sp-s); + border: $b-1 solid var(--input-border-color); + + @extend %select-wrapper; + color: var(--input-foreground-color-active); background-color: var(--input-background-color); + .main-content { - @include deprecated.flexColumn; - @include deprecated.bodySmallTypography; + @include t.use-typography("body-small"); + + display: flex; + flex-direction: column; + gap: var(--sp-xs); position: relative; justify-content: center; flex-grow: 1; - height: 100%; - padding: deprecated.$s-8; + block-size: 100%; + padding: var(--sp-s); .label { color: var(--input-foreground-color); } + .value { - width: 100%; - padding: 0px; - margin: 0px; - border: 0px; + inline-size: 100%; + padding: 0; + margin: 0; + border: 0; color: var(--input-foreground-color-active); } } + .icon { - @include deprecated.flexCenter; - height: deprecated.$s-32; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + block-size: $sz-32; + inline-size: $sz-24; pointer-events: none; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); transform: rotate(90deg); } @@ -224,50 +273,56 @@ &.disabled { background-color: var(--input-background-color-disabled); - border: deprecated.$s-1 solid var(--input-border-color-disabled); + border: $b-1 solid var(--input-border-color-disabled); color: var(--input-foreground-color-disabled); } + &.focus { outline: none; color: var(--input-foreground-color-active); background-color: var(--input-background-color-active); - border: deprecated.$s-1 solid var(--input-border-color-active); + border: $b-1 solid var(--input-border-color-active); } } select { - @extend .menu-dropdown; - @include deprecated.bodySmallTypography; + @extend %menu-dropdown; + @include t.use-typography("body-small"); + box-sizing: border-box; position: absolute; - top: 0; - left: 0; - min-height: deprecated.$s-32; - height: auto; - width: calc(100% - 1px); - padding: 0 deprecated.$s-12; + inset-block-start: 0; + inset-inline-start: 0; + min-block-size: $sz-32; + block-size: auto; + inline-size: calc(100% - 1px); + padding: 0 var(--sp-m); margin: 0; border: none; opacity: 0; - z-index: deprecated.$z-index-10; + z-index: var(--z-index-dropdown); background-color: transparent; cursor: pointer; + option { - @include deprecated.bodySmallTypography; + @include t.use-typography("body-small"); + color: var(--title-foreground-color-hover); background-color: var(--menu-background-color); appearance: none; - height: deprecated.$s-32; + block-size: $sz-32; } } } // SUBMIT-BUTTON .button-submit { - @extend .button-primary; + @extend %button-primary; + &:disabled { - @extend .button-disabled; - min-height: deprecated.$s-32; + @extend %button-disabled; + + min-block-size: $sz-32; } } @@ -276,78 +331,98 @@ display: flex; flex-direction: column; position: relative; - min-height: deprecated.$s-40; - max-height: deprecated.$s-180; - width: 100%; + min-block-size: $sz-40; + max-block-size: px2rem(180); + inline-size: 100%; overflow-y: hidden; + .inside-input { - @include deprecated.removeInputStyle; - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; - width: 100%; - max-width: calc(100% - deprecated.$s-1); - min-height: deprecated.$s-32; - padding-top: 0; - height: deprecated.$s-32; - padding: deprecated.$s-8; + @include deprecated.remove-input-style; + @include t.use-typography("body-small"); + @include text-ellipsis; + + inline-size: 100%; + max-inline-size: calc(100% - deprecated.$s-1); + min-block-size: $sz-32; + padding-block-start: 0; + block-size: $sz-32; + padding: var(--sp-s); margin: 0; - border-radius: deprecated.$br-8; + border-radius: var(--sp-s); color: var(--input-foreground-color-active); background-color: var(--input-background-color); + &:focus { outline: none; - border: deprecated.$s-1 solid var(--input-border-color-focus); + border: $b-1 solid var(--input-border-color-focus); } + &.invalid { - border: deprecated.$s-1 solid var(--input-border-color-error); + border: $b-1 solid var(--input-border-color-error); + &:hover, &:focus { - border: deprecated.$s-1 solid var(--input-border-color-error); + border: $b-1 solid var(--input-border-color-error); } } } + label { display: none; } + .selected-items { display: flex; flex-wrap: wrap; - gap: deprecated.$s-4; - max-height: deprecated.$s-136; - padding: deprecated.$s-4 0; + gap: var(--sp-xs); + max-block-size: px2rem(136); + padding: var(--sp-xs) 0; overflow-y: auto; .selected-item { .around { - @include deprecated.flexRow; - height: deprecated.$s-24; - width: fit-content; - padding-left: deprecated.$s-6; - border-radius: deprecated.$br-6; + display: flex; + align-items: center; + gap: var(--sp-xs); + block-size: $sz-24; + inline-size: fit-content; + padding-inline-start: px2rem(6); + border-radius: $br-6; background-color: var(--pill-background-color); - border: deprecated.$s-1 solid var(--pill-background-color); + border: $b-1 solid var(--pill-background-color); box-sizing: border-box; + .text { - @include deprecated.bodySmallTypography; - padding-right: deprecated.$s-8; + @include t.use-typography("body-small"); + + padding-inline-end: var(--sp-s); color: var(--pill-foreground-color); } .icon { - @include deprecated.flexCenter; - @include deprecated.buttonStyle; - height: deprecated.$s-32; - width: deprecated.$s-24; + display: flex; + justify-content: center; + align-items: center; + border: none; + background: none; + cursor: pointer; + block-size: $sz-32; + inline-size: $sz-24; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } + &.invalid { background-color: var(--status-widget-background-color-error); + .text { color: var(--alert-text-foreground-color-error); } + .icon svg { stroke: var(--alert-text-foreground-color-error); } @@ -361,97 +436,107 @@ .custom-radio { display: grid; grid-template-columns: repeat(3, 1fr); - gap: deprecated.$s-16; + gap: var(--sp-l); } .radio-label { - @include deprecated.bodySmallTypography; - @include deprecated.flexRow; + @include t.use-typography("body-small"); + + display: flex; + align-items: center; align-items: flex-start; - gap: deprecated.$s-8; - min-height: deprecated.$s-32; - height: fit-content; - border-radius: deprecated.$br-8; - padding: deprecated.$s-8; + gap: var(--sp-s); + min-block-size: $sz-32; + block-size: fit-content; + border-radius: var(--sp-s); + padding: var(--sp-s); color: var(--input-foreground-color-rest); - border: deprecated.$s-1 solid transparent; - &:focus, - &:focus-within { + border: $b-1 solid transparent; + + &:has(:focus-visible) { outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); + border: $b-1 solid var(--input-border-color-active); } } .radio-dot { - height: deprecated.$s-8; - width: deprecated.$s-8; - border-radius: deprecated.$br-circle; + block-size: var(--sp-s); + inline-size: var(--sp-s); + border-radius: $br-circle; background-color: var(--color-background-tertiary); } .radio-input { - width: 0; + inline-size: 0; margin: 0; } .radio-icon { - @extend .checkbox-icon; - border-radius: deprecated.$br-circle; + @extend %checkbox-icon; + + border-radius: $br-circle; } .radio-label-image { - @include deprecated.smallTitleTipography; + @include t.use-typography("body-medium"); + display: grid; - grid-template-rows: auto auto 0px; + grid-template-rows: auto auto 0; justify-items: center; gap: 0; - border-radius: deprecated.$br-8; + border-radius: var(--sp-s); margin: 0; - border: 1px solid var(--color-background-tertiary); + border: $b-1 solid var(--color-background-tertiary); cursor: pointer; + &:global(.checked) { - border: 1px solid var(--color-accent-primary); + border: $b-1 solid var(--color-accent-primary); } + &:focus, &:focus-within { outline: none; - border: deprecated.$s-1 solid var(--input-border-color-active); + border: $b-1 solid var(--input-border-color-active); } + .image-text { color: var(--input-foreground-color-rest); display: grid; align-self: center; - margin-bottom: deprecated.$s-16; - padding-inline: deprecated.$s-8; + margin-block-end: var(--sp-l); + padding-inline: var(--sp-s); text-align: center; } } .image-inside { - margin: deprecated.$s-16; + margin: var(--sp-l); background-size: 100%; background-repeat: no-repeat; background-position: center; } .icon-inside { - margin: deprecated.$s-16; - @include deprecated.flexCenter; + margin: var(--sp-l); + display: flex; + justify-content: center; + align-items: center; + svg { - width: 40px; - height: 60px; + inline-size: 40px; + block-size: 60px; stroke: var(--icon-foreground); fill: none; } } -//TEXTAREA +// TEXTAREA .textarea-label { - @include deprecated.uppercaseTitleTipography; + @include t.use-typography("headline-small"); + color: var(--modal-title-foreground-color); - text-transform: uppercase; - margin-bottom: deprecated.$s-8; + margin-block-end: var(--sp-s); } .textarea-wrapper { diff --git a/frontend/src/app/main/ui/components/numeric_input.cljs b/frontend/src/app/main/ui/components/numeric_input.cljs index 3d7a2b3e46..bec4efd044 100644 --- a/frontend/src/app/main/ui/components/numeric_input.cljs +++ b/frontend/src/app/main/ui/components/numeric_input.cljs @@ -63,6 +63,11 @@ ;; Last value input by the user we need to store to save on unmount last-value* (mf/use-var value) + ;; Drag scrubbing state + drag-state* (mf/use-ref :idle) + drag-start-x* (mf/use-ref 0) + drag-start-val* (mf/use-ref 0) + parse-value (mf/use-fn (mf/deps min-value max-value value nillable? default integer?) @@ -217,16 +222,80 @@ (mf/use-callback (mf/deps on-focus select-on-focus?) (fn [event] - (reset! last-value* (parse-value)) - (let [target (dom/get-target event)] - (when on-focus - (mf/set-ref-val! dirty-ref true) - (on-focus event)) + (when-not (= :dragging (mf/ref-val drag-state*)) + (reset! last-value* (parse-value)) + (let [target (dom/get-target event)] + (when on-focus + (mf/set-ref-val! dirty-ref true) + (on-focus event)) - (when select-on-focus? - (dom/select-text! target) - ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect - (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) + (when select-on-focus? + (dom/select-text! target) + ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect + (.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))) + + on-scrub-pointer-down + (mf/use-fn + (mf/deps value value-str min-value max-value default) + (fn [event] + (let [disabled? (unchecked-get props "disabled") + node (mf/ref-val ref) + is-focused (and (some? node) (dom/active? node))] + (when-not (or disabled? is-focused (= :multiple value-str)) + (let [client-x (.-clientX event) + start-val (or value default 0)] + (mf/set-ref-val! drag-state* :maybe-dragging) + (mf/set-ref-val! drag-start-x* client-x) + (mf/set-ref-val! drag-start-val* start-val) + (dom/capture-pointer event)))))) + + on-scrub-pointer-move + (mf/use-fn + (mf/deps apply-value update-input step-value min-value max-value) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (or (= state :maybe-dragging) (= state :dragging)) + (let [client-x (.-clientX event) + start-x (mf/ref-val drag-start-x*) + delta-x (- client-x start-x)] + (when (and (= state :maybe-dragging) + (>= (js/Math.abs delta-x) 3)) + (mf/set-ref-val! drag-state* :dragging) + (dom/add-class! (dom/get-body) "cursor-drag-scrub")) + (when (= (mf/ref-val drag-state*) :dragging) + (let [effective-step (cond + (.-shiftKey event) (* step-value 10) + (.-ctrlKey event) (* step-value 0.1) + :else step-value) + steps (js/Math.round (/ delta-x 1)) + new-val (+ (mf/ref-val drag-start-val*) + (* steps effective-step)) + new-val (cond-> new-val + (d/num? min-value) (mth/max min-value) + (d/num? max-value) (mth/min max-value))] + (update-input new-val) + (apply-value event new-val)))))))) + + on-scrub-pointer-up + (mf/use-fn + (mf/deps ref) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (= state :maybe-dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/release-pointer event) + (when-let [node (mf/ref-val ref)] + (dom/focus! node))) + (when (= state :dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (dom/release-pointer event))))) + + on-scrub-lost-pointer-capture + (mf/use-fn + (fn [_event] + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub"))) props (-> (obj/clone props) (obj/unset! "selectOnFocus") @@ -241,7 +310,11 @@ (obj/set! "title" title) (obj/set! "onKeyDown" handle-key-down) (obj/set! "onBlur" handle-blur) - (obj/set! "onFocus" handle-focus))] + (obj/set! "onFocus" handle-focus) + (obj/set! "onPointerDown" on-scrub-pointer-down) + (obj/set! "onPointerMove" on-scrub-pointer-move) + (obj/set! "onPointerUp" on-scrub-pointer-up) + (obj/set! "onLostPointerCapture" on-scrub-lost-pointer-capture))] (mf/with-effect [value] (when-let [input-node (mf/ref-val ref)] diff --git a/frontend/src/app/main/ui/components/org_avatar.cljs b/frontend/src/app/main/ui/components/org_avatar.cljs new file mode 100644 index 0000000000..43521a1dd7 --- /dev/null +++ b/frontend/src/app/main/ui/components/org_avatar.cljs @@ -0,0 +1,42 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.components.org-avatar + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [rumext.v2 :as mf])) + +(mf/defc org-avatar* + {::mf/props :obj} + [{:keys [org size]}] + (let [name (:name org) + custom-photo (:custom-photo org) + avatar-bg (:avatar-bg-url org) + initials (d/get-initials name)] + + (if custom-photo + [:img {:src custom-photo + :class (stl/css-case :org-avatar true + :org-avatar-custom true + :org-avatar-xxxl (= size "xxxl") + :org-avatar-xxl (= size "xxl") + :org-avatar-xl (= size "xl")) + :alt name}] + [:div {:class (stl/css-case :org-avatar true + :org-avatar-xxxl (= size "xxxl") + :org-avatar-xxl (= size "xxl") + :org-avatar-xl (= size "xl")) + :aria-hidden "true"} + [:img {:src avatar-bg + :class (stl/css :org-avatar-bg) + :alt ""}] + (when (seq initials) + [:span {:class (stl/css-case :org-avatar-initials true + :size-initials-xxxl (= size "xxxl") + :size-initials-xxl (= size "xxl") + :size-initials-xxl (= size "xl"))} ;; Keep the initials as xxl to make them legible + initials])]))) diff --git a/frontend/src/app/main/ui/components/org_avatar.scss b/frontend/src/app/main/ui/components/org_avatar.scss new file mode 100644 index 0000000000..b72591568b --- /dev/null +++ b/frontend/src/app/main/ui/components/org_avatar.scss @@ -0,0 +1,63 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/typography.scss" as t; +@use "ds/colors.scss" as *; + +.org-avatar { + position: relative; + border-radius: 50%; + overflow: hidden; + flex-shrink: 0; +} + +.org-avatar-custom { + object-fit: cover; +} + +.org-avatar-xxxl { + width: var(--sp-xxxl); + height: var(--sp-xxxl); +} + +.org-avatar-xxl { + width: var(--sp-xxl); + height: var(--sp-xxl); +} + +.org-avatar-xl { + width: var(--sp-xl); + height: var(--sp-xl); +} + +.org-avatar-bg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + object-fit: cover; +} + +.org-avatar-initials { + display: flex; + justify-content: center; + align-items: center; + position: absolute; + inset: 0; + color: #{$gray-950}; +} + +.size-initials-xxl { + @include t.use-typography("headline-small"); + + font-weight: 600; +} + +.size-initials-xxxl { + @include t.use-typography("headline-medium"); + + font-weight: 600; +} diff --git a/frontend/src/app/main/ui/components/progress.scss b/frontend/src/app/main/ui/components/progress.scss index 0ef02d0f17..646571f7f8 100644 --- a/frontend/src/app/main/ui/components/progress.scss +++ b/frontend/src/app/main/ui/components/progress.scss @@ -8,7 +8,8 @@ // PROGRESS WIDGET .progress-widget { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-28; height: deprecated.$s-28; } @@ -19,6 +20,7 @@ --export-modal-fg-color: var(--alert-text-foreground-color-default); --export-modal-icon-color: var(--alert-icon-foreground-color-default); --export-modal-border-color: var(--alert-border-color-default); + position: absolute; right: deprecated.$s-16; top: deprecated.$s-48; @@ -41,13 +43,15 @@ --export-modal-fg-color: var(--alert-text-foreground-color-error); --export-modal-icon-color: var(--alert-icon-foreground-color-error); --export-modal-border-color: var(--alert-border-color-error); + grid-template-areas: "icon text close"; gap: deprecated.$s-8; padding-block: deprecated.$s-8; } .icon { - @extend .button-icon; + @extend %button-icon; + grid-area: icon; align-self: center; margin-inline-start: deprecated.$s-8; @@ -55,7 +59,8 @@ } .title { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + display: grid; grid-template-columns: auto 1fr; gap: deprecated.$s-8; @@ -67,7 +72,8 @@ } .progress { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + padding-left: deprecated.$s-8; margin: 0; align-self: center; @@ -75,8 +81,9 @@ } .retry-btn { - @include deprecated.buttonStyle; - @include deprecated.bodySmallTypography; + @include deprecated.button-style; + @include deprecated.body-small-typography; + display: inline; text-align: left; color: var(--modal-link-foreground-color); @@ -85,13 +92,15 @@ } .progress-close-button { - @include deprecated.buttonStyle; + @include deprecated.button-style; + padding: 0; margin-inline-end: deprecated.$s-8; } .close-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--export-modal-icon-color); } diff --git a/frontend/src/app/main/ui/components/radio_buttons.scss b/frontend/src/app/main/ui/components/radio_buttons.scss index 6ef73339ad..f8c06c4715 100644 --- a/frontend/src/app/main/ui/components/radio_buttons.scss +++ b/frontend/src/app/main/ui/components/radio_buttons.scss @@ -7,7 +7,8 @@ @use "refactor/common-refactor.scss" as deprecated; .radio-btn-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + border-radius: deprecated.$br-8; height: deprecated.$s-32; background-color: var(--input-background-color); @@ -17,9 +18,10 @@ .radio-icon { --radio-icon-border-color: var(--radio-btn-border-color); - @include deprecated.buttonStyle; - @include deprecated.flexCenter; - @include deprecated.focusRadio; + @include deprecated.button-style; + @include deprecated.flex-center; + @include deprecated.focus-radio; + height: deprecated.$s-32; flex-grow: 1; border-radius: deprecated.$s-8; @@ -29,14 +31,19 @@ input { display: none; } + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--radio-btn-foreground-color); } + .title-name { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--radio-btn-foreground-color); } + &:hover { svg { stroke: var(--radio-btn-foreground-color-selected); @@ -48,9 +55,11 @@ --radio-icon-border-color: var(--radio-btn-border-color-selected); background-color: var(--radio-btn-background-color-selected); + svg { stroke: var(--radio-btn-foreground-color-selected); } + .title-name { color: var(--radio-btn-foreground-color-selected); } @@ -60,18 +69,23 @@ cursor: default; background-color: transparent; border: deprecated.$s-2 solid transparent; + svg { stroke: var(--button-foreground-color-disabled); } + .title-name { color: var(--button-foreground-color-disabled); } + &:hover { background-color: transparent; border: deprecated.$s-2 solid transparent; + svg { stroke: var(--button-foreground-color-disabled); } + .title-name { color: var(--button-foreground-color-disabled); } diff --git a/frontend/src/app/main/ui/components/reorder_handler.scss b/frontend/src/app/main/ui/components/reorder_handler.scss index 499ff56ad5..8991efb661 100644 --- a/frontend/src/app/main/ui/components/reorder_handler.scss +++ b/frontend/src/app/main/ui/components/reorder_handler.scss @@ -20,6 +20,7 @@ block-size: var(--sp-l); pointer-events: none; visibility: var(--reorder-icon-visibility, hidden); + --icon-stroke-color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/components/search_bar.scss b/frontend/src/app/main/ui/components/search_bar.scss index 96855005a1..22294f1d94 100644 --- a/frontend/src/app/main/ui/components/search_bar.scss +++ b/frontend/src/app/main/ui/components/search_bar.scss @@ -22,7 +22,8 @@ } .search-input-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-32; width: 100%; border: deprecated.$s-1 solid var(--search-bar-input-border-color); @@ -32,6 +33,7 @@ &:hover { border: deprecated.$s-1 solid var(--input-border-color-hover); background-color: var(--input-background-color-hover); + .search-input { background-color: var(--input-background-color-hover); } @@ -41,6 +43,7 @@ background-color: var(--input-background-color-active); color: var(--input-foreground-color-active); border: deprecated.$s-1 solid var(--input-border-color-focus); + .search-input { background-color: var(--input-background-color-active); } @@ -56,13 +59,15 @@ font-size: deprecated.$fs-12; color: var(--input-foreground-color); border-radius: deprecated.$br-8; + &:focus { outline: none; } } .clear-icon { - @extend .button-tag; + @extend %button-tag; + flex: 0 0 deprecated.$s-32; height: 100%; color: var(--color-icon-default); diff --git a/frontend/src/app/main/ui/components/select.scss b/frontend/src/app/main/ui/components/select.scss index ba01e42e08..e75034f6f3 100644 --- a/frontend/src/app/main/ui/components/select.scss +++ b/frontend/src/app/main/ui/components/select.scss @@ -11,8 +11,10 @@ --bg-color: var(--menu-background-color); --icon-color: var(--icon-foreground); --text-color: var(--menu-foreground-color); - @extend .new-scrollbar; - @include deprecated.bodySmallTypography; + + @extend %new-scrollbar; + @include deprecated.body-small-typography; + position: relative; display: grid; grid-template-columns: 1fr auto; @@ -48,32 +50,40 @@ --border-color: var(--menu-border-color-disabled); --icon-color: var(--menu-foreground-color-disabled); --text-color: var(--menu-foreground-color-disabled); + pointer-events: none; cursor: default; } .dropdown-button { - @include deprecated.flexCenter; + @include deprecated.flex-center; + margin-inline-end: var(--sp-xxs); + svg { - @extend .button-icon-small; + @extend %button-icon-small; + transform: rotate(90deg); stroke: var(--icon-color); } } .current-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-24; padding-right: deprecated.$s-4; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } .custom-select-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + .separator { margin: 0; height: deprecated.$s-12; @@ -87,14 +97,18 @@ } .checked-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; padding-right: deprecated.$s-4; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } @@ -105,9 +119,11 @@ } .check-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + visibility: hidden; stroke: var(--icon-foreground); } @@ -115,16 +131,18 @@ &.is-selected { color: var(--menu-foreground-color); + .check-icon svg { stroke: var(--menu-foreground-color); visibility: visible; } } + &.disabled { display: none; } } .current-label { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; } diff --git a/frontend/src/app/main/ui/components/tab_container.scss b/frontend/src/app/main/ui/components/tab_container.scss index aab1d5ffdd..89c5692c55 100644 --- a/frontend/src/app/main/ui/components/tab_container.scss +++ b/frontend/src/app/main/ui/components/tab_container.scss @@ -31,7 +31,8 @@ } .tab-container-tab-title { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; width: 100%; padding: 0 deprecated.$s-8; @@ -43,12 +44,14 @@ min-width: 0; svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--tab-foreground-color); } .content { - @include deprecated.headlineSmallTypography; + @include deprecated.headline-small-typography; + text-align: center; white-space: nowrap; overflow: hidden; @@ -76,8 +79,9 @@ } .collapse-sidebar { - @include deprecated.flexCenter; - @include deprecated.buttonStyle; + @include deprecated.flex-center; + @include deprecated.button-style; + height: 100%; width: deprecated.$s-24; min-width: deprecated.$s-24; @@ -85,7 +89,8 @@ border-radius: deprecated.$br-5; svg { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-16; width: deprecated.$s-16; stroke: var(--icon-foreground); @@ -109,13 +114,12 @@ } .tab-container-content { - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; display: flex; flex-direction: column; } -//Firefox doesn't respect scrollbar-gutter +// Firefox doesn't respect scrollbar-gutter @supports (-moz-appearance: none) { .tab-container-content { padding-right: deprecated.$s-8; diff --git a/frontend/src/app/main/ui/components/title_bar.cljs b/frontend/src/app/main/ui/components/title_bar.cljs index 432936b0b3..56c74696c3 100644 --- a/frontend/src/app/main/ui/components/title_bar.cljs +++ b/frontend/src/app/main/ui/components/title_bar.cljs @@ -13,30 +13,21 @@ (mf/defc title-bar* [{:keys [class collapsable collapsed title children - btn-icon btn-title all-clickable add-icon-gap + btn-icon btn-title add-icon-gap title-class on-collapsed on-btn-click]}] - [:div {:class [(stl/css-case :title-bar true - :all-clickable all-clickable) + [:div {:class [(stl/css :title-bar) class]} (if ^boolean collapsable [:div {:class [(stl/css :title-wrapper) title-class]} (let [icon-id (if collapsed "arrow-right" "arrow-down")] - (if ^boolean all-clickable - [:button {:class (stl/css :icon-text-btn) - :on-click on-collapsed} - [:> icon* {:icon-id icon-id - :size "s" - :class (stl/css :icon)}] - [:div {:class (stl/css :title)} title]] - [:* - [:button {:class (stl/css :icon-btn) - :on-click on-collapsed} - [:> icon* {:icon-id icon-id - :size "s" - :class (stl/css :icon)}]] - [:div {:class (stl/css :title)} title]]))] + [:button {:class (stl/css :icon-text-btn) + :on-click on-collapsed} + [:> icon* {:icon-id icon-id + :size "s" + :class (stl/css :icon)}] + [:div {:class (stl/css :title)} title]])] [:div {:class [(stl/css-case :title-only true :title-only-icon-gap add-icon-gap) diff --git a/frontend/src/app/main/ui/components/title_bar.scss b/frontend/src/app/main/ui/components/title_bar.scss index b4b5b84554..de605369bc 100644 --- a/frontend/src/app/main/ui/components/title_bar.scss +++ b/frontend/src/app/main/ui/components/title_bar.scss @@ -14,6 +14,7 @@ height: deprecated.$s-32; width: 100%; min-height: deprecated.$s-32; + --arrow-icon-color: var(--icon-foreground); --title-color: var(--title-foreground-color); } @@ -32,12 +33,15 @@ .title { @include t.use-typography("headline-small"); + color: var(--title-color); } .title-only { @include t.use-typography("headline-small"); + --title-bar-title-margin: #{deprecated.$s-8}; + color: var(--title-color); margin-inline-start: var(--title-bar-title-margin); } @@ -63,7 +67,8 @@ } .icon-text-btn { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: flex; align-items: center; flex-grow: 1; @@ -75,12 +80,3 @@ --title-color: var(--title-foreground-color-hover); } } - -.icon-btn { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; - - &:hover { - --arrow-icon-color: var(--icon-foreground-hover); - } -} diff --git a/frontend/src/app/main/ui/confirm.cljs b/frontend/src/app/main/ui/confirm.cljs index d2c068ebf2..522641e93c 100644 --- a/frontend/src/app/main/ui/confirm.cljs +++ b/frontend/src/app/main/ui/confirm.cljs @@ -30,10 +30,12 @@ on-accept on-cancel hint + error-msg items cancel-label accept-label - accept-style] :as props}] + accept-style + hint-level] :as props}] (let [on-accept (or on-accept identity) on-cancel (or on-cancel identity) message (or message (tr "ds.confirm-title")) @@ -83,9 +85,12 @@ (when (and (string? scd-message) (not= scd-message "")) [:h3 {:class (stl/css :modal-scd-msg)} scd-message]) (when (string? hint) - [:> context-notification* {:level :info + [:> context-notification* {:level (or hint-level :info) :appearance :ghost} hint]) + (when (string? error-msg) + [:> context-notification* {:level :error :class (stl/css :modal-error-msg)} + error-msg]) (when (> (count items) 0) [:* [:p {:class (stl/css :modal-subtitle)} diff --git a/frontend/src/app/main/ui/confirm.scss b/frontend/src/app/main/ui/confirm.scss index 09b23426f3..0f14a6e305 100644 --- a/frontend/src/app/main/ui/confirm.scss +++ b/frontend/src/app/main/ui/confirm.scss @@ -7,21 +7,24 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; + &.transparent { background-color: transparent; } } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + display: flex; flex-direction: column; gap: var(--sp-xxl); } .modal-title { - @include deprecated.headlineMediumTypography; + @include deprecated.headline-medium-typography; + color: var(--modal-title-foreground-color); } @@ -32,30 +35,37 @@ } .modal-content { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; } .modal-item-element { - @include deprecated.flexRow; + @include deprecated.flex-row; } .modal-component-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + color: var(--color-foreground-secondary); } .modal-component-name { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--color-foreground-secondary); } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .modal-scd-msg, .modal-subtitle, .modal-msg { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); } + +.modal-error-msg { + margin: var(--sp-xxl) 0; +} diff --git a/frontend/src/app/main/ui/dashboard.cljs b/frontend/src/app/main/ui/dashboard.cljs index 5962ecacf3..2625105d94 100644 --- a/frontend/src/app/main/ui/dashboard.cljs +++ b/frontend/src/app/main/ui/dashboard.cljs @@ -13,8 +13,10 @@ [app.main.data.dashboard.shortcuts :as sc] [app.main.data.event :as ev] [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] [app.main.data.notifications :as notif] [app.main.data.plugins :as dp] + [app.main.data.profile :as dprof] [app.main.data.project :as dpj] [app.main.refs :as refs] [app.main.router :as rt] @@ -261,6 +263,14 @@ (binding [storage/*sync* true] (swap! storage/session dissoc :template)))))) +(defn- use-nitrate-entry-popup + [] + (mf/with-effect [] + (when (dnt/nitrate-entry-popup-pending?) + (dnt/consume-nitrate-entry-popup!) + (st/emit! (dprof/update-profile-props {:onboarding-viewed true}) + (dnt/show-nitrate-popup :nitrate-form))))) + (mf/defc dashboard* [{:keys [profile project-id team-id search-term plugin-url template section]}] (let [team (mf/deref refs/team) @@ -299,6 +309,7 @@ (use-plugin-register plugin-url team-id (:id default-project)) (use-templates-import can-edit? template default-project) + (use-nitrate-entry-popup) [:& (mf/provider ctx/current-project-id) {:value project-id} [:> modal-container*] diff --git a/frontend/src/app/main/ui/dashboard.scss b/frontend/src/app/main/ui/dashboard.scss index 994f56a723..045d817316 100644 --- a/frontend/src/app/main/ui/dashboard.scss +++ b/frontend/src/app/main/ui/dashboard.scss @@ -7,7 +7,8 @@ @use "refactor/common-refactor.scss" as deprecated; .dashboard { - @extend .new-scrollbar; + @extend %new-scrollbar; + background-color: var(--app-background); display: grid; grid-template-columns: deprecated.$s-40 deprecated.$s-256 1fr; diff --git a/frontend/src/app/main/ui/dashboard/change_owner.cljs b/frontend/src/app/main/ui/dashboard/change_owner.cljs index fc4ae33cc9..31bb3b3b0a 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.cljs +++ b/frontend/src/app/main/ui/dashboard/change_owner.cljs @@ -7,11 +7,14 @@ (ns app.main.ui.dashboard.change-owner (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.schema :as sm] + [app.common.uuid :as uuid] [app.main.data.modal :as modal] [app.main.ui.components.forms :as fm] [app.main.ui.icons :as deprecated-icon] [app.util.i18n :as i18n :refer [tr]] + [cuerdas.core :as str] [rumext.v2 :as mf])) (def ^:private schema:leave-modal-form @@ -72,3 +75,110 @@ :disabled (not (:valid @form)) :value (tr "modals.leave-and-reassign.promote-and-leave") :on-click on-accept}]]]]])) + + + +(mf/defc ^:private team-member-select* + [{:keys [team profile form field-name default-member-id]}] + (let [members (get team :members) + filtered-members (->> members + (filter #(not= (:email %) (:email profile)))) + options (->> filtered-members + (map #(hash-map :label (:name %) :value (str (:id %)))))] + [:div {:class (stl/css :team-select-container)} + [:div {:class (stl/css :team-name)} (:name team)] + (if (empty? filtered-members) + [:p {:class (stl/css :modal-msg)} + (tr "modals.leave-and-reassign.forbidden")] + [:& fm/select {:name field-name + :select-class (stl/css :team-member) + :dropdown-class (stl/css :team-member) + :options options + :form form + :default default-member-id}])])) + +(defn- make-leave-org-modal-form-schema [teams] + (into + [:map {:title "LeaveOrgModalForm"}] + (for [team teams] + [(keyword (str "member-id-" (:id team))) ::sm/text]))) + + +(mf/defc leave-and-reassign-org-modal + {::mf/register modal/components + ::mf/register-as :leave-and-reassign-org + ::mf/wrap [mf/memo]} + [{:keys [profile teams-to-transfer num-teams-to-delete accept] :as props}] + (let [schema (mf/with-memo [teams-to-transfer] + (make-leave-org-modal-form-schema teams-to-transfer)) + ;; Compute initial values for each team select + team-fields (mf/with-memo [teams-to-transfer] + (for [team teams-to-transfer] + (let [members (get team :members) + filtered-members (filter #(not= (:email %) (:email profile)) members) + first-admin (first (filter :is-admin filtered-members)) + first-member (first filtered-members) + default-member-id (cond + first-admin (str (:id first-admin)) + first-member (str (:id first-member)) + :else "") + field-name (keyword (str "member-id-" (:id team)))] + {:team team + :field-name field-name + :default-member-id default-member-id}))) + + initial-values (mf/with-memo [team-fields] + (d/index-by :field-name :default-member-id team-fields)) + + form (fm/use-form :schema schema :initial initial-values) + + all-valid? (every? + (fn [{:keys [field-name]}] + (let [val (get-in @form [:clean-data field-name])] + (not (str/blank? val)))) + team-fields) + + on-accept (fn [_] + (let [teams-to-transfer (mapv (fn [{:keys [team field-name]}] + (let [val (get-in @form [:clean-data field-name])] + {:id (:id team) + :reassign-to (uuid/parse val)})) + team-fields)] + (accept {:teams-to-transfer teams-to-transfer})))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-org-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-org-title)} (tr "modals.before-leave-org.title")] + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + + [:div {:class (stl/css :modal-content)} + (if (zero? num-teams-to-delete) + [:p {:class (stl/css :modal-org-msg)} + (tr "modals.leave-org-and-reassign.hint")] + [:* + [:p {:class (stl/css :modal-org-msg)} + (tr "modals.leave-org-and-reassign.hint-delete")] + [:p {:class (stl/css :modal-org-msg)} + (tr "modals.leave-org-and-reassign.hint-promote")]]) + [:& fm/form {:form form} + [:div {:class (stl/css :teams-container)} + (for [{:keys [team field-name default-member-id]} team-fields] + ^{:key (:id team)} + [:> team-member-select* {:team team :profile profile :form form :field-name field-name :default-member-id default-member-id}])]]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + [:input {:class (stl/css :cancel-button) + :type "button" + :value (tr "labels.cancel") + :on-click modal/hide!}] + + [:input.accept-button + {:type "button" + :class (stl/css-case :accept-btn true + :danger all-valid? + :global/disabled (not all-valid?)) + :disabled (not all-valid?) + :value (tr "modals.leave-and-reassign.promote-and-leave") + :on-click on-accept}]]]]])) diff --git a/frontend/src/app/main/ui/dashboard/change_owner.scss b/frontend/src/app/main/ui/dashboard/change_owner.scss index 40e8387274..13f5968eb8 100644 --- a/frontend/src/app/main/ui/dashboard/change_owner.scss +++ b/frontend/src/app/main/ui/dashboard/change_owner.scss @@ -5,13 +5,18 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; +@use "ds/z-index.scss" as *; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; + + z-index: var(--z-index-notifications); } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; } .modal-header { @@ -19,39 +24,85 @@ } .modal-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin-bottom: deprecated.$s-24; } .input-wrapper { - @extend .input-with-label; - @include deprecated.bodySmallTypography; + @extend %input-with-label; + @include deprecated.body-small-typography; } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } .modal-msg { color: var(--modal-text-foreground-color); } + +.teams-container { + display: flex; + flex-direction: column; + gap: var(--sp-s); + margin: var(--sp-xxxl) 0; +} + +.team-select-container { + display: grid; + grid-template-columns: 1fr 2fr; + align-items: center; + width: 100%; +} + +.modal-org-container { + @extend %modal-container-base; + + overflow-y: auto; + max-height: $sz-512; +} + +.modal-org-title { + @include t.use-typography("headline-large"); + + color: var(--modal-title-foreground-color); +} + +.modal-org-msg { + @include t.use-typography("body-large"); + + color: var(--modal-text-foreground-color); +} + +.team-name { + @include t.use-typography("body-medium"); + + color: var(--modal-text-foreground-color); +} + +.team-member { + @include t.use-typography("body-medium"); +} diff --git a/frontend/src/app/main/ui/dashboard/comments.scss b/frontend/src/app/main/ui/dashboard/comments.scss index c81fd1ee71..2d5948199c 100644 --- a/frontend/src/app/main/ui/dashboard/comments.scss +++ b/frontend/src/app/main/ui/dashboard/comments.scss @@ -7,7 +7,8 @@ @use "refactor/common-refactor.scss" as deprecated; .dashboard-comments-section { - @include deprecated.flexCenter; + @include deprecated.flex-center; + position: relative; border-radius: deprecated.$br-8; } @@ -35,7 +36,8 @@ } .comments-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); height: deprecated.$s-24; width: deprecated.$s-24; @@ -44,25 +46,28 @@ .comment-button { position: relative; + .unread { position: absolute; width: deprecated.$s-8; height: deprecated.$s-8; border: deprecated.$s-2 solid var(--color-background-tertiary); border-radius: 50%; - background: red; + background: var(--color-foreground-error); top: deprecated.$s-6; right: deprecated.$s-6; } } .comments-icon-small { - @extend .button-icon; + @extend %button-icon; + stroke: var(--comment-icon-small-foreground-color); } .dropdown { - @include deprecated.menuShadow; + @include deprecated.menu-shadow; + background-color: var(--color-background-tertiary); border-radius: deprecated.$br-8; border: deprecated.$s-1 solid transparent; @@ -101,6 +106,7 @@ &:hover { cursor: pointer; } + &.mark-all-as-read-button { border-radius: deprecated.$s-8; border: deprecated.$s-1 solid; diff --git a/frontend/src/app/main/ui/dashboard/deleted.scss b/frontend/src/app/main/ui/dashboard/deleted.scss index 7187633722..69eeb9585d 100644 --- a/frontend/src/app/main/ui/dashboard/deleted.scss +++ b/frontend/src/app/main/ui/dashboard/deleted.scss @@ -29,9 +29,10 @@ .deleted-info { display: block; - height: fit-content; color: var(--color-foreground-secondary); + @include t.use-typography("body-large"); + line-height: 0.8; height: var(--sp-xl); } @@ -64,7 +65,6 @@ .nav-option { color: var(--color-foreground-secondary); padding: 0.5rem; - display: flex; align-items: center; justify-content: center; @@ -101,6 +101,7 @@ .project-name { @include t.use-typography("body-large"); + width: fit-content; margin-inline-end: var(--sp-m); line-height: 0.8; @@ -116,7 +117,8 @@ .add-file-btn, .options-btn { - @extend .button-tertiary; + @extend %button-tertiary; + height: var(--sp-xxxl); width: var(--sp-xxxl); margin: 0 var(--sp-s); @@ -131,6 +133,7 @@ .add-icon, .menu-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } diff --git a/frontend/src/app/main/ui/dashboard/files.scss b/frontend/src/app/main/ui/dashboard/files.scss index 838f8ea78c..39cfe4958b 100644 --- a/frontend/src/app/main/ui/dashboard/files.scss +++ b/frontend/src/app/main/ui/dashboard/files.scss @@ -20,6 +20,7 @@ &.dashboard-projects { user-select: none; } + &.dashboard-shared { width: calc(100vw - deprecated.$s-320); margin-right: deprecated.$s-52; @@ -35,7 +36,8 @@ } .menu-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } diff --git a/frontend/src/app/main/ui/dashboard/fonts.cljs b/frontend/src/app/main/ui/dashboard/fonts.cljs index 72c57856b9..eaefe3925f 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.cljs +++ b/frontend/src/app/main/ui/dashboard/fonts.cljs @@ -65,10 +65,9 @@ (mf/defc font-variant-display-name* {::mf/private true} [{:keys [variant]}] - [:* - [:span (cm/font-weight->name (:font-weight variant))] - (when (not= "normal" (:font-style variant)) - [:span " " (str/capital (:font-style variant))])]) + [:span (cm/font-display-variant (:variant-name variant) + (:font-weight variant) + (:font-style variant))]) (mf/defc uploaded-fonts* {::mf/private true} diff --git a/frontend/src/app/main/ui/dashboard/fonts.scss b/frontend/src/app/main/ui/dashboard/fonts.scss index f3d195b35b..f277fbe517 100644 --- a/frontend/src/app/main/ui/dashboard/fonts.scss +++ b/frontend/src/app/main/ui/dashboard/fonts.scss @@ -5,7 +5,6 @@ // Copyright (c) KALEIDOS INC @use "common/refactor/common-dashboard"; - @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @@ -37,6 +36,7 @@ h3 { @include t.use-typography("title-small"); + color: var(--color-foreground-secondary); margin: var(--sp-xs); } @@ -48,6 +48,7 @@ .installed-fonts-header { @include t.use-typography("headline-small"); + align-items: center; color: var(--color-foreground-secondary); display: flex; @@ -55,7 +56,8 @@ padding-left: var(--sp-xxl); > .family { - @include twoLineTextEllipsis; + @include two-line-text-ellipsis; + min-width: $sz-200; width: $sz-200; } @@ -72,8 +74,8 @@ input { @include t.use-typography("body-medium"); + background-color: var(--color-background-tertiary); - border-color: transparent; border-radius: $br-8; border: $b-1 solid transparent; color: var(--color-foreground-primary); @@ -85,6 +87,7 @@ &:focus { outline: $b-1 solid var(--color-accent-primary); } + &::placeholder { color: var(--color-foreground-secondary); } @@ -93,6 +96,7 @@ .font-item { @include t.use-typography("body-medium"); + align-items: center; background-color: var(--color-background-tertiary); border-radius: $br-4; @@ -106,11 +110,11 @@ input { @include t.use-typography("body-medium"); - @include textEllipsis; + @include text-ellipsis; + border: $b-1 solid transparent; margin: 0; padding: var(--sp-s); - background-color: var(--color-background-tertiary); border-radius: $br-8; color: var(--color-foreground-primary); @@ -123,21 +127,25 @@ } > .family { - @include twoLineTextEllipsis; + @include two-line-text-ellipsis; + min-width: $sz-200; width: $sz-200; + &.is-edition { overflow: visible; } } > .filenames { - @include textEllipsis; + @include text-ellipsis; + min-width: $sz-200; } > .variants { @include t.use-typography("body-medium"); + display: flex; flex-wrap: wrap; flex-grow: 1; @@ -151,12 +159,14 @@ padding: var(--sp-s) var(--sp-m); cursor: pointer; gap: var(--sp-xs); + .icon { display: flex; align-items: center; justify-content: center; height: $sz-16; width: $sz-16; + svg { fill: none; width: $sz-12; @@ -171,6 +181,7 @@ } } } + .inhert-variant { cursor: default; } @@ -178,6 +189,7 @@ .table-field { color: var(--color-foreground-primary); + .variant { background-color: var(--color-background-quaternary); border-radius: $br-8; @@ -186,7 +198,8 @@ .filenames { @include t.use-typography("body-small"); - @include textEllipsis; + @include text-ellipsis; + min-width: $sz-400; padding-left: var(--sp-xxxl); } @@ -203,6 +216,7 @@ margin-left: var(--sp-m); justify-content: center; align-items: center; + svg { width: $sz-16; height: $sz-16; @@ -212,6 +226,7 @@ &.failure { margin-right: var(--sp-m); + svg { stroke: var(--element-foreground-warning); } @@ -220,6 +235,7 @@ &.close { background: none; border: none; + svg { stroke: var(--color-foreground-secondary); } @@ -245,6 +261,7 @@ .dashboard-fonts-hero { @include t.use-typography("body-medium"); + padding: var(--sp-xxxl) 0; margin-top: px2rem(80); display: flex; @@ -269,6 +286,7 @@ p { @include t.use-typography("body-large"); + color: var(--color-foreground-secondary); } } @@ -299,6 +317,7 @@ .label { @include t.use-typography("body-medium"); + color: var(--color-foreground-secondary); } } diff --git a/frontend/src/app/main/ui/dashboard/grid.cljs b/frontend/src/app/main/ui/dashboard/grid.cljs index 1396986e06..c1a813adc4 100644 --- a/frontend/src/app/main/ui/dashboard/grid.cljs +++ b/frontend/src/app/main/ui/dashboard/grid.cljs @@ -411,6 +411,7 @@ :ref node-ref :role "button" :title (:name file) + :aria-label (:name file) :draggable (dm/str can-edit) :on-click on-select :on-key-down on-key-down diff --git a/frontend/src/app/main/ui/dashboard/grid.scss b/frontend/src/app/main/ui/dashboard/grid.scss index e1aaef396a..394979b7e7 100644 --- a/frontend/src/app/main/ui/dashboard/grid.scss +++ b/frontend/src/app/main/ui/dashboard/grid.scss @@ -8,15 +8,13 @@ // TODO: Legacy sass variables. We should remove them in favor of DS tokens. $bp-max-1366: "(max-width: 1366px)"; - $thumbnail-default-width: deprecated.$s-252; // Default width $thumbnail-default-height: deprecated.$s-168; // Default width .dashboard-grid { font-size: deprecated.$fs-14; height: 100%; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; padding: 0 var(--sp-l) deprecated.$s-16; } @@ -42,6 +40,7 @@ $thumbnail-default-height: deprecated.$s-168; // Default width width: 100%; font-weight: deprecated.$fw400; } + button { background-color: transparent; border: none; @@ -108,7 +107,6 @@ $thumbnail-default-height: deprecated.$s-168; // Default width line-height: 1.92; max-width: deprecated.$s-260; overflow: hidden; - padding-right: deprecated.$s-8; padding: 0; text-overflow: ellipsis; white-space: nowrap; @@ -126,9 +124,11 @@ $thumbnail-default-height: deprecated.$s-168; // Default width width: 100%; white-space: nowrap; max-width: deprecated.$s-260; + &::first-letter { text-transform: capitalize; } + @media #{$bp-max-1366} { max-width: deprecated.$s-232; } @@ -198,9 +198,11 @@ $thumbnail-default-height: deprecated.$s-168; // Default width &:focus, &:focus-within { background-color: var(--color-background-tertiary); + .project-th-actions { opacity: 1; } + a { text-decoration: none; } @@ -243,6 +245,7 @@ $thumbnail-default-height: deprecated.$s-168; // Default width margin-right: 0; margin-top: deprecated.$s-20; width: 100%; + --menu-icon-color: var(--button-tertiary-foreground-color-rest); &:hover, diff --git a/frontend/src/app/main/ui/dashboard/import.cljs b/frontend/src/app/main/ui/dashboard/import.cljs index 5f5dd533b8..7bb0f0b60f 100644 --- a/frontend/src/app/main/ui/dashboard/import.cljs +++ b/frontend/src/app/main/ui/dashboard/import.cljs @@ -194,13 +194,14 @@ (mf/defc import-entry* {::mf/memo true ::mf/private true} - [{:keys [entries entry edition can-be-deleted on-edit on-change on-delete]}] + [{:keys [entries entry edition can-be-deleted importing? on-edit on-change on-delete]}] (let [status (:status entry) ;; FIXME: rename to format format (:type entry) loading? (or (= :analyze status) - (= :import-progress status)) + (= :import-progress status) + (and importing? (= :import-ready status))) analyze-error? (= :analyze-error status) import-success? (= :import-success status) import-error? (= :import-error status) @@ -293,7 +294,9 @@ import-error? [:div {:class (stl/css :error-message)} - (tr "labels.error")] + (if (some? (:error entry)) + (tr (:error entry)) + (tr "labels.error"))] (and (not import-success?) (some? progress)) [:div {:class (stl/css :progress-message)} (parse-progress-message progress)]) @@ -489,7 +492,12 @@ [:ul {:class (stl/css :import-error-list)} (for [entry entries] (when (contains? #{:import-error :analyze-error} (:status entry)) - [:li {:class (stl/css :import-error-list-enry)} (:name entry)]))] + [:li {:class (stl/css :import-error-list-enry) + :key (dm/str (or (:file-id entry) (:uri entry) (:name entry)))} + [:div (:name entry)] + (when-let [err (:error entry)] + [:div {:class (stl/css :import-error-detail)} + (tr err)])]))] [:div (tr "dashboard.import.import-error.message2")]] (for [entry entries] @@ -497,6 +505,7 @@ :key (dm/str (:uri entry) "/" (:file-id entry)) :entry entry :entries entries + :importing? (= :import-progress status) :on-edit on-edit :on-change on-entry-change :on-delete on-entry-delete @@ -504,7 +513,13 @@ (when (some? template) [:> import-entry* {:entry (assoc template :status status) - :can-be-deleted false}])] + :can-be-deleted false}]) + + (when (= :import-progress status) + [:div {:class (stl/css :status-message) + :role "status" + :aria-live "polite"} + (tr "labels.uploading-file")])] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} diff --git a/frontend/src/app/main/ui/dashboard/import.scss b/frontend/src/app/main/ui/dashboard/import.scss index cf7cea13ae..2d3cb22e67 100644 --- a/frontend/src/app/main/ui/dashboard/import.scss +++ b/frontend/src/app/main/ui/dashboard/import.scss @@ -7,11 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + display: flex; flex-direction: column; } @@ -21,19 +22,20 @@ } .modal-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + flex: 1; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; display: grid; grid-template-columns: 1fr; gap: deprecated.$s-16; @@ -41,99 +43,140 @@ min-height: 40px; } +.status-message { + @include deprecated.body-small-typography; + + color: var(--modal-title-foreground-color); + font-style: italic; +} + .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } + .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } .modal-scd-msg, .modal-subtitle, .modal-msg { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--modal-text-foreground-color); line-height: 1.5; } .file-entry { display: flex; + .file-name { - @include deprecated.flexRow; + @include deprecated.flex-row; + .file-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-16; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } + &.icon-fill svg { fill: var(--icon-foreground); } } + .file-name-edit { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + flex-grow: 1; } + .file-name-label { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; gap: deprecated.$s-12; flex-grow: 1; + .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-16; width: deprecated.$s-16; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } } + .edit-entry-buttons { - @include deprecated.flexRow; + @include deprecated.flex-row; + button { - @extend .button-tertiary; + @extend %button-tertiary; + width: deprecated.$s-28; height: deprecated.$s-32; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } } } + .error-message, .progress-message { display: flex; align-items: center; - height: deprecated.$s-32; + min-height: deprecated.$s-32; color: var(--modal-text-foreground-color); } + .error-message { + align-items: flex-start; + white-space: pre-wrap; + overflow-wrap: anywhere; + } + .linked-library { display: flex; align-items: center; gap: deprecated.$s-12; color: var(--modal-text-foreground-color); + .linked-library-tag { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } + &.error { svg { stroke: var(--element-foreground-error); @@ -147,45 +190,57 @@ color: var(--modal-text-foreground-color); } } + &.warning { .file-name { color: var(--element-foreground-warning); + .file-icon svg { stroke: var(--element-foreground-warning); } + .file-icon.icon-fill svg { fill: var(--element-foreground-warning); } } } + &.success { .file-name { color: var(--modal-text-foreground-color); + .file-icon svg { stroke: var(--modal-text-foreground-color); } + .file-icon.icon-fill svg { fill: var(--modal-text-foreground-color); } } } + &.error { .file-name { color: var(--modal-text-foreground-color); + .file-icon svg { stroke: var(--modal-text-foreground-color); } + .file-icon.icon-fill svg { fill: var(--modal-text-foreground-color); } } } + &.editable { .file-name { color: var(--modal-text-foreground-color); + .file-icon svg { stroke: var(--modal-text-foreground-color); } + .file-icon.icon-fill svg { fill: var(--modal-text-foreground-color); } @@ -209,3 +264,12 @@ .import-error-list-enry { padding: var(--sp-xs) 0; } + +.import-error-detail { + @include deprecated.body-small-typography; + + margin-top: var(--sp-xs); + color: var(--modal-text-foreground-color); + white-space: pre-wrap; + overflow-wrap: anywhere; +} diff --git a/frontend/src/app/main/ui/dashboard/inline_edition.scss b/frontend/src/app/main/ui/dashboard/inline_edition.scss index 4f62033011..d07e701fee 100644 --- a/frontend/src/app/main/ui/dashboard/inline_edition.scss +++ b/frontend/src/app/main/ui/dashboard/inline_edition.scss @@ -34,7 +34,6 @@ input.element-title { .close { cursor: pointer; position: absolute; - top: deprecated.$s-1; right: calc(-1 * deprecated.$s-8); @@ -45,6 +44,7 @@ input.element-title { width: deprecated.$s-16; margin: 0; } + &:hover { svg { fill: var(--element-foreground-warning); diff --git a/frontend/src/app/main/ui/dashboard/placeholder.scss b/frontend/src/app/main/ui/dashboard/placeholder.scss index ca47399436..6254535b58 100644 --- a/frontend/src/app/main/ui/dashboard/placeholder.scss +++ b/frontend/src/app/main/ui/dashboard/placeholder.scss @@ -14,7 +14,7 @@ padding: deprecated.$s-12 0; &.libs { - background-image: url(/images/ph-left.svg), url(/images/ph-right.svg); + background-image: url("/images/ph-left.svg"), url("/images/ph-right.svg"); background-position: 15% bottom, 85% top; @@ -47,7 +47,6 @@ border-radius: deprecated.$br-8; color: var(--color-foreground-primary); cursor: pointer; - height: deprecated.$s-160; margin: deprecated.$s-8; border: deprecated.$s-2 solid transparent; width: var(--th-width, #{g.$thumbnail-default-width}); @@ -113,9 +112,11 @@ .empty-project-card { @include t.use-typography("body-small"); + --color-card-background: var(--color-background-tertiary); --color-card-title: var(--color-foreground-primary); --color-card-subtitle: var(--color-foreground-secondary); + display: flex; flex-direction: column; justify-content: center; @@ -129,6 +130,7 @@ --color-card-background: var(--color-accent-primary); --color-card-title: var(--color-background-secondary); --color-card-subtitle: var(--color-background-secondary); + cursor: pointer; .empty-project-card-title { diff --git a/frontend/src/app/main/ui/dashboard/projects.scss b/frontend/src/app/main/ui/dashboard/projects.scss index a37575c38e..2616edcd49 100644 --- a/frontend/src/app/main/ui/dashboard/projects.scss +++ b/frontend/src/app/main/ui/dashboard/projects.scss @@ -41,6 +41,7 @@ .dashboard-project-row { --actions-opacity: 0; + margin-block-end: var(--sp-xxl); position: relative; @@ -83,8 +84,9 @@ } .project-name { - @include textEllipsis; + @include text-ellipsis; @include t.use-typography("body-large"); + color: var(--title-foreground-color-hover); cursor: pointer; block-size: $sz-16; @@ -101,8 +103,10 @@ .info, .recent-files-row-title-info { @include t.use-typography("body-medium"); + color: var(--title-foreground-color); - @media (max-width: 760px) { + + @media (width <= 760px) { display: none; } } @@ -115,7 +119,8 @@ .add-file-btn, .options-btn { - @extend .button-tertiary; + @extend %button-tertiary; + block-size: $sz-32; inline-size: $sz-32; margin: 0 var(--sp-s); @@ -124,7 +129,8 @@ .add-icon, .menu-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } @@ -139,7 +145,9 @@ .show-more { --show-more-color: var(--button-secondary-foreground-color-rest); + @include t.use-typography("body-medium"); + border: none; background: none; cursor: pointer; @@ -178,7 +186,7 @@ border-radius: $br-4; inline-size: auto; - @media (max-width: 1200px) { + @media (width <= 1200px) { display: none; inline-size: 0; } @@ -201,18 +209,22 @@ .info { flex: 1; font-size: $sz-16; + span { color: var(--color-foreground-secondary); display: block; } + a { color: var(--color-accent-primary); } + padding: var(--sp-s) 0; } .close { --close-icon-foreground-color: var(--icon-foreground); + position: absolute; top: var(--sp-xl); inset-inline-end: var(--sp-xxl); @@ -220,13 +232,15 @@ background-color: transparent; border: none; cursor: pointer; + &:hover { --close-icon-foreground-color: var(--button-icon-foreground-color-selected); } } .close-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--close-icon-foreground-color); } @@ -243,7 +257,8 @@ block-size: var(--sp-xl) 0; overflow: hidden; border-radius: $br-4; - @media (max-width: 1200px) { + + @media (width <= 1200px) { display: none; inline-size: 0; } diff --git a/frontend/src/app/main/ui/dashboard/search.scss b/frontend/src/app/main/ui/dashboard/search.scss index c360ac61ac..f514fb4ba0 100644 --- a/frontend/src/app/main/ui/dashboard/search.scss +++ b/frontend/src/app/main/ui/dashboard/search.scss @@ -6,7 +6,7 @@ @use "refactor/common-refactor.scss" as deprecated; @use "common/refactor/common-dashboard"; -@use "./placeholder.scss"; +@use "./placeholder"; .dashboard-container { flex: 1 0 0; @@ -18,6 +18,7 @@ &.dashboard-projects { user-select: none; } + &.dashboard-shared { width: calc(100vw - deprecated.$s-320); margin-right: deprecated.$s-52; @@ -41,6 +42,7 @@ .text { color: var(--color-foreground-primary); } + .icon svg { stroke: var(--color-foreground-secondary); width: deprecated.$s-32; diff --git a/frontend/src/app/main/ui/dashboard/sidebar.cljs b/frontend/src/app/main/ui/dashboard/sidebar.cljs index 66604f3f73..84509976a4 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.cljs +++ b/frontend/src/app/main/ui/dashboard/sidebar.cljs @@ -25,12 +25,14 @@ [app.main.ui.components.dropdown-menu :refer [dropdown-menu* dropdown-menu-item*]] [app.main.ui.components.link :refer [link]] + [app.main.ui.components.org-avatar :refer [org-avatar*]] [app.main.ui.dashboard.comments :refer [comments-icon* comments-section]] [app.main.ui.dashboard.inline-edition :refer [inline-edition]] [app.main.ui.dashboard.project-menu :refer [project-menu*]] [app.main.ui.dashboard.subscription :refer [dashboard-cta* get-subscription-type menu-team-icon* + nitrate-current-plan* nitrate-sidebar* show-subscription-dashboard-banner? subscription-sidebar*]] @@ -72,6 +74,12 @@ (def ^:private menu-icon (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) +(def ^:private org-menu-icon + (deprecated-icon/icon-xref :menu (stl/css :org-menu-icon))) + +(def ^:private org-menu-icon-open + (deprecated-icon/icon-xref :menu (stl/css :org-menu-icon-open))) + (def ^:private pin-icon (deprecated-icon/icon-xref :pin (stl/css :pin-icon))) @@ -307,46 +315,47 @@ (mf/deps profile) (fn [] (if (dnt/is-valid-license? profile) - (dnt/go-to-nitrate-cc) + (dnt/go-to-nitrate-cc-create-org) (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) on-go-to-cc-click (mf/use-fn - (mf/deps organization) + (mf/deps organization profile) (fn [] - (dnt/go-to-nitrate-cc organization))) + ;; Navigate to active org if user owns it, otherwise to last visited org + (if (and (:id organization) + (= (:id profile) (:owner-id organization))) + (dnt/go-to-nitrate-cc organization) + (dnt/go-to-nitrate-cc)))) - default-team-id (or (->> organizations - vals - (filter :is-default) - first - :id) + empty-org (d/seek #(nil? (:id %)) organizations) + default-team-id (or (:default-team-id empty-org) (:default-team-id profile)) - organizations (dissoc organizations default-team-id)] + + organizations (filter :id organizations) + + is-valid-license? (dnt/is-valid-license? profile)] [:> dropdown-menu* props [:> dropdown-menu-item* {:on-click on-org-click :data-value default-team-id :class (stl/css :org-dropdown-item)} - [:span {:class (stl/css :nitrate-org-icon)} + [:span {:class (stl/css :org-icon)} [:> raw-svg* {:id penpot-logo-icon}]] "Penpot" - (when (= default-team-id (:id organization)) + (when (= default-team-id (:default-team-id organization)) tick-icon)] - (for [org-item (remove :is-default (vals organizations))] + (for [org-item organizations] [:> dropdown-menu-item* {:on-click on-org-click - :data-value (:id org-item) + :data-value (:default-team-id org-item) :class (stl/css :org-dropdown-item) - :key (str (:id org-item))} - ;; TODO org pictures - [:img {:src (cf/resolve-team-photo-url org-item) - :class (stl/css :team-picture) - :alt (:name org-item)}] + :key (str (:default-team-id org-item))} + [:> org-avatar* {:org org-item :size "xxl"}] [:span {:class (stl/css :team-text) :title (:name org-item)} (:name org-item)] - (when (= (:id org-item) (:id organization)) + (when (= (:default-team-id org-item) (:default-team-id organization)) tick-icon)]) [:hr {:role "separator" :class (stl/css :team-separator)}] @@ -354,10 +363,11 @@ :class (stl/css :org-dropdown-item :action)} [:span {:class (stl/css :icon-wrapper)} add-org-icon] [:span {:class (stl/css :team-text)} (tr "dashboard.create-new-org")]] - [:> dropdown-menu-item* {:on-click on-go-to-cc-click - :class (stl/css :org-dropdown-item :action)} - [:span {:class (stl/css :icon-wrapper)} arrow-up-right-icon] - [:span {:class (stl/css :team-text)} (tr "dashboard.go-to-control-center")]]])) + (when is-valid-license? + [:> dropdown-menu-item* {:on-click on-go-to-cc-click + :class (stl/css :org-dropdown-item :action)} + [:span {:class (stl/css :icon-wrapper)} arrow-up-right-icon] + [:span {:class (stl/css :team-text)} (tr "dashboard.go-to-control-center")]])])) (mf/defc teams-selector-dropdown* {::mf/private true} @@ -371,7 +381,13 @@ teams (dissoc teams default-team-id) on-create-team-click - (mf/use-fn #(st/emit! (modal/show :team-form {}))) + (mf/use-fn + (mf/deps team) + (fn [] + (let [params (if (and (contains? cf/flags :nitrate) (:organization-id team)) + {:organization-id (:organization-id team)} + {})] + (st/emit! (modal/show :team-form params))))) on-team-click (mf/use-fn @@ -383,12 +399,12 @@ [:> dropdown-menu* props [:> dropdown-menu-item* {:on-click on-team-click - :data-value (:default-team-id profile) + :data-value default-team-id :class (stl/css :team-dropdown-item)} [:span {:class (stl/css :penpot-icon)} deprecated-icon/logo-icon] [:span {:class (stl/css :team-text)} (tr "dashboard.your-penpot")] - (when (= (:default-team-id profile) (:id team)) + (when (= default-team-id (:id team)) tick-icon)] (for [team-item (remove :is-default (vals teams))] @@ -438,18 +454,22 @@ (modal/hide)))) on-error - (fn [{:keys [code] :as error}] - (condp = code - :no-enough-members-for-leave - (rx/of (ntf/error (tr "errors.team-leave.insufficient-members"))) + (fn [error] + (let [code (-> error ex-data :code)] + (condp = code + :only-owner-can-delete-team + (rx/of (ntf/error (tr "errors.team-leave.only-owner-can-delete"))) - :member-does-not-exist - (rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists"))) + :no-enough-members-for-leave + (rx/of (ntf/error (tr "errors.team-leave.insufficient-members"))) - :owner-cant-leave-team - (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) + :member-does-not-exist + (rx/of (ntf/error (tr "errors.team-leave.member-does-not-exists"))) - (rx/throw error))) + :owner-cant-leave-team + (rx/of (ntf/error (tr "errors.team-leave.owner-cant-leave"))) + + (rx/throw error)))) leave-fn (mf/use-fn @@ -505,14 +525,19 @@ on-delete-clicked (mf/use-fn - (mf/deps delete-fn) - #(st/emit! - (modal/show - {:type :confirm - :title (tr "modals.delete-team-confirm.title") - :message (tr "modals.delete-team-confirm.message") - :accept-label (tr "modals.delete-team-confirm.accept") - :on-accept delete-fn})))] + (mf/deps team delete-fn) + (fn [] + (let [is-org-team? (some? (:organization-id team)) + message (if is-org-team? + (tr "modals.delete-org-team-confirm.message" (:organization-name team)) + (tr "modals.delete-team-confirm.message"))] + (st/emit! + (modal/show + {:type :confirm + :title (tr "modals.delete-team-confirm.title") + :message message + :accept-label (tr "modals.delete-team-confirm.accept") + :on-accept delete-fn})))))] [:> dropdown-menu* props [:> dropdown-menu-item* {:on-click go-members @@ -565,10 +590,108 @@ :data-testid "delete-team"} (tr "dashboard.delete-team")])])) +(mf/defc org-options-dropdown* + {::mf/private true} + [{:keys [organization profile teams] :rest props}] + (let [default-team-id (mf/with-memo [teams] + (->> teams + (filter :is-default) + first + :id)) + non-default-teams (mf/with-memo [teams] + (remove :is-default teams)) + owned-teams (mf/with-memo [non-default-teams] + (filter #(dm/get-in % [:permissions :is-owner]) non-default-teams)) + not-owned-teams (mf/with-memo [non-default-teams] + (remove #(dm/get-in % [:permissions :is-owner]) non-default-teams)) + teams-to-delete (mf/with-memo [owned-teams] + (filter #(= (count (:members %)) 1) owned-teams)) + teams-to-transfer (mf/with-memo [owned-teams] + (filter #(> (count (:members %)) 1) owned-teams)) + num-teams-to-leave (+ (count teams-to-transfer) (count not-owned-teams)) + num-teams-to-delete (count teams-to-delete) + num-teams-to-transfer (count teams-to-transfer) + + on-error + (mf/use-fn + (fn [error] + (let [code (-> error ex-data :code) + ;; Map error codes to their translation keys + error-map {:not-valid-teams "errors.org-leave.no-valid-teams" + :org-owner-cannot-leave "errors.org-leave.org-owner-cannot-leave" + :only-owner-can-delete-team "errors.team-leave.only-owner-can-delete" + :no-enough-members-for-leave "errors.team-leave.insufficient-members" + :member-does-not-exist "errors.team-leave.member-does-not-exists" + :owner-cant-leave-team "errors.team-leave.owner-cant-leave"}] + + (if-let [tr-key (get error-map code)] + (rx/of (dtm/fetch-teams) + (modal/hide) + (ntf/error (tr tr-key))) + (rx/throw error))))) + + leave-fn + (mf/use-fn + (mf/deps on-error organization default-team-id not-owned-teams teams-to-delete) + (fn [{:keys [teams-to-transfer]}] + (let [teams-to-leave (cond->> not-owned-teams + :always + (map #(select-keys % [:id])) + (seq teams-to-transfer) + (concat teams-to-transfer)) + teams-to-delete (map :id teams-to-delete)] + + + (st/emit! (dnt/leave-org {:id (:id organization) + :name (:name organization) + :default-team-id default-team-id + :teams-to-delete teams-to-delete + :teams-to-leave teams-to-leave + :on-error on-error}))))) + + on-leave-clicked + (mf/use-fn + (mf/deps leave-fn profile organization teams-to-transfer num-teams-to-leave num-teams-to-delete num-teams-to-transfer) + (fn [] + (cond + (and (pos? num-teams-to-delete) + (zero? num-teams-to-transfer)) + (st/emit! (modal/show + {:type :confirm + :title (tr "modals.before-leave-org.title" (:name organization)) + :message (tr "modals.before-leave-org.message") + :accept-label (tr "modals.leave-org-confirm.accept") + :on-accept leave-fn + :error-msg (tr "modals.before-leave-org.warning")})) + (pos? num-teams-to-transfer) + (st/emit! + (modal/show + {:type :leave-and-reassign-org + :profile profile + :teams-to-transfer teams-to-transfer + :num-teams-to-delete num-teams-to-delete + :accept leave-fn})) + + :else + (st/emit! (modal/show + {:type :confirm + :title (tr "modals.leave-org-confirm.title" (:name organization)) + :message (tr "modals.leave-org-confirm.message") + :accept-label (tr "modals.leave-org-confirm.accept") + :on-accept leave-fn})))))] + (mf/use-effect + (fn [] + ;; We need all the team members of the owned teams + ;; TODO this will re-render once for each owned team, not very performance-wise + (do + (doseq [team owned-teams] + (st/emit! (dtm/fetch-members (:id team))))))) + [:> dropdown-menu* props + + [:> dropdown-menu-item* {:on-click on-leave-clicked + :class (stl/css :team-options-item)} + (tr "dashboard.leave-org")]])) -(defn- team->org [team] - (assoc (dm/select-keys team [:id :organization-id :organization-slug]) - :name (:organization-name team))) (mf/defc sidebar-org-switch* [{:keys [team profile]}] @@ -579,14 +702,23 @@ (->> teams vals (filter :is-default) - (map team->org) + (map dtm/team->organization) (d/index-by :id))) - no-orgs? (= (count orgs) 0) + show-dropdown? (or (dnt/is-valid-license? profile) + (> (count orgs) 1)) - current-org (team->org team) + current-org (dtm/team->organization team) - default-org? (= (:default-team-id profile) (:id current-org)) + org-teams (mf/with-memo [teams current-org] + (->> teams + vals + (filter #(= (:organization-id %) (:id current-org))))) + + default-org? (nil? (:id current-org)) + + show-options? (and (not default-org?) + (not= (:id profile) (:owner-id current-org))) show-orgs-menu* (mf/use-state false) @@ -594,6 +726,21 @@ show-orgs-menu? (deref show-orgs-menu*) + show-org-options-menu* + (mf/use-state false) + + show-org-options-menu? + (deref show-org-options-menu*) + + on-show-options-click + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-org-options-menu* not))) + + close-org-options-menu + (mf/use-fn #(reset! show-org-options-menu* false)) + on-show-orgs-click (mf/use-fn (fn [event] @@ -617,41 +764,35 @@ (mf/deps profile) (fn [] (if (dnt/is-valid-license? profile) - (dnt/go-to-nitrate-cc) + (dnt/go-to-nitrate-cc-create-org) (st/emit! (dnt/show-nitrate-popup :nitrate-form)))))] - (if no-orgs? - [:div {:class (stl/css :nitrate-selected-org)} - [:span {:class (stl/css :nitrate-penpot-icon)} - [:> raw-svg* {:id penpot-logo-icon}]] - "Penpot" - [:> button* {:variant "ghost" - :type "button" - :class (stl/css :nitrate-create-org) - :on-click on-create-org-click} (tr "dashboard.plus-create-new-org")]] - + (if show-dropdown? [:div {:class (stl/css :sidebar-org-switch)} + [:div {:class (stl/css :org-switch-content)} + [:button {:class (stl/css-case :current-org true :current-org-no-options (not show-options?)) + :on-click on-show-orgs-click + :on-key-down on-show-orgs-keydown + :aria-expanded show-orgs-menu? + :aria-haspopup "menu"} + [:div {:class (stl/css :team-name)} + (if default-org? + [:* + [:span {:class (stl/css :org-penpot-icon)} + [:> raw-svg* {:id penpot-logo-icon}]] + [:span {:class (stl/css :team-text)} + "Penpot"]] + [:* + [:> org-avatar* {:org current-org :size "xxxl"}] + [:span {:class (stl/css :team-text)} + (:name current-org)]])] + arrow-icon] + (when show-options? + [:> button* {:variant "ghost" + :type "button" + :class (stl/css :org-options-btn) + :on-click on-show-options-click} + (if show-org-options-menu? org-menu-icon-open org-menu-icon)])] - [:button {:class (stl/css :current-org) - :on-click on-show-orgs-click - :on-key-down on-show-orgs-keydown - :aria-expanded show-orgs-menu? - :aria-haspopup "menu"} - [:div {:class (stl/css :team-name)} - (if default-org? - [:* - [:span {:class (stl/css :nitrate-penpot-icon)} - [:> raw-svg* {:id penpot-logo-icon}]] - [:span {:class (stl/css :team-text)} - "Penpot"]] - [:* - [:span {:class (stl/css :nitrate-penpot-icon)} - ;; TODO org pictures - [:img {:src (cf/resolve-team-photo-url current-org) - :class (stl/css :team-picture) - :alt (:name current-org)}]] - [:span {:class (stl/css :team-text)} - (:name current-org)]])] - arrow-icon] ;; Orgs Dropdown [:> organizations-selector-dropdown* {:show show-orgs-menu? @@ -660,15 +801,31 @@ :class (stl/css :dropdown :teams-dropdown) :organization current-org :profile profile - :organizations orgs}]]))) + :organizations (vals orgs)}] + ;; Orgs options + [:> org-options-dropdown* {:show show-org-options-menu? + :on-close close-org-options-menu + :id "team-options" + :class (stl/css :dropdown :options-dropdown) + :organization current-org + :profile profile + :teams org-teams}]] + [:div {:class (stl/css :selected-org)} + [:span {:class (stl/css :org-penpot-icon)} + [:> raw-svg* {:id penpot-logo-icon}]] + "Penpot" + [:> button* {:variant "ghost" + :type "button" + :class (stl/css :create-org) + :on-click on-create-org-click} (tr "dashboard.plus-create-new-org")]]))) (mf/defc sidebar-team-switch* [{:keys [team profile]}] (let [nitrate? (contains? cf/flags :nitrate) - org-id (when nitrate? (:organization-id team)) + organization-id (when nitrate? (:organization-id team)) teams (cond->> (mf/deref refs/teams) nitrate? - (filter #(= (-> % val :organization-id) org-id)) + (filter #(= (-> % val :organization-id) organization-id)) nitrate? (into {})) @@ -900,11 +1057,12 @@ (reset! overflow* (> scroll-height client-height)))) [:* - [:div {:ref container} + [:div {:class (stl/css :sidebar-content-wrapper)} (when nitrate? - [:div {:class (stl/css :nitrate-orgs-container)} + [:div {:class (stl/css :orgs-container)} [:> sidebar-org-switch* {:team team :profile profile}]]) - [:div {:class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)} + [:div {:ref container + :class (stl/css-case :sidebar-content true :sidebar-content-nitrate nitrate?)} [:> sidebar-team-switch* {:team team :profile profile}] [:> sidebar-search* {:search-term search-term @@ -1161,14 +1319,21 @@ (st/emit! (ptk/event ::ev/event {::ev/name "explore-pricing-click" ::ev/origin "dashboard" :section "sidebar"})) (dom/open-new-window "https://penpot.app/pricing")))] + (mf/with-effect [show-profile-menu?] + (when-not show-profile-menu? + (reset! sub-menu* nil))) + [:* (if (contains? cf/flags :nitrate) - [:> nitrate-sidebar* {:profile profile :teams teams}] + [:* + [:> nitrate-sidebar* {:profile profile :teams teams}] + [:> nitrate-current-plan* {:profile profile}]] (when (contains? cf/flags :subscriptions) (if (show-subscription-dashboard-banner? profile) [:> dashboard-cta* {:profile profile}] [:> subscription-sidebar* {:profile profile}]))) + ;; TODO remove this block when subscriptions is full implemented (when (contains? cf/flags :subscriptions-old) [:button {:class (stl/css :upgrade-plan-section) diff --git a/frontend/src/app/main/ui/dashboard/sidebar.scss b/frontend/src/app/main/ui/dashboard/sidebar.scss index 7b83394abe..51b3b929a2 100644 --- a/frontend/src/app/main/ui/dashboard/sidebar.scss +++ b/frontend/src/app/main/ui/dashboard/sidebar.scss @@ -28,15 +28,23 @@ background-color: var(--panel-background-color); } -//SIDEBAR CONTENT COMPONENT +// SIDEBAR CONTENT COMPONENT +.sidebar-content-wrapper { + display: flex; + flex-direction: column; + min-height: 0; + height: 100%; + overflow: hidden; +} + .sidebar-content { display: grid; grid-template-rows: auto auto auto auto 1fr; gap: var(--sp-xxl); - height: 100%; + flex: 1; + min-height: 0; padding: 0; - overflow-x: hidden; - overflow-y: auto; + overflow: hidden auto; } .sidebar-content-nitrate { @@ -56,6 +64,7 @@ .sidebar-section-title { @include t.use-typography("headline-small"); + padding: 0 var(--sp-s) var(--sp-s) var(--sp-xxl); color: var(--color-foreground-secondary); } @@ -78,7 +87,8 @@ } .current-team { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: grid; align-items: center; grid-template-columns: 1fr auto; @@ -96,8 +106,9 @@ } .team-text { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; @include t.use-typography("title-small"); + width: auto; text-align: left; color: var(--menu-foreground-color-hover); @@ -112,7 +123,7 @@ // This icon still use the old svg .penpot-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; svg { fill: var(--icon-foreground); @@ -122,21 +133,24 @@ } .team-picture { - @include deprecated.flexCenter; + @include deprecated.flex-center; + border-radius: 50%; height: var(--sp-xxl); width: var(--sp-xxl); } .arrow-icon { - @extend .button-icon; + @extend %button-icon; + transform: rotate(90deg); stroke: var(--icon-foreground); } .switch-options { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + max-width: var(--sp-xxl); min-width: deprecated.$s-28; height: 100%; @@ -145,26 +159,28 @@ } .menu-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } // DROPDOWNS .teams-dropdown { - @extend .menu-dropdown; + @extend %menu-dropdown; + left: 0; top: deprecated.$s-52; height: fit-content; max-height: $sz-480; min-width: deprecated.$s-248; width: 100%; - overflow-x: hidden; - overflow-y: auto; + overflow: hidden auto; } .team-dropdown-item { - @extend .menu-item-base; + @extend %menu-item-base; + display: grid; grid-template-columns: var(--sp-xxl) 1fr auto; gap: var(--sp-s); @@ -172,7 +188,8 @@ } .org-dropdown-item { - @extend .menu-item-base; + @extend %menu-item-base; + display: grid; grid-template-columns: var(--sp-xxxl) 1fr auto; gap: var(--sp-s); @@ -190,7 +207,8 @@ } .icon-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: var(--sp-xxl); height: var(--sp-xxl); margin-right: var(--sp-m); @@ -199,7 +217,8 @@ } .add-icon { - @extend .button-icon; + @extend %button-icon; + width: var(--sp-xxl); height: var(--sp-xxl); stroke: var(--sidebar-action-icon-color); @@ -211,12 +230,14 @@ } .tick-icon { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } .options-dropdown { - @extend .menu-dropdown; + @extend %menu-dropdown; + right: var(--sp-xxs); top: deprecated.$s-52; max-height: $sz-480; @@ -227,7 +248,8 @@ } .team-options-item { - @extend .menu-item-base; + @extend %menu-item-base; + height: $sz-40; } @@ -241,7 +263,6 @@ .sidebar-nav { margin: 0; user-select: none; - overflow: none; } .pinned-projects { @@ -288,7 +309,8 @@ } .element-title { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + width: deprecated.$s-256; color: var(--color-foreground-primary); font-size: deprecated.$fs-14; @@ -304,7 +326,8 @@ } .pin-icon { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); margin: 0 var(--sp-m); } @@ -328,6 +351,7 @@ .input-text { @include t.use-typography("title-small"); + height: $sz-40; width: 100%; padding: $sz-6 var(--sp-m); @@ -350,8 +374,9 @@ } .search-btn { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + position: absolute; right: 0; height: var(--sp-xxl); @@ -361,8 +386,10 @@ .search-icon, .clear-search-btn { - @extend .button-icon; + @extend %button-icon; + --sidebar-search-foreground-color: var(--search-bar-icon-foreground-color); + stroke: var(--sidebar-search-foreground-color); } @@ -382,7 +409,8 @@ } .profile { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: grid; grid-template-columns: auto 1fr; gap: var(--sp-s); @@ -392,7 +420,8 @@ .profile-fullname { @include t.use-typography("title-small"); - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + align-self: center; max-width: var(--sp-l) 0; color: var(--profile-foreground-color); @@ -405,16 +434,19 @@ } .profile-dropdown { - @extend .menu-dropdown; + @extend %menu-dropdown; + inset-inline-start: var(--sp-s); inset-block-end: px2rem(72); // 72 is the height of the profile button min-width: calc(100% - var(--sp-s)); + // TODO ADD animation fadeInUp } .profile-dropdown-item { - @extend .menu-item-base; + @extend %menu-item-base; @include t.use-typography("body-medium"); + block-size: $sz-40; margin-block-end: var(--sp-xs); padding: var(--sp-s); @@ -430,22 +462,29 @@ } .profile-dropdown-item .open-arrow { - @include deprecated.flexCenter; + @include deprecated.flex-center; } .profile-dropdown-item .open-arrow svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } .sub-menu { - @extend .menu-dropdown; + @extend %menu-dropdown; + inset-inline-start: calc(deprecated.$s-292 + var(--sp-s)); min-width: deprecated.$s-192; } +// Each submenu is positioned via its bottom edge; the visual top lands +// at `inset-block-end + submenu_height`. Help & Learning (3 items, +// taller) needs the same inset as Community (2 items, shorter) so that +// its top edge sits one row above Community — aligning with the +// "Help & Learning" trigger row in the parent menu. .sub-menu.help-learning { - inset-block-end: deprecated.$s-72; + inset-block-end: deprecated.$s-120; } .sub-menu.community { @@ -457,8 +496,9 @@ } .submenu-item { - @extend .menu-item-base; + @extend %menu-item-base; @include t.use-typography("body-medium"); + block-size: $sz-40; margin-block-end: var(--sp-xs); padding-block: var(--sp-s); @@ -477,7 +517,8 @@ .menu-version { @include t.use-typography("code-font"); - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + color: var(--color-foreground-secondary); margin-inline-start: var(--sp-s); text-transform: uppercase; @@ -495,26 +536,30 @@ } .exit-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } .add-org-icon { - @extend .button-icon; + @extend %button-icon; + width: var(--sp-l); height: var(--sp-l); stroke: var(--sidebar-action-icon-color); } .arrow-up-right-icon { - @extend .button-icon; + @extend %button-icon; + width: var(--sp-m); height: var(--sp-m); stroke: var(--sidebar-action-icon-color); } .upgrade-plan-section { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: flex; justify-content: space-between; border: $b-1 solid var(--color-background-quaternary); @@ -527,6 +572,7 @@ .penpot-free { @include t.use-typography("body-medium"); + display: flex; flex-direction: column; text-align: left; @@ -538,35 +584,36 @@ .power-up { @include t.use-typography("body-small"); + color: var(--color-accent-tertiary); } -.nitrate-orgs-container { +.orgs-container { align-items: center; display: flex; height: calc(2 * var(--sp-xxxl)); max-height: calc(2 * var(--sp-xxxl)); justify-content: space-between; - padding: var(--sp-xs) var(--sp-l) var(--sp-xs) var(--sp-s); - // border-block-end: $b-1 solid var(--color-background-quaternary); + padding: 0 var(--sp-xl); } -.nitrate-selected-org { +.selected-org { @include t.use-typography("body-medium"); + color: var(--color-foreground-primary); width: 100%; - margin: var(--sp-xs) 0 var(--sp-xs) var(--sp-l); + padding-inline-start: var(--sp-s); display: flex; align-items: center; gap: var(--sp-s); } -.nitrate-create-org { +.create-org { margin-inline-start: auto; text-transform: uppercase; } -.nitrate-penpot-icon { +.org-penpot-icon { display: flex; justify-content: center; align-items: center; @@ -582,7 +629,7 @@ } } -.nitrate-org-icon { +.org-icon { display: flex; justify-content: center; align-items: center; @@ -604,12 +651,64 @@ } .current-org { - @include deprecated.buttonStyle; + @include deprecated.button-style; + + text-transform: none; display: grid; align-items: center; - grid-template-columns: 1fr auto; + grid-template-columns: 1fr auto auto; gap: var(--sp-s); height: 100%; width: 100%; - padding: 0 var(--sp-m); +} + +.current-org-no-options { + gap: 0; +} + +.current-org .arrow-icon { + margin-inline-end: var(--sp-xs); +} + +.org-options { + display: flex; + justify-content: center; + align-items: center; + max-width: var(--sp-xxl); + min-width: $sz-28; + height: 100%; +} + +.org-switch-content { + display: grid; + grid-template-columns: 1fr auto; + align-items: center; + height: $sz-48; + width: 100%; +} + +.org-options-btn { + --icon-stroke: var(--icon-foreground); + + display: flex; + justify-content: center; + align-items: center; + width: $sz-32; + height: $sz-32; + + &:hover { + --icon-stroke: var(--color-accent-primary); + } +} + +.org-menu-icon { + @extend %button-icon; + + stroke: var(--icon-stroke); +} + +.org-menu-icon-open { + @extend %button-icon; + + stroke: var(--color-accent-primary); } diff --git a/frontend/src/app/main/ui/dashboard/subscription.cljs b/frontend/src/app/main/ui/dashboard/subscription.cljs index a07dbe0649..86dcba36fd 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.cljs +++ b/frontend/src/app/main/ui/dashboard/subscription.cljs @@ -120,6 +120,8 @@ (mf/defc nitrate-sidebar* [{:keys [profile teams]}] (let [nitrate? (dnt/is-valid-license? profile) + nitrate-license (:subscription profile) + subscription-type (if nitrate? (:type nitrate-license) (get-subscription-type (-> profile :props :subscription))) orgs (mf/with-memo [teams] (let [orgs (->> teams vals @@ -133,8 +135,14 @@ handle-click (mf/use-fn + (mf/deps nitrate-license subscription-type) (fn [] - (st/emit! (dnt/show-nitrate-popup :nitrate-form))))] + (if (= subscription-type "unlimited") + (st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license :show-contact-sales-option true})) + (st/emit! (dnt/show-nitrate-popup :nitrate-form))))) + + handle-go-to-cc + (mf/use-fn dnt/go-to-nitrate-cc-create-org)] ;; TODO add translations for this texts when we have the definitive ones (if (and nitrate? no-orgs-created?) @@ -147,7 +155,7 @@ [:> button* {:variant "primary" :type "button" :class (stl/css :nitrate-bottom-button) - :on-click dnt/go-to-nitrate-cc} "CREATE ORGANIZATION"]]] + :on-click handle-go-to-cc} "CREATE ORGANIZATION"]]] ;; Banner for users without nitrate license (when (not nitrate?) @@ -159,7 +167,30 @@ [:> button* {:variant "primary" :type "button" :class (stl/css :nitrate-bottom-button) - :on-click handle-click} "UPGRADE TO NITRATE"]]])))) + :on-click handle-click} (if (:subscription profile) + "UPGRADE TO NITRATE" + "Try 14 days for free")]]])))) + +(mf/defc nitrate-current-plan* + [{:keys [profile]}] + (let [nitrate? (dnt/is-valid-license? profile) + nitrate-license (:subscription profile) + subscription (-> profile :props :subscription) + subscription-type (if nitrate? (:type nitrate-license) (get-subscription-type subscription)) + subscription-is-trial (= "trialing" (:status (if nitrate? nitrate-license subscription)))] + [:div {:class (stl/css :nitrate-current-plan)} + [:div {:class (stl/css :nitrate-current-plan-label)} + (tr "subscription.current-plan.title")] + [:div {:class (stl/css :nitrate-current-plan-text)} + (case subscription-type + "professional" (tr "subscription.current-plan.professional") + "unlimited" (if subscription-is-trial + (tr "subscription.current-plan.unlimited-trial") + (tr "subscription.current-plan.unlimited")) + "nitrate" (if subscription-is-trial + (tr "subscription.current-plan.nitrate-trial") + (tr "subscription.current-plan.nitrate")) + "enterprise" (tr "subscription.current-plan.enterprise"))]])) (mf/defc team* [{:keys [is-owner team]}] diff --git a/frontend/src/app/main/ui/dashboard/subscription.scss b/frontend/src/app/main/ui/dashboard/subscription.scss index e9439558f1..b37cff4658 100644 --- a/frontend/src/app/main/ui/dashboard/subscription.scss +++ b/frontend/src/app/main/ui/dashboard/subscription.scss @@ -26,7 +26,8 @@ } .cta-top-section { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: grid; color: var(--color-foreground-secondary); grid-template-columns: 1fr auto; @@ -43,13 +44,15 @@ } .icon-dropdown { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; width: var(--sp-l); } .icon-dropdown svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); transform: rotate(90deg); } @@ -67,7 +70,8 @@ .cta-bottom-section .content { @include t.use-typography("body-medium"); - @include deprecated.buttonStyle; + @include deprecated.button-style; + color: var(--color-foreground-secondary); display: inline-block; text-align: left; @@ -88,11 +92,13 @@ .cta-title { @include t.use-typography("body-small"); + margin-block-end: var(--sp-xs); } .highlighted .cta-title { @include t.use-typography("body-medium"); + margin-block-end: 0; } @@ -102,17 +108,20 @@ .highlighted .cta-text { @include t.use-typography("body-large"); + color: var(--color-foreground-primary); } .cta-bottom-section .content a { @include t.use-typography("body-medium"); + color: var(--color-accent-tertiary); margin-inline-start: var(--sp-xs); } .cta-link { - @include deprecated.buttonStyle; + @include deprecated.button-style; + align-self: end; margin-inline-start: var(--sp-xs); } @@ -127,17 +136,20 @@ .team-label { @include t.use-typography("headline-small"); + color: var(--title-foreground-color); } .team-text { @include t.use-typography("title-medium"); + color: var(--color-foreground-primary); } .manage-subscription-link { - @include deprecated.buttonStyle; + @include deprecated.button-style; @include t.use-typography("body-medium"); + color: var(--color-accent-tertiary); display: flex; margin-block-start: -8px; @@ -161,7 +173,8 @@ } .menu-item { - @extend .menu-item-base; + @extend %menu-item-base; + cursor: pointer; &:hover { @@ -197,6 +210,7 @@ .cta-message { @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); line-height: 1; @@ -210,7 +224,7 @@ display: flex; border-radius: var(--sp-s); flex-direction: column; - margin: var(--sp-m); + margin: var(--sp-m) var(--sp-m) 0; background: var(--color-background-quaternary); border: $b-1 solid var(--color-accent-primary-muted); padding: var(--sp-l); @@ -218,11 +232,13 @@ .nitrate-title { @include t.use-typography("body-large"); + color: var(--color-foreground-primary); } .nitrate-info { @include t.use-typography("body-medium"); + color: var(--color-foreground-secondary); margin-block: var(--sp-s) var(--sp-xxl); } @@ -235,3 +251,24 @@ .nitrate-bottom-button { width: fit-content; } + +.nitrate-current-plan { + border-radius: var(--sp-s); + margin: var(--sp-m); + background: var(--color-background-tertiary); + border: $b-1 solid var(--color-background-quaternary); + padding: var(--sp-m) var(--sp-l); +} + +.nitrate-current-plan-label { + @include t.use-typography("body-small"); + + padding-block-end: var(--sp-xs); + color: var(--color-foreground-secondary); +} + +.nitrate-current-plan-text { + @include t.use-typography("body-medium"); + + color: var(--color-foreground-primary); +} diff --git a/frontend/src/app/main/ui/dashboard/team.cljs b/frontend/src/app/main/ui/dashboard/team.cljs index e492b9a20d..dd3bbfb2de 100644 --- a/frontend/src/app/main/ui/dashboard/team.cljs +++ b/frontend/src/app/main/ui/dashboard/team.cljs @@ -14,6 +14,7 @@ [app.main.data.common :as dcm] [app.main.data.event :as ev] [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] [app.main.data.notifications :as ntf] [app.main.data.team :as dtm] [app.main.refs :as refs] @@ -21,6 +22,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.forms :as fm] + [app.main.ui.components.org-avatar :refer [org-avatar*]] [app.main.ui.dashboard.change-owner] [app.main.ui.dashboard.subscription :refer [members-cta* show-subscription-members-banner? @@ -28,12 +30,15 @@ [app.main.ui.dashboard.team-form] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.combobox :refer [combobox*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.badge :refer [badge-notification]] [app.main.ui.notifications.context-notification :refer [context-notification]] [app.util.dom :as dom] + [app.util.forms :as uforms] [app.util.i18n :as i18n :refer [tr]] + [app.util.timers :as tm] [beicon.v2.core :as rx] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -44,6 +49,9 @@ (def ^:private menu-icon (deprecated-icon/icon-xref :menu (stl/css :menu-icon))) +(def ^:private org-menu-icon + (deprecated-icon/icon-xref :menu (stl/css :org-menu-icon))) + (def ^:private warning-icon (deprecated-icon/icon-xref :msg-warning (stl/css :warning-icon))) @@ -539,7 +547,7 @@ (tr "dashboard.your-penpot") (:name team))))) - (mf/with-effect [] + (mf/with-effect [team] (st/emit! (dtm/fetch-members))) [:* @@ -606,7 +614,10 @@ (mf/defc invitation-actions* {::mf/private true} [{:keys [invitation team-id]}] - (let [email (:email invitation) + (let [email (:email invitation) + copied* (mf/use-state false) + copied? (deref copied*) + on-error (mf/use-fn (mf/deps email) @@ -632,6 +643,8 @@ on-copy-success (mf/use-fn (fn [] + (reset! copied* true) + (tm/schedule 1000 #(reset! copied* false)) (st/emit! (ntf/success (tr "notifications.invitation-link-copied")) (modal/hide)))) @@ -649,7 +662,7 @@ [:> icon-button* {:variant "ghost" :aria-label (tr "labels.copy-invitation-link") :on-click on-copy - :icon "clipboard"}])) + :icon (if copied? "tick" "clipboard")}])) (mf/defc invitation-row* {::mf/wrap [mf/memo] @@ -785,6 +798,83 @@ (tr "labels.continue") (tr "labels.resend"))]]]]]) + +(def schema:organization-form [:map {:title "SelectOrgForm"} + [:selected-id ::sm/uuid]]) + +(mf/defc render-org-combobox-avatar* + [{:keys [avatar]}] + [:> org-avatar* {:org (:organization avatar) + :size (:size avatar)}]) + +(mf/defc select-organization-modal + {::mf/register modal/components + ::mf/register-as :select-organization-modal} + [{:keys [organizations current-organization-id on-confirm title-key text-key choose-key placeholder-key accept-key cancel-key]}] + (let [valid-organizations (mf/with-memo [organizations] + (remove #(= (:id %) current-organization-id) organizations)) + options (mf/with-memo [valid-organizations] + (mapv (fn [organization] + {:id (str (:id organization)) + :label (:name organization) + :avatar {:render-fn render-org-combobox-avatar* + :organization organization + :size "xl"}}) + valid-organizations)) + + form (fm/use-form :schema schema:organization-form :initial {}) + + on-change + (mf/use-fn + (mf/deps form) + (fn [id] + (uforms/on-input-change form :selected-id id))) + + on-confirm' + (mf/use-fn + (mf/deps on-confirm form) + (fn [] + (on-confirm (dm/get-in @form [:clean-data :selected-id]))))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-select-org-container :modal-container)} + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-select-org-title)} + (tr title-key)] + + [:button {:class (stl/css :modal-close-btn) + :on-click modal/hide!} deprecated-icon/close]] + + (when text-key + [:div {:class (stl/css :modal-content :modal-select-org-text)} (tr text-key)]) + + [:div + [:div {:class (stl/css :modal-select-org-content)} + (tr choose-key)] + [:> combobox* {:id "selected-id" + :class (stl/css :team-member) + :options options + :select-only true + :default-selected (or (some-> (get-in @form [:data :selected-id]) str) "") + :placeholder (tr placeholder-key) + :on-change on-change}]] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons :modal-invitation-action-buttons)} + + [:> button* + {:class (stl/css :cancel-button) + :variant "secondary" + :type "button" + :on-click modal/hide!} + (tr cancel-key)] + [:> button* + {:class (stl/css :accept-btn) + :variant "primary" + :type "button" + :disabled (not (:valid @form)) + :on-click on-confirm'} + (tr accept-key)]]]]])) + (mf/defc invitation-section* {::mf/private true} [{:keys [team]}] @@ -979,7 +1069,7 @@ (tr "dashboard.your-penpot") (:name team))))) - (mf/with-effect [] + (mf/with-effect [(:id team) (:members team)] (st/emit! (dtm/fetch-invitations))) [:* @@ -1264,7 +1354,8 @@ (mf/defc team-settings-page* [{:keys [team]}] - (let [finput (mf/use-ref) + (let [nitrate? (contains? cfg/flags :nitrate) + finput (mf/use-ref) members (get team :members) stats (get team :stats) @@ -1275,12 +1366,99 @@ can-edit (or (:is-owner permissions) (:is-admin permissions)) + organizations (mf/deref refs/teams) + organizations (mf/with-memo [organizations] + (->> (vals organizations) + (filter :is-default) + (filter :organization-id) + (map dtm/team->organization))) + + can-change-organization? (mf/with-memo [organizations] + (> (count organizations) 1)) + + can-add-to-organization? (mf/with-memo [organizations] + (and (pos? (count organizations)) + (not (:is-default team)))) + + show-org-options-menu* + (mf/use-state false) + + show-org-options-menu? + (deref show-org-options-menu*) + + on-show-options-click + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (swap! show-org-options-menu* not))) + + close-org-options-menu + (mf/use-fn #(reset! show-org-options-menu* false)) + on-image-click (mf/use-fn #(dom/click (mf/ref-val finput))) on-file-selected (fn [file] - (st/emit! (dtm/update-team-photo file)))] + (st/emit! (dtm/update-team-photo file))) + + remove-team-from-org-fn + (mf/use-fn + (mf/deps team) + (fn [] + (st/emit! (dnt/remove-team-from-org {:team-id (:id team) + :organization-id (:organization-id team) + :organization-name (:organization-name team)})))) + + on-remove-team-from-org + (mf/use-fn + (mf/deps team) + (fn [] + (let [params {:type :confirm + :title (tr "modals.remove-team-org.title") + :message (tr "modals.remove-team-org.text" (:name team) (:organization-name team)) + :hint (tr "modals.remove-team-org.info") + :hint-level :default + :accept-label (tr "modals.remove-team-org.accept") + :on-accept remove-team-from-org-fn + :accept-style :danger}] + (st/emit! (modal/show params))))) + + on-add-team-to-org-confirm + (mf/use-fn + (mf/deps team) + (fn [organization-id] + (let [organization (d/seek #(= organization-id (:id %)) organizations)] + (when organization + (st/emit! (dnt/add-team-to-org {:team-id (:id team) + :organization-id organization-id})))))) + + on-add-team-to-org + (mf/use-fn + (mf/deps organizations on-add-team-to-org-confirm) + (fn [] + (st/emit! (modal/show :select-organization-modal {:organizations organizations + :current-organization-id (:organization-id team) + :on-confirm on-add-team-to-org-confirm + :title-key "dashboard.select-org-modal.title" + :choose-key "dashboard.select-org-modal.choose" + :placeholder-key "dashboard.select-org-modal.select" + :accept-key "dashboard.select-org-modal.accept" + :cancel-key "labels.cancel"})))) + + on-change-team-org + (mf/use-fn + (mf/deps organizations on-add-team-to-org-confirm) + (fn [] + (st/emit! (modal/show :select-organization-modal {:organizations organizations + :current-organization-id (:organization-id team) + :on-confirm on-add-team-to-org-confirm + :title-key "dashboard.change-org-modal.title" + :text-key "dashboard.change-org-modal.text" + :choose-key "dashboard.change-org-modal.choose" + :placeholder-key "dashboard.change-org-modal.select" + :accept-key "dashboard.change-org-modal.accept" + :cancel-key "labels.cancel"}))))] (mf/with-effect [team] (dom/set-html-title (tr "title.team-settings" @@ -1314,6 +1492,44 @@ [:div {:class (stl/css :block-text)} (:name team)]] + (when nitrate? + [:div {:class (stl/css :block)} + [:div {:class (stl/css :block-label)} + (tr "dashboard.team-organization")] + (if (:organization-id team) + [:div {:class (stl/css :block-content)} + [:div {:class (stl/css :org-block-content)} + [:> org-avatar* {:org (dtm/team->organization team) :size "xxxl"}] + [:span {:class (stl/css :block-text)} + (:organization-name team)] + + (when (and (:is-owner permissions) (not (:is-default team))) + [:* + [:> button* {:variant "ghost" + :type "button" + :class (stl/css-case :org-options-btn (not show-org-options-menu?) :org-options-btn-open show-org-options-menu?) + :on-click on-show-options-click} + org-menu-icon + + [:& dropdown {:show show-org-options-menu? :on-close close-org-options-menu :dropdown-id "org-options"} + [:ul {:class (stl/css :org-dropdown) + :role "listbox"} + (when can-change-organization? + [:li {:on-click on-change-team-org + :class (stl/css :org-dropdown-item)} + (tr "dashboard.team-organization.change")]) + [:li {:on-click on-remove-team-from-org + :class (stl/css :org-dropdown-item)} + (tr "dashboard.team-organization.remove")]]]]])]] + [:* + [:div {:class (stl/css :block-content)} + [:span {:class (stl/css :block-text)} + (tr "dashboard.team-organization.none")]] + (when can-add-to-organization? + [:div {:class (stl/css :block-content)} + [:span {:class (stl/css :block-text)} + [:a {:on-click on-add-team-to-org} (tr "dashboard.team-organization.add")]]])])]) + [:div {:class (stl/css :block)} [:div {:class (stl/css :block-label)} (tr "dashboard.team-members")] diff --git a/frontend/src/app/main/ui/dashboard/team.scss b/frontend/src/app/main/ui/dashboard/team.scss index 259fdeb565..d68bbd26c9 100644 --- a/frontend/src/app/main/ui/dashboard/team.scss +++ b/frontend/src/app/main/ui/dashboard/team.scss @@ -45,11 +45,13 @@ .block-label { @include t.use-typography("headline-small"); + color: var(--color-foreground-secondary); } .block-text { color: var(--color-foreground-primary); + text-wrap: nowrap; } .block-content { @@ -82,6 +84,7 @@ .team-icon { --update-button-opacity: 0; + position: relative; height: $sz-120; width: $sz-120; @@ -162,6 +165,7 @@ .table-header { @include t.use-typography("headline-small"); + display: grid; align-items: center; grid-template-columns: 43% 1fr px2rem(108) var(--sp-m); @@ -245,12 +249,13 @@ .member-name, .member-email { - @include textEllipsis; + @include text-ellipsis; @include t.use-typography("body-large"); } .member-email { @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); } @@ -262,6 +267,7 @@ // ROL INFO .rol-selector { @include t.use-typography("body-medium"); + position: relative; display: grid; grid-template-columns: 1fr auto; @@ -303,6 +309,7 @@ .rol-dropdown-item { @include t.use-typography("body-small"); + display: flex; align-items: center; justify-content: space-between; @@ -311,6 +318,7 @@ padding: px2rem(6); border-radius: $br-8; cursor: pointer; + &:hover { background-color: var(--color-background-quaternary); } @@ -337,7 +345,8 @@ .input-checkbox { // TODO: remove this extended class. - @extend .input-checkbox; + @extend %input-checkbox; + cursor: pointer; } @@ -363,6 +372,7 @@ .action-dropdown-item { @include t.use-typography("body-small"); + display: flex; align-items: center; justify-content: space-between; @@ -371,6 +381,7 @@ padding: px2rem(6); border-radius: $br-8; cursor: pointer; + &:hover { background-color: var(--color-background-quaternary); } @@ -399,6 +410,7 @@ .invitations-actions { @include t.use-typography("body-medium"); + display: flex; justify-content: end; align-items: center; @@ -432,7 +444,8 @@ .btn-empty-invitations { // TODO: Remove this extend add DS component - @extend .button-primary; + @extend %button-primary; + margin-block-start: var(--sp-l); padding-inline: var(--sp-m); } @@ -451,8 +464,9 @@ } .field-email { - @include textEllipsis; + @include text-ellipsis; @include t.use-typography("body-large"); + display: flex; gap: var(--sp-l); align-items: center; @@ -499,10 +513,10 @@ .webhooks-hero { @include t.use-typography("body-medium"); + display: grid; grid-template-rows: auto 1fr auto; gap: var(--sp-xxxl); - margin-top: var(--sp-xxxl); margin: 0; padding: var(--sp-xxxl); padding: 0; @@ -511,19 +525,22 @@ .hero-title { @include t.use-typography("title-large"); + color: var(--color-foreground-primary); } .hero-desc { @include t.use-typography("body-large"); + color: var(--color-foreground-secondary); margin-bottom: 0; max-width: $sz-512; } .hero-btn { - //TODO: Remove this extended class using a DS component - @extend .button-primary; + // TODO: Remove this extended class using a DS component + @extend %button-primary; + height: $sz-32; max-width: $sz-512; } @@ -572,6 +589,7 @@ .webhook-dropdown-item { @include t.use-typography("body-small"); + display: flex; align-items: center; justify-content: space-between; @@ -580,6 +598,7 @@ padding: px2rem(6); border-radius: $br-8; cursor: pointer; + &:hover { background-color: var(--color-background-quaternary); } @@ -611,10 +630,7 @@ // INVITE MEMBERS MODAL .modal-team-container { - position: relative; - padding: var(--sp-xxxl); border-radius: $br-8; - background-color: var(--color-background-primary); border: $b-2 solid var(--color-background-quaternary); min-width: $sz-364; min-height: $sz-192; @@ -643,6 +659,7 @@ .modal-title { @include t.use-typography("headline-medium"); + height: $sz-32; color: var(--color-foreground-primary); } @@ -669,12 +686,14 @@ .invite-team-member-text { @include t.use-typography("body-large"); + margin: 0 0 var(--sp-l) 0; color: var(--color-foreground-primary); } .role-title { @include t.use-typography("body-large"); + margin: 0; color: var(--color-foreground-primary); } @@ -691,7 +710,7 @@ .accept-btn { // TODO: remove this extend class creating a modal component - @extend .modal-accept-btn; + @extend %modal-accept-btn; } // WEBHOOKS MODAL @@ -727,16 +746,18 @@ .modal-title { @include t.use-typography("title-small"); + color: var(--color-foreground-primary); } .modal-close-btn { // TODO remove extended class creating a modal component - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { @include t.use-typography("body-small"); + display: flex; flex-direction: column; gap: var(--sp-xxl); @@ -751,6 +772,7 @@ .select-title { @include t.use-typography("body-small"); + color: var(--color-foreground-primary); } @@ -764,28 +786,31 @@ // TODO: Remove this extended classes creating a modal component .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; button { - @extend .modal-accept-btn; + @extend %modal-accept-btn; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } } // TODO: Remove this extended class using input component .email-input { @include t.use-typography("body-small"); - @extend .input-base; + @extend %input-base; + height: auto; } + // FIXME: This does not conform to our CSS Guidelines. Need to unnest and to use // custom properties to handle state changes. .input-wrapper { display: flex; align-items: center; + @include t.use-typography("body-large"); label { @@ -801,6 +826,7 @@ border-color: var(--color-accent-primary); } } + &:hover { span { border-color: var(--color-accent-primary-muted); @@ -809,13 +835,17 @@ } span { - @extend .checkbox-icon; + @extend %checkbox-icon; @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); } + input { margin: 0; + @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); } } @@ -840,3 +870,117 @@ margin-block-start: var(--sp-xxxl); gap: var(--sp-s); } + +// SELECT ORGANIZATION MODAL + +.modal-select-org-container { + display: flex; + flex-direction: column; + width: $sz-512; +} + +.modal-select-org-content { + @include t.use-typography("body-large"); + + color: var(--color-foreground-secondary); + overflow: auto; + margin-block-end: var(--sp-s); +} + +.modal-select-org-title { + @include t.use-typography("title-medium"); + + color: var(--color-foreground-primary); + text-transform: uppercase; + height: $sz-40; +} + +.modal-select-org-text { + @include t.use-typography("body-large"); + + color: var(--color-foreground-secondary); +} + +// ORGANIZATIONS SETTINGS + +.org-block-content { + display: grid; + grid-template-columns: var(--sp-xxxl) 1fr var(--sp-xxxl); + align-items: center; + gap: var(--sp-m); + width: max-content; +} + +.org-options-btn { + padding: 0; + justify-content: center; + + --stroke-color: var(--color-foreground-primary); + + &:hover { + --stroke-color: var(--color-accent-primary); + } +} + +.org-options-btn-open { + padding: 0; + justify-content: center; + + --stroke-color: var(--color-accent-primary); + + background-color: var(--color-background-tertiary); + position: relative; +} + +.org-menu-icon { + display: flex; + justify-content: center; + align-items: center; + height: $sz-16; + width: $sz-16; + color: transparent; + fill: none; + stroke-width: $b-1; + stroke: var(--stroke-color); +} + +.org-dropdown { + box-shadow: var(--el-shadow-dark); + display: flex; + flex-direction: column; + gap: var(--sp-xs); + position: absolute; + padding: var(--sp-xs); + border-radius: $br-8; + z-index: var(--z-index-dropdown); + color: var(--color-foreground-primary); + background-color: var(--color-background-tertiary); + border: $b-2 solid var(--color-background-quaternary); + margin: 0; + top: var(--sp-xxxl); + width: fit-content; + min-width: $sz-160; +} + +.org-dropdown-item { + @include t.use-typography("body-small"); + + display: flex; + align-items: center; + justify-content: space-between; + height: $sz-28; + width: 100%; + padding: px2rem(6); + border-radius: $br-8; + cursor: pointer; + text-transform: none; + white-space: nowrap; + + &:hover { + background-color: var(--color-background-quaternary); + } +} + +a { + color: var(--modal-link-foreground-color); +} diff --git a/frontend/src/app/main/ui/dashboard/team_form.cljs b/frontend/src/app/main/ui/dashboard/team_form.cljs index a2ef4d1490..9a6240ce8d 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.cljs +++ b/frontend/src/app/main/ui/dashboard/team_form.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.dashboard.team-form (:require-macros [app.main.style :as stl]) (:require + [app.common.schema :as sm] [app.common.types.team :as ctt] [app.main.data.common :as dcm] [app.main.data.event :as ev] @@ -24,7 +25,8 @@ (def ^:private schema:team-form [:map {:title "TeamForm"} - [:name ctt/schema:team-name]]) + [:name ctt/schema:team-name] + [:organization-id {:optional true} [:maybe ::sm/uuid]]]) (defn- on-create-success [_form response] @@ -50,7 +52,9 @@ [form] (let [mdata {:on-success (partial on-create-success form) :on-error (partial on-error form)} - params {:name (get-in @form [:clean-data :name])}] + data (:clean-data @form) + params (cond-> {:name (:name data)} + (:organization-id data) (assoc :organization-id (:organization-id data)))] (st/emit! (-> (dtm/create-team (with-meta params mdata)) (with-meta {::ev/origin :dashboard}))))) @@ -58,7 +62,8 @@ [form] (let [mdata {:on-success (partial on-update-success form) :on-error (partial on-error form)} - team (get @form :clean-data)] + data (:clean-data @form) + team (select-keys data [:id :name])] ;; Only send name and id for updates (st/emit! (dtm/update-team (with-meta team mdata)) (modal/hide)))) @@ -72,10 +77,16 @@ (mf/defc team-form-modal {::mf/register modal/components ::mf/register-as :team-form} - [{:keys [team] :as props}] - (let [initial (mf/use-memo (fn [] - (or (some-> team (select-keys [:name :id])) - {}))) + [{:keys [team organization-id] :as props}] + (let [initial (mf/use-memo + (mf/deps team organization-id) + (fn [] + (if team + ;; For existing teams, only include name and id (no organization changes) + (select-keys team [:name :id]) + ;; For new teams, include organization-id if provided + (cond-> {} + organization-id (assoc :organization-id organization-id))))) form (fm/use-form :schema schema:team-form :initial initial) handle-keydown diff --git a/frontend/src/app/main/ui/dashboard/team_form.scss b/frontend/src/app/main/ui/dashboard/team_form.scss index eba9c361d0..592ca7a94d 100644 --- a/frontend/src/app/main/ui/dashboard/team_form.scss +++ b/frontend/src/app/main/ui/dashboard/team_form.scss @@ -7,11 +7,11 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; } .modal-header { @@ -19,12 +19,13 @@ } .modal-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { @@ -36,12 +37,15 @@ } .group-name-input { - @extend .input-element-label; - @include deprecated.bodySmallTypography; + @extend %input-element-label; + @include deprecated.body-small-typography; + margin-bottom: deprecated.$s-8; + label { - @include deprecated.flexColumn; - @include deprecated.bodySmallTypography; + @include deprecated.flex-column; + @include deprecated.body-small-typography; + align-items: flex-start; width: 100%; border: none; @@ -49,21 +53,23 @@ height: 100%; input { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; } } } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } + .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } diff --git a/frontend/src/app/main/ui/dashboard/templates.scss b/frontend/src/app/main/ui/dashboard/templates.scss index f3323c58f2..6b4b44600d 100644 --- a/frontend/src/app/main/ui/dashboard/templates.scss +++ b/frontend/src/app/main/ui/dashboard/templates.scss @@ -20,11 +20,10 @@ flex-direction: column; height: px2rem(244); justify-content: flex-end; - margin-inline-start: px2rem(6); - margin-inline-end: px2rem(6); - margin-block-end: px2rem(6); + margin-inline: var(--sp-s); + margin-block-end: var(--sp-xs); position: absolute; - transition: bottom 300ms; + transition: inset-block-end 300ms; width: calc(100% - $sz-12); pointer-events: none; z-index: var(--z-index-panels); @@ -32,11 +31,13 @@ &.collapsed { inset-block-end: calc(-1 * px2rem(228)); background-color: transparent; - transition: bottom 300ms; + transition: inset-block-end 300ms; + .title-btn { border-end-end-radius: $br-8; border-end-start-radius: $br-8; } + .content, .content-description { visibility: hidden; @@ -69,26 +70,24 @@ .title-text { @include t.use-typography("body-large"); + display: inline-block; vertical-align: middle; - margin-inline-start: var(--sp-m); - margin-inline-end: var(--sp-s); + margin-inline: var(--sp-m) var(--sp-s); color: var(--color-foreground-primary); } .title-icon-container { display: inline-block; vertical-align: middle; - margin-inline-start: auto; - margin-inline-end: var(--sp-s); + margin-inline: auto var(--sp-s); color: var(--color-foreground-primary); } .title-icon { display: inline-block; vertical-align: middle; - margin-inline-start: auto; - margin-inline-end: var(--sp-s); + margin-inline: auto var(--sp-s); transform: rotate(90deg); } @@ -130,6 +129,7 @@ &:hover { border: $b-2 solid var(--color-background-tertiary); background-color: var(--color-accent-primary); + .arrow-icon { stroke: var(--color-background-tertiary); } @@ -149,9 +149,9 @@ .content-description { @include t.use-typography("body-medium"); + color: var(--color-foreground-primary); - margin-block-end: calc(-1 * var(--sp-s)); - margin-block-start: var(--sp-l); + margin-block: var(--sp-l) calc(-1 * var(--sp-s)); margin-inline-start: var(--sp-l); visibility: visible; } @@ -182,6 +182,7 @@ .template-card { @include t.use-typography("body-large"); + display: inline-block; width: px2rem(256); cursor: pointer; @@ -189,12 +190,15 @@ padding: 0 var(--sp-xs) var(--sp-s) var(--sp-xs); border-radius: $br-8; border: $b-2 solid transparent; + &:hover { text-decoration: none; border-color: var(--color-accent-primary); + .download-icon { stroke: var(--color-accent-primary); } + .card-text { color: var(--color-accent-primary); } @@ -205,7 +209,7 @@ width: 100%; height: px2rem(136); margin-block-end: var(--sp-s); - border-radius: px2rem(5); + border-radius: $br-6; display: flex; justify-content: center; flex-direction: column; @@ -216,7 +220,7 @@ } .card-name { - padding: 0 px2rem(6); + padding: 0 var(--sp-s); display: flex; justify-content: space-between; height: $sz-24; @@ -225,6 +229,7 @@ .card-text { @include t.use-typography("body-large"); + white-space: nowrap; overflow: hidden; width: 90%; @@ -252,11 +257,13 @@ .template-link-title { @include t.use-typography("body-medium"); + color: var(--color-foreground-primary); } .template-link-text { @include t.use-typography("body-small"); + margin-block-start: var(--sp-s); color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/debug/icons_preview.scss b/frontend/src/app/main/ui/debug/icons_preview.scss index a8493ed42b..a5a83fdf11 100644 --- a/frontend/src/app/main/ui/debug/icons_preview.scss +++ b/frontend/src/app/main/ui/debug/icons_preview.scss @@ -9,7 +9,8 @@ } .title { - @include deprecated.bigTitleTipography; + @include deprecated.big-title-typography; + color: var(--color-foreground-primary); } @@ -28,10 +29,10 @@ row-gap: 0.5rem; grid-template-rows: var(--cell-size) 1fr; padding: 0.5rem; - color: var(--color-foreground-primary); - word-break: break-word; - @include deprecated.bodySmallTypography; + overflow-wrap: break-word; + + @include deprecated.body-small-typography; svg { width: var(--cell-size); diff --git a/frontend/src/app/main/ui/debug/playground.scss b/frontend/src/app/main/ui/debug/playground.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/src/app/main/ui/delete_shared.scss b/frontend/src/app/main/ui/delete_shared.scss index d0f08a50e8..c3487c3aa2 100644 --- a/frontend/src/app/main/ui/delete_shared.scss +++ b/frontend/src/app/main/ui/delete_shared.scss @@ -8,14 +8,16 @@ @use "ds/typography.scss" as t; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; + &.transparent { background-color: transparent; } } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + display: grid; gap: var(--sp-xxl); grid-template-rows: auto minmax(0, 1fr) auto; @@ -29,38 +31,42 @@ .modal-title { @include t.use-typography("headline-medium"); + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { @include t.use-typography("body-small"); + display: grid; gap: var(--sp-s); } .element-list { @include t.use-typography("body-large"); + color: var(--modal-text-foreground-color); overflow-y: auto; margin-block: 0; } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } @@ -72,6 +78,7 @@ .modal-subtitle, .modal-msg { @include t.use-typography("body-large"); + color: var(--modal-text-foreground-color); line-height: 1.5; } diff --git a/frontend/src/app/main/ui/ds/_borders.scss b/frontend/src/app/main/ui/ds/_borders.scss index 9fff3615ac..070f0b9abb 100644 --- a/frontend/src/app/main/ui/ds/_borders.scss +++ b/frontend/src/app/main/ui/ds/_borders.scss @@ -12,6 +12,5 @@ $br-6: px2rem(6); $br-8: px2rem(8); $br-12: px2rem(12); $br-circle: 50%; - $b-1: px2rem(1); $b-2: px2rem(2); diff --git a/frontend/src/app/main/ui/ds/_utils.scss b/frontend/src/app/main/ui/ds/_utils.scss index 248d43d002..a455cb0318 100644 --- a/frontend/src/app/main/ui/ds/_utils.scss +++ b/frontend/src/app/main/ui/ds/_utils.scss @@ -7,6 +7,7 @@ @use "sass:math"; @function px2rem($value) { - $remValue: math.div($value, 16) * 1rem; - @return $remValue; + $rem-value: math.div($value, 16) * 1rem; + + @return $rem-value; } diff --git a/frontend/src/app/main/ui/ds/buttons/_buttons.scss b/frontend/src/app/main/ui/ds/buttons/_buttons.scss index 433495c300..41c9474839 100644 --- a/frontend/src/app/main/ui/ds/buttons/_buttons.scss +++ b/frontend/src/app/main/ui/ds/buttons/_buttons.scss @@ -11,29 +11,21 @@ %base-button { --button-bg-color: initial; --button-fg-color: initial; - --button-hover-bg-color: initial; --button-hover-fg-color: initial; - --button-active-bg-color: initial; --button-active-fg-color: initial; - --button-disabled-bg-color: initial; --button-disabled-fg-color: initial; - --button-border-color: var(--button-bg-color); - --button-focus-inner-ring-color: initial; --button-focus-outer-ring-color: initial; - --button-width: initial; --button-height: #{$sz-32}; appearance: none; - width: var(--button-width); height: var(--button-height); - background: var(--button-bg-color); color: var(--button-fg-color); border: $b-1 solid var(--button-border-color); @@ -53,6 +45,7 @@ &:focus-visible { outline: var(--button-focus-inner-ring-color) solid #{px2rem(2)}; outline-offset: -#{px2rem(3)}; + --button-border-color: var(--button-focus-outer-ring-color); --button-fg-color: var(--button-focus-fg-color); } @@ -66,16 +59,12 @@ %base-button-primary { --button-bg-color: var(--color-accent-primary); --button-fg-color: var(--color-background-secondary); - --button-hover-bg-color: var(--color-accent-tertiary); --button-hover-fg-color: var(--color-background-secondary); - --button-active-bg-color: var(--color-accent-tertiary); --button-active-fg-color: var(--color-background-secondary); - --button-disabled-bg-color: var(--color-accent-primary-muted); --button-disabled-fg-color: var(--color-background-secondary); - --button-focus-bg-color: var(--color-accent-primary); --button-focus-fg-color: var(--color-background-secondary); --button-focus-inner-ring-color: var(--color-background-secondary); @@ -83,23 +72,19 @@ &:active, &[aria-pressed="true"] { - box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgba(0, 0, 0, 0.2); + box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgb(0 0 0 / 0.2); } } %base-button-secondary { --button-bg-color: var(--color-background-tertiary); --button-fg-color: var(--color-foreground-secondary); - --button-hover-bg-color: var(--color-background-tertiary); --button-hover-fg-color: var(--color-accent-primary); - --button-active-bg-color: var(--color-background-quaternary); --button-active-fg-color: var(--color-accent-primary); - --button-disabled-bg-color: transparent; --button-disabled-fg-color: var(--color-foreground-secondary); - --button-focus-bg-color: var(--color-background-tertiary); --button-focus-fg-color: var(--color-foreground-primary); --button-focus-inner-ring-color: var(--color-background-secondary); @@ -109,16 +94,12 @@ %base-button-ghost { --button-bg-color: transparent; --button-fg-color: var(--color-foreground-secondary); - --button-hover-bg-color: var(--color-background-tertiary); --button-hover-fg-color: var(--color-accent-primary); - --button-active-bg-color: var(--color-background-quaternary); --button-active-fg-color: var(--color-accent-primary); - --button-disabled-bg-color: transparent; --button-disabled-fg-color: var(--color-accent-primary-muted); - --button-focus-bg-color: transparent; --button-focus-fg-color: var(--color-foreground-secondary); --button-focus-inner-ring-color: transparent; @@ -128,16 +109,12 @@ %base-button-destructive { --button-bg-color: var(--color-accent-error); --button-fg-color: var(--color-foreground-primary); - --button-hover-bg-color: var(--color-background-error); --button-hover-fg-color: var(--color-foreground-primary); - --button-active-bg-color: var(--color-accent-error); --button-active-fg-color: var(--color-foreground-primary); - --button-disabled-bg-color: var(--color-background-error); --button-disabled-fg-color: var(--color-accent-error); - --button-focus-bg-color: var(--color-accent-error); --button-focus-fg-color: var(--color-foreground-primary); --button-focus-inner-ring-color: var(--color-background-primary); @@ -145,6 +122,6 @@ &:active, &[aria-pressed="true"] { - box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgba(0, 0, 0, 0.2); + box-shadow: inset 0 0 #{px2rem(10)} #{px2rem(2)} rgb(0 0 0 / 0.2); } } diff --git a/frontend/src/app/main/ui/ds/buttons/button.scss b/frontend/src/app/main/ui/ds/buttons/button.scss index dd8c720559..5885f881c8 100644 --- a/frontend/src/app/main/ui/ds/buttons/button.scss +++ b/frontend/src/app/main/ui/ds/buttons/button.scss @@ -9,10 +9,9 @@ .button { @extend %base-button; - @include use-typography("headline-small"); - padding: 0 var(--sp-m); + padding: 0 var(--sp-m); display: inline-flex; align-items: center; column-gap: var(--sp-xs); diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs index bcfd24240e..45b0b7b1b7 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.cljs +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.cljs @@ -19,6 +19,7 @@ [:tooltip-class {:optional true} [:maybe :string]] [:type {:optional true} [:maybe [:enum "button" "submit" "reset"]]] [:icon-class {:optional true} :string] + [:icon-size {:optional true} [:maybe [:enum "s" "m" "l"]]] [:icon [:and :string [:fn #(contains? icon-list %)]]] [:aria-label :string] @@ -30,7 +31,7 @@ (mf/defc icon-button* {::mf/schema schema:icon-button ::mf/memo true} - [{:keys [class icon icon-class variant aria-label children tooltip-placement tooltip-class type] :rest props}] + [{:keys [class icon icon-class icon-size variant aria-label children tooltip-placement tooltip-class type] :rest props}] (let [variant (d/nilv variant "primary") @@ -60,5 +61,5 @@ :placement tooltip-placement :id tooltip-id} [:> :button props - [:> icon* {:icon-id icon :aria-hidden true :class icon-class}] + [:> icon* {:icon-id icon :aria-hidden true :class icon-class :size icon-size}] children]])) diff --git a/frontend/src/app/main/ui/ds/buttons/icon_button.scss b/frontend/src/app/main/ui/ds/buttons/icon_button.scss index 26c8692558..40b422168a 100644 --- a/frontend/src/app/main/ui/ds/buttons/icon_button.scss +++ b/frontend/src/app/main/ui/ds/buttons/icon_button.scss @@ -37,20 +37,15 @@ .icon-button-action { --button-bg-color: transparent; --button-fg-color: var(--color-foreground-secondary); - --button-hover-bg-color: transparent; --button-hover-fg-color: var(--color-accent-primary); - --button-active-bg-color: var(--color-background-quaternary); - --button-disabled-bg-color: transparent; --button-disabled-fg-color: var(--color-accent-primary-muted); - --button-focus-bg-color: transparent; --button-focus-fg-color: var(--color-accent-primary); --button-focus-inner-ring-color: transparent; --button-focus-outer-ring-color: var(--color-accent-primary); - --button-width: #{$sz-24}; --button-height: #{$sz-24}; } diff --git a/frontend/src/app/main/ui/ds/colors.scss b/frontend/src/app/main/ui/ds/colors.scss index e5c1525e10..67358d3096 100644 --- a/frontend/src/app/main/ui/ds/colors.scss +++ b/frontend/src/app/main/ui/ds/colors.scss @@ -12,22 +12,17 @@ $mint-700: #426158; $mint-150-60: #7efff599; $mint-250-10: #00d1b81a; $mint-250-70: #00d1b8b3; - $green-200: #a7e8d9; $green-500: #2d9f8f; $green-950: #0a2927; - $orange-200: #fedeac; $orange-500: #fe9c07; $orange-950: #3d2501; - $red-200: #ffcada; $red-400: #c80857; $red-500: #ff3277; $red-950: #500124; - $pink-400: #ff6fe0; - $purple-200: #e1d2f5; $purple-400: #bb97d8; $purple-500: #a977d1; @@ -36,23 +31,18 @@ $purple-700: #6911d4; $purple-600-10: #8c33eb1a; $purple-600-70: #8c33ebb3; $purple-700-60: #6911d499; - $aqua-200: #ddf7ff; $aqua-400: #77e1f3; $aqua-600: #59acbb; $aqua-800: #1d4464; - $violet-300: #a7a9ff; $violet-600: #6c6dad; $violet-700: #484c74; $violet-800: #272941; - $blue-200: #bae3fd; $blue-500: #0e9be9; $blue-950: #082c49; - $cobalt-700: #1345aa; - $black: #000; $gray-950: #18181a; $gray-950-60: #18181a99; @@ -63,12 +53,10 @@ $gray-200: #e8eaee; $gray-100: #eef0f2; $gray-50: #f3f4f6; $white: #fff; -$white-60: #ffffff99; +$white-60: #fff9; $white-90: #ffffffe6; - $blue-teal-700: #495e74; $grayish-blue-500: #8f9da3; - $grayish-red: #bfbfbf; :global(.light) { @@ -83,7 +71,6 @@ $grayish-red: #bfbfbf; --color-accent-action: #{$purple-400}; --color-accent-action-hover: #{$purple-500}; --color-accent-off: #{$gray-50}; - --color-accent-success: #{$green-500}; --color-background-success: #{$green-200}; --color-accent-warning: #{$orange-500}; @@ -97,29 +84,23 @@ $grayish-red: #bfbfbf; --color-accent-default: #{$gray-100}; --color-icon-default: #{$blue-teal-700}; --color-background-disabled: #{$gray-200}; - --color-background-primary: #{$white}; --color-background-secondary: #{$gray-200}; --color-background-tertiary: #{$gray-50}; --color-background-quaternary: #{$gray-100}; - --color-foreground-primary: #{$black}; --color-foreground-secondary: #{$blue-teal-700}; - --color-static-white: #{$white}; --color-static-black: #{$black}; - --color-shadow-dark: #{color.change($gray-200, $alpha: 0.6)}; --color-shadow-light: #{color.change($black, $alpha: 0.3)}; --color-overlay-default: #{$white-60}; --color-overlay-onboarding: #{$white-90}; --color-canvas: #{$grayish-red}; - --color-token-background: #{$aqua-200}; --color-token-border: #{$aqua-400}; --color-token-accent: #{$aqua-600}; --color-token-foreground: #{$aqua-800}; - --color-badge-premium: #{$orange-500}; } @@ -135,7 +116,6 @@ $grayish-red: #bfbfbf; --color-accent-action: #{$purple-400}; --color-accent-action-hover: #{$purple-500}; --color-accent-off: #{$gray-50}; - --color-accent-success: #{$green-500}; --color-background-success: #{$green-950}; --color-accent-warning: #{$orange-500}; @@ -149,28 +129,22 @@ $grayish-red: #bfbfbf; --color-accent-default: #{$gray-800}; --color-icon-default: #{$grayish-blue-500}; --color-background-disabled: #{$gray-800}; - --color-background-primary: #{$gray-950}; --color-background-secondary: #{$black}; --color-background-tertiary: #{$gray-900}; --color-background-quaternary: #{$gray-800}; - --color-foreground-primary: #{$white}; --color-foreground-secondary: #{$grayish-blue-500}; - --color-static-white: #{$white}; --color-static-black: #{$black}; - --color-shadow-dark: #{color.change($black, $alpha: 0.6)}; --color-shadow-light: #{color.change($black, $alpha: 0.3)}; --color-overlay-default: #{$gray-950-60}; --color-overlay-onboarding: #{$gray-950-90}; --color-canvas: #{$grayish-red}; - --color-token-background: #{$violet-800}; --color-token-border: #{$violet-700}; --color-token-accent: #{$violet-600}; --color-token-foreground: #{$violet-300}; - --color-badge-premium: #{$orange-200}; } diff --git a/frontend/src/app/main/ui/ds/controls/checkbox.scss b/frontend/src/app/main/ui/ds/controls/checkbox.scss index 81eda1fd47..de272a654f 100644 --- a/frontend/src/app/main/ui/ds/controls/checkbox.scss +++ b/frontend/src/app/main/ui/ds/controls/checkbox.scss @@ -14,14 +14,11 @@ --input-checkbox-border-color-hover: var(--color-accent-primary-muted); --input-checkbox-foreground-color: var(--color-foreground-primary); --input-checkbox-background-color: var(--color-background-quaternary); - --input-checkbox-border-color-checked: var(--color-background-quaternary); --input-checkbox-foreground-color-checked: var(--color-background-primary); --input-checkbox-background-color-checked: var(--color-accent-primary); - --input-checkbox-foreground-color-disabled: var(--color-background-primary); --input-checkbox-background-color-disabled: var(--color-foreground-secondary); - --input-checkbox-text-color: var(--color-foreground-secondary); } @@ -73,6 +70,7 @@ .checkbox-text { @include use-typography("body-small"); + padding-inline-start: var(--sp-s); color: var(--input-checkbox-text-color); } diff --git a/frontend/src/app/main/ui/ds/controls/combobox.cljs b/frontend/src/app/main/ui/ds/controls/combobox.cljs index f8fcc566b6..14ade592a2 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.cljs +++ b/frontend/src/app/main/ui/ds/controls/combobox.cljs @@ -10,7 +10,7 @@ (:require [app.common.data :as d] [app.main.constants :refer [max-input-length]] - [app.main.ui.ds.controls.select :refer [get-option handle-focus-change]] + [app.main.ui.ds.controls.select :refer [handle-focus-change]] [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.dom :as dom] @@ -29,19 +29,21 @@ [:placeholder {:optional true} :string] [:disabled {:optional true} :boolean] [:default-selected {:optional true} :string] + [:select-only {:optional true} :boolean] [:on-change {:optional true} fn?] [:empty-to-end {:optional true} [:maybe :boolean]] [:has-error {:optional true} :boolean]]) (mf/defc combobox* {::mf/schema schema:combobox} - [{:keys [id options class placeholder disabled has-error default-selected max-length empty-to-end on-change] :rest props}] + [{:keys [id options class placeholder disabled has-error default-selected select-only max-length empty-to-end on-change] :rest props}] (let [;; NOTE: we use mfu/bean here for transparently handle ;; options provide as clojure data structures or javascript ;; plain objects and lists. options (if (array? options) (mfu/bean options) options) + select-only (d/nilv select-only false) empty-to-end (d/nilv empty-to-end false) is-open* (mf/use-state false) @@ -64,13 +66,15 @@ value-ref (mf/use-ref nil) dropdown-options - (mf/with-memo [options filter-id] - (->> options - (filterv (fn [option] - (let [option (str/lower (get option :id)) - filter (str/lower filter-id)] - (str/includes? option filter)))) - (not-empty))) + (mf/with-memo [options filter-id select-only] + (if select-only + (not-empty options) + (->> options + (filterv (fn [option] + (let [option (str/lower (get option :label)) + filter (str/lower filter-id)] + (str/includes? option filter)))) + (not-empty)))) set-option-ref (mf/use-fn @@ -113,7 +117,7 @@ on-blur (mf/use-fn - (mf/deps on-change) + (mf/deps on-change options selected-id select-only) (fn [event] (dom/stop-propagation event) (let [target (dom/get-related-target event) @@ -123,7 +127,14 @@ (reset! focused-id* nil) (when (fn? on-change) (when-let [input-node (mf/ref-val input-ref)] - (on-change (dom/get-input-value input-node)))))))) + (let [input-value (dom/get-input-value input-node) + selected-option (d/seek #(= selected-id (get % :id)) options) + value (if select-only + selected-id + (if (some? selected-option) + selected-id + input-value))] + (on-change value)))))))) on-input-click (mf/use-fn @@ -144,10 +155,18 @@ on-input-key-down (mf/use-fn - (mf/deps is-open focused-id disabled) + (mf/deps is-open focused-id disabled select-only) (fn [event] (dom/stop-propagation event) (when-not disabled + (when (and select-only + (not (kbd/down-arrow? event)) + (not (kbd/up-arrow? event)) + (not (kbd/home? event)) + (not (kbd/enter? event)) + (not (kbd/esc? event)) + (not (kbd/tab? event))) + (dom/prevent-default event)) (let [options (mf/ref-val options-ref) len (count options) index (d/index-of-pred options #(= focused-id (get % :id))) @@ -196,24 +215,34 @@ on-input-change (mf/use-fn + (mf/deps select-only) (fn [event] (dom/stop-propagation event) - (let [value (-> event - dom/get-target - dom/get-value)] - (mf/set-ref-val! value-ref value) - (reset! selected-id* value) - (reset! filter-id* value) - (reset! focused-id* nil)))) + (when-not select-only + (let [value (-> event + dom/get-target + dom/get-value)] + (mf/set-ref-val! value-ref value) + (reset! selected-id* value) + (reset! filter-id* value) + (reset! focused-id* nil))))) selected-option (mf/with-memo [options selected-id] (when (d/not-empty? options) - (get-option options selected-id))) + (d/seek #(= selected-id (get % :id)) options))) icon (when selected-option - (get selected-option :icon))] + (get selected-option :icon)) + + avatar + (when selected-option + (get selected-option :avatar)) + + render-avatar-fn + (when avatar + (get avatar :render-fn))] (mf/with-effect [dropdown-options] (mf/set-ref-val! options-ref dropdown-options)) @@ -241,25 +270,33 @@ :on-click on-click} [:span {:class (stl/css-case :header true - :header-icon (some? icon))} + :header-icon (some? icon) + :header-avatar (fn? render-avatar-fn))} (when icon [:> icon* {:icon-id icon :size "s" :aria-hidden true}]) + (when (fn? render-avatar-fn) + [:> render-avatar-fn {:avatar avatar}]) [:input {:id id :ref input-ref :type "text" :role "combobox" :class (stl/css :input) :auto-complete "off" - :aria-autocomplete "both" + :aria-autocomplete (if select-only "none" "both") :aria-expanded is-open :aria-controls listbox-id :aria-activedescendant focused-id :data-testid "combobox-input" :max-length (d/nilv max-length max-input-length) :disabled disabled - :value (d/nilv selected-id "") + :read-only select-only + :value (if select-only + (d/nilv (:label selected-option) "") + (if (str/empty? (:id selected-option)) + (d/nilv selected-id "") + (d/nilv (:label selected-option) ""))) :placeholder placeholder :on-change on-input-change :on-click on-input-click diff --git a/frontend/src/app/main/ui/ds/controls/combobox.mdx b/frontend/src/app/main/ui/ds/controls/combobox.mdx index eff4b6b18d..58075b8aed 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.mdx +++ b/frontend/src/app/main/ui/ds/controls/combobox.mdx @@ -53,6 +53,39 @@ These are available in the `app.main.ds.foundations.assets.icon` namespace. ]}] ``` + +### Avatars + +Each option of `combobox*` also accepts an optional `avatar` map. +Avatar rendering is defined per option with `:render-fn`, so each avatar type can provide its own UI. The renderer should be a component function that receives the full `avatar` map. + +```clj +;; Example renderer for organization avatars +(mf/defc render-org-avatar* + [{:keys [avatar]}] + (when (= :organization (:type avatar)) + [:> org-avatar* {:org (:organization avatar) + :size (:size avatar)}])) + +[:> combobox* + {:options [{:label "Design Team" + :id "org-design" + :avatar {:render-fn render-org-avatar* + :size "s" + :organization {:name "Design Team" + :organization-avatar-bg-url "https://example.com/avatar-bg.svg" + :organization-custom-photo nil}}} + {:label "Engineering" + :id "org-engineering" + :avatar {:render-fn render-org-avatar* + :size "s" + :organization {:name "Engineering" + :organization-avatar-bg-url nil + :organization-custom-photo "https://example.com/custom-photo.png"}}}]}] +``` + +The same pattern can be used later for other avatar kinds, for example `:team`, by adding a different `:render-fn` in those options. + ## Usage guidelines (design) ### Where to Use diff --git a/frontend/src/app/main/ui/ds/controls/combobox.scss b/frontend/src/app/main/ui/ds/controls/combobox.scss index 3df8586715..519243a8fb 100644 --- a/frontend/src/app/main/ui/ds/controls/combobox.scss +++ b/frontend/src/app/main/ui/ds/controls/combobox.scss @@ -26,6 +26,7 @@ } @include use-typography("body-small"); + position: relative; display: grid; grid-template-rows: auto; @@ -40,7 +41,6 @@ height: $sz-32; width: 100%; padding: var(--sp-s); - border: none; border-radius: $br-8; outline: $b-1 solid var(--combobox-outline-color); border: $b-1 solid var(--combobox-border-color); @@ -64,10 +64,16 @@ color: var(--combobox-icon-color); } +.header-avatar { + grid-template-columns: auto 1fr; + gap: var(--sp-s); +} + .input { all: unset; @include use-typography("body-small"); + background-color: transparent; overflow: hidden; text-align: left; @@ -88,6 +94,7 @@ .disabled { cursor: default; + --combobox-background-color: var(--color-background-primary); --combobox-border-color: var(--color-background-quaternary); --combobox-text-color: var(--color-foreground-secondary); diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs index bd18d6dcdf..54da21ed03 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.cljs +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.cljs @@ -19,6 +19,7 @@ [app.main.ui.ds.controls.utilities.token-field :refer [token-field*]] [app.main.ui.ds.foundations.assets.icon :refer [icon* icon-list] :as i] [app.main.ui.formats :as fmt] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] @@ -83,48 +84,6 @@ (str/replace #"^\{" "") (str/replace #"\}$" ""))) -(defn- token->dropdown-option - [token] - {:id (str (get token :id)) - :type :token - :resolved-value (get token :resolved-value) - :name (get token :name)}) - -(defn- generate-dropdown-options - [tokens no-sets] - (if (empty? tokens) - [{:type :empty - :label (if no-sets - (tr "ds.inputs.numeric-input.no-applicable-tokens") - (tr "ds.inputs.numeric-input.no-matches"))}] - (->> tokens - (map (fn [[type items]] - (cons {:group true - :type :group - :id (dm/str "group-" (name type)) - :name (name type)} - (map token->dropdown-option items)))) - (interpose [{:separator true - :id "separator" - :type :separator}]) - (apply concat) - (vec) - (not-empty)))) - -(defn- extract-partial-brace-text - [s] - (when-let [start (str/last-index-of s "{")] - (subs s (inc start)))) - -(defn- filter-token-groups-by-name - [tokens filter-text] - (let [lc-filter (str/lower filter-text)] - (into {} - (keep (fn [[group tokens]] - (let [filtered (filter #(str/includes? (str/lower (:name %)) lc-filter) tokens)] - (when (seq filtered) - [group filtered])))) - tokens))) (defn- focusable-option? [option] @@ -150,31 +109,12 @@ j))) indices))) -(defn- sort-groups-and-tokens - "Sorts both the groups and the tokens inside them alphabetically. +(defn- find-token-by-name + [data name] + (some (fn [tokens-data] + (some #(when (= (:name %) name) %) tokens-data)) + (vals data))) - Input: - A map where: - - keys are groups (keywords or strings, e.g. :dimensions, :colors) - - values are vectors of token maps, each containing at least a :name key - - Example input: - {:dimensions [{:name \"tres\"} {:name \"quini\"}] - :colors [{:name \"azul\"} {:name \"rojo\"}]} - - Output: - A sorted map where: - - groups are ordered alphabetically by key - - tokens inside each group are sorted alphabetically by :name - - Example output: - {:colors [{:name \"azul\"} {:name \"rojo\"}] - :dimensions [{:name \"quini\"} {:name \"tres\"}]}" - - [groups->tokens] - (into (sorted-map) ;; ensure groups are ordered alphabetically by their key - (for [[group tokens] groups->tokens] - [group (sort-by :name tokens)]))) (def ^:private schema:icon [:and :string [:fn #(contains? icon-list %)]]) @@ -203,10 +143,14 @@ [:applied-token {:optional true} [:maybe [:or :string [:= :multiple]]]] [:empty-to-end {:optional true} :boolean] [:on-change {:optional true} fn?] + [:on-change-start {:optional true} fn?] + [:on-change-end {:optional true} fn?] [:on-blur {:optional true} fn?] [:on-focus {:optional true} fn?] [:on-detach {:optional true} fn?] [:property {:optional true} :string] + [:tooltip-placement {:optional true} + [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]] [:align {:optional true} [:maybe [:enum :left :right]]]]) (mf/defc numeric-input* @@ -215,10 +159,11 @@ icon disabled inner-class min max max-length step is-selected-on-focus nillable - tokens applied-token empty-to-end - on-change on-blur on-focus on-detach + tokens applied-token-name empty-to-end + on-change on-change-start on-change-end + on-blur on-focus on-detach property align ref name - text-icon] + tooltip-placement text-icon] :rest props}] (let [;; NOTE: we use mfu/bean here for transparently handle @@ -227,9 +172,16 @@ tokens (if (object? tokens) (mfu/bean tokens) tokens) - value (if (= :multiple applied-token) + + value (if (= :multiple applied-token-name) :multiple value) + + token-applied (mf/with-memo [tokens applied-token-name] + (find-token-by-name tokens applied-token-name)) + + token-has-errors? (-> token-applied :errors seq boolean) + is-multiple? (= :multiple value) value (cond is-multiple? nil @@ -264,8 +216,8 @@ is-open* (mf/use-state false) is-open (deref is-open*) - token-applied* (mf/use-state applied-token) - token-applied (deref token-applied*) + token-applied-name* (mf/use-state applied-token-name) + token-applied-name (deref token-applied-name*) focused-id* (mf/use-state nil) focused-id (deref focused-id*) @@ -276,6 +228,10 @@ raw-value* (mf/use-ref nil) last-value* (mf/use-ref nil) + ;; Flag to prevent effect from overwriting token during selection + ;; This prevents race condition between blur and token selection + token-selection-in-progress* (mf/use-ref false) + ;; Refs wrapper-ref (mf/use-ref nil) nodes-ref (mf/use-ref nil) @@ -287,23 +243,19 @@ open-dropdown-ref (mf/use-ref nil) token-detach-btn-ref (mf/use-ref nil) + ;; Drag scrubbing state + drag-state* (mf/use-ref :idle) + drag-start-x* (mf/use-ref 0) + drag-start-val* (mf/use-ref 0) + dropdown-options (mf/with-memo [tokens filter-id] - (delay - (let [tokens (if (delay? tokens) @tokens tokens) - - sorted-tokens (sort-groups-and-tokens tokens) - partial (extract-partial-brace-text filter-id) - options (if (seq partial) - (filter-token-groups-by-name sorted-tokens partial) - sorted-tokens) - no-sets? (nil? sorted-tokens)] - (generate-dropdown-options options no-sets?)))) + (csu/get-token-dropdown-options tokens filter-id)) selected-id* (mf/use-state (fn [] - (if applied-token - (:id (get-option-by-name dropdown-options applied-token)) + (if applied-token-name + (:id (get-option-by-name dropdown-options applied-token-name)) nil))) selected-id (deref selected-id*) @@ -337,7 +289,7 @@ (if-let [parsed (parse-value raw-value (mf/ref-val last-value*) min max nillable)] (when-not (= parsed (mf/ref-val last-value*)) (mf/set-ref-val! last-value* parsed) - (reset! token-applied* nil) + (reset! token-applied-name* nil) (when (fn? on-change) (on-change parsed)) @@ -348,7 +300,7 @@ (do (mf/set-ref-val! last-value* nil) (mf/set-ref-val! raw-value* "") - (reset! token-applied* nil) + (reset! token-applied-name* nil) (update-input "") (when (fn? on-change) (on-change nil))) @@ -356,7 +308,7 @@ (let [fallback-value (or (mf/ref-val last-value*) default)] (mf/set-ref-val! raw-value* fallback-value) (mf/set-ref-val! last-value* fallback-value) - (reset! token-applied* nil) + (reset! token-applied-name* nil) (update-input (fmt/format-number fallback-value)) (when (and (fn? on-change) (not= fallback-value (str value))) @@ -383,13 +335,15 @@ (mf/use-fn (mf/deps apply-token) (fn [id value name] + (mf/set-ref-val! token-selection-in-progress* true) (reset! selected-id* id) (reset! focused-id* nil) (reset! is-open* false) - (reset! token-applied* name) + (reset! token-applied-name* name) (apply-token value name) (ts/schedule-on-idle (fn [] + (mf/set-ref-val! token-selection-in-progress* false) (when token-wrapper-ref (dom/focus! (mf/ref-val token-wrapper-ref))))))) @@ -419,7 +373,7 @@ (on-token-apply focused-id value name) (reset! filter-id* "")))) - on-blur + handle-blur (mf/use-fn (mf/deps apply-value on-blur) (fn [event] @@ -434,7 +388,8 @@ (when (mf/ref-val dirty-ref) (apply-value (mf/ref-val raw-value*))) (when (fn? on-blur) - (on-blur event)))) + (on-blur event)) + (dom/blur! (mf/ref-val ref)))) on-key-down (mf/use-fn @@ -474,8 +429,9 @@ value (get option :resolved-value) name (get option :name)] (on-token-apply option-id value name) - (reset! filter-id* "")))) - (on-blur event)) + (reset! filter-id* "") + (handle-blur event)))) + (handle-blur event)) esc? (do @@ -516,13 +472,14 @@ (mf/use-fn (mf/deps on-focus select-on-focus) (fn [event] - (when (fn? on-focus) - (on-focus event)) - (let [target (dom/get-target event)] - (when select-on-focus - (dom/select-text! target) - ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect - (.addEventListener target "mouseup" dom/prevent-default #js {:once true}))))) + (when-not (= :dragging (mf/ref-val drag-state*)) + (when (fn? on-focus) + (on-focus event)) + (let [target (dom/get-target event)] + (when select-on-focus + (dom/select-text! target) + ;; In webkit browsers the mouseup event will be called after the on-focus causing and unselect + (.addEventListener target "mouseup" dom/prevent-default #js {:once true})))))) on-mouse-wheel (mf/use-fn @@ -542,6 +499,77 @@ (dom/stop-propagation event) (apply-value (dm/str new-val))))))) + on-scrub-pointer-down + (mf/use-fn + (mf/deps disabled is-open is-multiple? ref min max nillable default) + (fn [event] + (when-not (or disabled is-open is-multiple?) + (let [node (mf/ref-val ref) + is-focused (and (some? node) (dom/active? node)) + has-token (some? (deref token-applied-name*))] + (when-not (or is-focused has-token) + (let [client-x (.-clientX event) + parsed (parse-value (mf/ref-val raw-value*) (mf/ref-val last-value*) min max nillable) + start-val (or parsed default 0)] + (mf/set-ref-val! drag-state* :maybe-dragging) + (mf/set-ref-val! drag-start-x* client-x) + (mf/set-ref-val! drag-start-val* start-val) + (dom/capture-pointer event))))))) + + on-scrub-pointer-move + (mf/use-fn + (mf/deps apply-value update-input step min max on-change-start) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (or (= state :maybe-dragging) (= state :dragging)) + (let [client-x (.-clientX event) + start-x (mf/ref-val drag-start-x*) + delta-x (- client-x start-x)] + (when (and (= state :maybe-dragging) + (>= (js/Math.abs delta-x) 3)) + (mf/set-ref-val! drag-state* :dragging) + (dom/add-class! (dom/get-body) "cursor-drag-scrub") + (when (fn? on-change-start) + (on-change-start))) + (when (= (mf/ref-val drag-state*) :dragging) + (let [effective-step (cond + (.-shiftKey event) (* step 10) + (.-ctrlKey event) (* step 0.1) + :else step) + steps (js/Math.round (/ delta-x 1)) + new-val (mth/clamp (+ (mf/ref-val drag-start-val*) + (* steps effective-step)) + min max)] + (update-input (fmt/format-number new-val)) + (apply-value (dm/str new-val))))))))) + + on-scrub-pointer-up + (mf/use-fn + (mf/deps ref on-change-end) + (fn [event] + (let [state (mf/ref-val drag-state*)] + (when (= state :maybe-dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/release-pointer event) + (when-let [node (mf/ref-val ref)] + (dom/focus! node))) + (when (= state :dragging) + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (dom/release-pointer event) + (when (fn? on-change-end) + (on-change-end)))))) + + on-scrub-lost-pointer-capture + (mf/use-fn + (mf/deps on-change-end) + (fn [_event] + (let [was-dragging (= :dragging (mf/ref-val drag-state*))] + (mf/set-ref-val! drag-state* :idle) + (dom/remove-class! (dom/get-body) "cursor-drag-scrub") + (when (and was-dragging (fn? on-change-end)) + (on-change-end))))) + open-dropdown (mf/use-fn (mf/deps disabled ref) @@ -562,16 +590,16 @@ detach-token (mf/use-fn - (mf/deps on-detach tokens disabled token-applied) + (mf/deps on-detach tokens disabled token-applied-name) (fn [event] (when-not disabled (dom/prevent-default event) (dom/stop-propagation event) - (reset! token-applied* nil) + (reset! token-applied-name* nil) (reset! selected-id* nil) (reset! focused-id* nil) (when on-detach - (on-detach token-applied)) + (on-detach token-applied-name)) (ts/schedule-on-idle (fn [] (dom/focus! (mf/ref-val ref))))))) @@ -633,7 +661,7 @@ (tr "labels.mixed-values") placeholder) :default-value (or (mf/ref-val last-value*) (fmt/format-number value)) - :on-blur on-blur + :on-blur handle-blur :on-key-down on-key-down :on-focus on-focus :on-change store-raw-value @@ -653,14 +681,15 @@ :class (stl/css :invisible-button) :aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown") :ref open-dropdown-ref + :tooltip-placement tooltip-placement :on-click open-dropdown}]))) :max-length max-length}) token-props - (when (and token-applied (not= :multiple token-applied)) - (let [token (get-option-by-name dropdown-options token-applied) + (when (and token-applied-name (not= :multiple token-applied-name)) + (let [token (get-option-by-name dropdown-options token-applied-name) id (get token :id) - label (or (get token :name) applied-token) + label (or (get token :name) applied-token-name) token-value (or (get token :resolved-value) (or (mf/ref-val last-value*) (fmt/format-number value))) @@ -675,9 +704,12 @@ :on-focus on-focus :on-token-key-down on-token-key-down :disabled disabled - :on-blur on-blur + :on-blur handle-blur + :token-has-errors token-has-errors? :class inner-class :property property + :is-open is-open + :tooltip-placement tooltip-placement :slot-start (when (or icon text-icon) (mf/html (cond @@ -694,7 +726,7 @@ :token-detach-btn-ref token-detach-btn-ref :detach-token detach-token})))] - (mf/with-effect [value default applied-token] + (mf/with-effect [value default applied-token-name] (let [value' (cond is-multiple? "" @@ -704,18 +736,28 @@ :else (fmt/format-number (d/parse-double value default)))] - (mf/set-ref-val! raw-value* value') (mf/set-ref-val! last-value* value') - (reset! token-applied* applied-token) - (if applied-token - (let [token-id (:id (get-option-by-name dropdown-options applied-token))] - (reset! selected-id* token-id)) - (reset! selected-id* nil)) + + ;; Only sync token state if not in the middle of a selection + ;; This prevents race condition between blur and token selection + (when-not (mf/ref-val token-selection-in-progress*) + (reset! token-applied-name* applied-token-name) + (if applied-token-name + (let [token-id (:id (get-option-by-name dropdown-options applied-token-name))] + (reset! selected-id* token-id)) + (reset! selected-id* nil))) (when-let [node (mf/ref-val ref)] (dom/set-value! node value')))) + (mf/with-effect [applied-token-name] + (when (nil? applied-token-name) + ;; Only clear if not in the middle of a selection + (when-not (mf/ref-val token-selection-in-progress*) + (reset! token-applied-name* nil) + (reset! selected-id* nil)))) + (mf/with-layout-effect [on-mouse-wheel] (when-let [node (mf/ref-val ref)] (let [key (events/listen node "wheel" on-mouse-wheel #js {:passive false})] @@ -725,10 +767,14 @@ (mf/set-ref-val! options-ref dropdown-options)) [:div {:class [class (stl/css :input-wrapper)] - :ref wrapper-ref} + :ref wrapper-ref + :on-pointer-down on-scrub-pointer-down + :on-pointer-move on-scrub-pointer-move + :on-pointer-up on-scrub-pointer-up + :on-lost-pointer-capture on-scrub-lost-pointer-capture} - (if (and (some? token-applied) - (not= :multiple token-applied)) + (if (and (some? token-applied-name) + (not= :multiple token-applied-name)) [:> token-field* token-props] [:> input-field* input-props]) diff --git a/frontend/src/app/main/ui/ds/controls/numeric_input.scss b/frontend/src/app/main/ui/ds/controls/numeric_input.scss index 0b3ee3795f..60741e7d0b 100644 --- a/frontend/src/app/main/ui/ds/controls/numeric_input.scss +++ b/frontend/src/app/main/ui/ds/controls/numeric_input.scss @@ -13,16 +13,23 @@ .input-wrapper { --input-padding-size: var(--sp-xs); --opacity-button: 0; + @include t.use-typography("code-font"); + display: flex; flex-direction: column; gap: var(--sp-xs); inline-size: 100%; position: relative; + &:not(:focus-within) { + cursor: ew-resize; + } + &:hover { --opacity-button: 1; } + &:focus-within { --opacity-button: 1; } @@ -35,9 +42,12 @@ .text-icon { color: var(--color-foreground-secondary); - @include t.use-typography("code-font"); + + @include t.use-typography("body-small"); + inline-size: fit-content; - min-inline-size: px2rem(40); + min-inline-size: px2rem(46); + padding-inline-start: var(--sp-xs); } .invisible-button { @@ -46,12 +56,16 @@ inset-block-start: 0; opacity: var(--opacity-button); background-color: var(--color-background-quaternary); + &:hover { background-color: var(--color-background-quaternary); + --opacity-button: 1; } + &:focus { background-color: var(--color-background-quaternary); + --opacity-button: 1; } } diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs index ea9dd6fff3..93837196d0 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.cljs @@ -24,7 +24,7 @@ [:and :string [:fn #(contains? icon-list %)]]] [:label :string] [:value [:or :keyword :string]] - [:disabled {:optional true} :boolean]]) + [:disabled {:optional true} [:maybe :boolean]]]) (def ^:private schema:radio-buttons [:map @@ -35,46 +35,58 @@ [:name {:optional true} :string] [:selected {:optional true} [:maybe [:or :keyword :string]]] - [:allow-empty {:optional true} :boolean] + [:allow-empty {:optional true} [:maybe :boolean]] + [:disabled {:optional true} [:maybe :boolean]] [:options [:vector {:min 1} schema:radio-button]] [:on-change {:optional true} fn?]]) (mf/defc radio-buttons* {::mf/schema schema:radio-buttons} - [{:keys [class variant extended name selected allow-empty options on-change] :rest props}] + [{:keys [class variant extended name selected allow-empty options on-change disabled] :rest props}] (let [options (if (array? options) (mfu/bean options) options) - type (if allow-empty "checkbox" "radio") - variant (d/nilv variant "secondary") + type (if allow-empty "checkbox" "radio") + variant (d/nilv variant "secondary") + wrapper-disabled (d/nilv disabled false) handle-click (mf/use-fn + (mf/deps selected on-change allow-empty) (fn [event] (let [target (dom/get-target event) - label (dom/get-parent-with-data target "label")] - (dom/prevent-default event) - (dom/stop-propagation event) - (dom/click label)))) + label (dom/get-parent-with-data target "label") + input (dom/query label "input") + disabled? (dom/get-attribute target "disabled")] + (when-not disabled? + (dom/click input))))) handle-change (mf/use-fn - (mf/deps selected on-change) + (mf/deps selected on-change allow-empty) (fn [event] - (let [input (dom/get-target event) - value (dom/get-target-val event)] + (let [input (dom/get-target event) + value (dom/get-target-val event) + selected-str (when selected (d/name selected)) + new-value (if (and allow-empty (= value selected-str)) + nil + value)] (when (fn? on-change) - (on-change value event)) + (on-change new-value event)) (dom/blur! input)))) props (mf/spread-props props {:key (dm/str name "-" selected) :class [class (stl/css-case :wrapper true + :disabled disabled :extended extended)]})] [:> :div props (for [[idx {:keys [id class value label icon disabled]}] (d/enumerate options)] - (let [checked? (= selected value)] + (let [value-str (d/name value) + selected-str (when selected (d/name selected)) + checked? (= selected-str value-str) + disabled (d/nilv disabled false)] [:label {:key idx :html-for id :data-label true @@ -88,13 +100,13 @@ :aria-pressed checked? :aria-label label :icon icon - :disabled disabled}] + :disabled (or disabled wrapper-disabled)}] [:> button* {:variant variant :on-click handle-click :aria-pressed checked? :class (stl/css-case :button true :extended extended) - :disabled disabled} + :disabled (or disabled wrapper-disabled)} label]) [:input {:id id @@ -102,6 +114,6 @@ :on-change handle-change :type type :name name - :disabled disabled + :disabled (or disabled wrapper-disabled) :value value - :default-checked checked?}]]))])) + :checked checked?}]]))])) diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx index 226319286a..5346d8751c 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.mdx @@ -11,11 +11,17 @@ import * as RadioButtons from "./radio_buttons.stories"; # Radio Buttons -The `radio-buttons*` component allows users to switch between two or more options that are mutually exclusive. +The `radio-buttons*` component lets users select a single option from a set of mutually exclusive choices. + +It is designed for immediate selection changes, without requiring a confirmation step. + +--- ## Variants -Radio buttons with text only. The label will be the text of the button. +### Text only + +Radio buttons using text labels. The label is displayed directly on each option. @@ -34,12 +40,14 @@ Radio buttons with text only. The label will be the text of the button. {:id "align-right" :label "Right" :value "right"}]}] + + Icon only ``` -Radio buttons with icons only. In this case, the label will act as the tooltip of each button. +### Icons only +Radio buttons using icons instead of text labels. The label is used as tooltip and accessibility text. - ```clj (ns app.main.ui.foo (:require @@ -63,35 +71,58 @@ Radio buttons with icons only. In this case, the label will act as the tooltip o :label "Right align" :value "right"}]}] ``` +### Anatomy -## Anatomy +Each option is composed of: -Under the hood, each option is represented by -- a button, which is the visible and clickable element. It may be either an icon button or a text button. -- a radio input, which is not visible but retains the current state of the option. +A visible control (button or icon button) +A hidden native input (radio or checkbox) that stores the state -A radio group is defined by giving each of radio buttons in the group the same name. Once a radio group is established, -selecting any radio button in that group automatically deselects any currently-selected radio button in the same group. +All options share the same name, forming a radio group. Selecting one option automatically deselects the previously selected one. -The `selected` parameter should be set to the value of the option that is to be active. Otherwise, no option will be selected. +## Behavior -If the parameter `allow-empty` is enabled, then the component will work with checkboxes instead of radio buttons, -and therefore the selected option can be deselected. However, it will still only be possible to select one option. +### Selection +The selected prop controls the active option +It must match the value of one of the provided options +If selected is nil, no option is selected -The `extended` parameter allows the component to use all the available space from the parent and distribute it equally -among all elements. +### Allow empty -Any option can be individually disabled using the `disabled` parameter. +When allow-empty is enabled: + +The selected option can be deselected +Only one option can still be active at a time +This introduces toggle-like behavior over a single selection group + +### Extended + +When extended is enabled: + +The component expands to fill the width of its container +Options are evenly distributed across available space + +### Disabled state +The entire group can be disabled using the `:disabled` prop +Individual options can also be disabled using `:disabled` inside each option +Disabled options cannot be interacted with. ## Usage Guidelines -### When to Use +### When to use +For settings where users must choose exactly one option +For preference or configuration panels +When changes should take effect immediately -- For multiple choice settings that take effect immediately. -- In preference panels and configuration screens. +### When not to use -### When Not to Use +For boolean toggles → use a switch or checkbox +For multiple selection → use checkboxes +For actions requiring confirmation → use buttons or dialogs +For workflows that require an explicit “Apply” step -- For boolean settings (use switch or checkbox instead). -- For actions that require confirmation (use buttons instead). -- For temporary states that need explicit "Apply" action. +### Notes + +This component is controlled: state must be managed externally via selected +It does not manage internal state +The on-change handler is called with the new value whenever selection changes \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss index 05957025dc..56e53fae7e 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.scss +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.scss @@ -20,6 +20,11 @@ width: 100%; display: flex; } + + &.disabled { + outline: $b-1 solid var(--color-background-quaternary); + background-color: transparent; + } } .label { diff --git a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx index 7133a1b961..157f83e465 100644 --- a/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/radio_buttons.stories.jsx @@ -15,6 +15,12 @@ const options = [ { id: "right", label: "Right", value: "right" }, ]; +const optionsDisabled = [ + { id: "left", label: "Left", value: "left" }, + { id: "center", label: "Center", value: "center", disabled: true }, + { id: "right", label: "Right", value: "right" }, +]; + const optionsIcon = [ { id: "left", label: "Left align", value: "left", icon: "text-align-left" }, { @@ -68,9 +74,24 @@ export default { parameters: { controls: { exclude: ["options", "on-change"], + disabled: { + control: { type: "boolean" }, + }, }, }, - render: ({ ...args }) => , + render: (args) => { + const [selected, setSelected] = React.useState(args.selected); + + return ( + { + setSelected(value); + }} + /> + ); + }, }; export const Default = {}; @@ -80,3 +101,9 @@ export const WithIcons = { options: optionsIcon, }, }; + +export const WithOptionDisabled = { + args: { + options: optionsDisabled, + }, +}; diff --git a/frontend/src/app/main/ui/ds/controls/select.cljs b/frontend/src/app/main/ui/ds/controls/select.cljs index d40d7275b8..c31fb45264 100644 --- a/frontend/src/app/main/ui/ds/controls/select.cljs +++ b/frontend/src/app/main/ui/ds/controls/select.cljs @@ -9,8 +9,10 @@ [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.data.macros :as dm] [app.main.ui.ds.controls.shared.options-dropdown :refer [options-dropdown* schema:option]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.ds.tooltip.tooltip :refer [tooltip*]] [app.util.dom :as dom] [app.util.keyboard :as kbd] [app.util.object :as obj] @@ -50,15 +52,17 @@ [:map [:options [:vector {:min 1} schema:option]] [:class {:optional true} :string] + [:wrapper-class {:optional true} :string] [:disabled {:optional true} :boolean] [:default-selected {:optional true} :string] [:empty-to-end {:optional true} [:maybe :boolean]] [:on-change {:optional true} fn?] - [:variant {:optional true} [:maybe [:enum "default" "ghost"]]]]) + [:dropdown-alignment {:optional true} [:maybe [:enum :left :right]]] + [:variant {:optional true} [:maybe [:enum "default" "ghost" "icon-only"]]]]) (mf/defc select* {::mf/schema schema:select} - [{:keys [options class disabled default-selected empty-to-end on-change variant] :rest props}] + [{:keys [options class disabled default-selected empty-to-end on-change variant wrapper-class dropdown-alignment] :rest props}] (let [;; NOTE: we use mfu/bean here for transparently handle ;; options provide as clojure data structures or javascript ;; plain objects and lists. @@ -192,26 +196,40 @@ (some? icon) dimmed? - (:dimmed selected-option)] + (:dimmed selected-option) + + icon-ref (mf/use-ref nil) + icon-id (mf/use-id)] (mf/with-effect [options] (mf/set-ref-val! options-ref options)) - [:div {:class (stl/css :select-wrapper) + [:div {:class [wrapper-class (stl/css :select-wrapper)] :on-click on-click :ref select-ref :on-blur on-blur} [:> :button props [:span {:class (stl/css-case :select-header true - :header-icon has-icon?)} + :header-icon has-icon? + :header-icon-only (= variant "icon-only"))} (when ^boolean has-icon? - [:> icon* {:icon-id icon - :size "s" - :aria-hidden true}]) - [:span {:class (stl/css-case :header-label true - :header-label-dimmed (or empty-selected-id? dimmed?))} - (if ^boolean empty-selected-id? "--" label)]] + (if (= variant "icon-only") + [:> tooltip* {:content label + :trigger-ref icon-ref + :id (dm/str icon-id "-name") + :class (stl/css :option-text)} + [:> icon* {:icon-id icon + :ref icon-ref + :aria-labelledby (dm/str icon-id "-name")}]] + [:> icon* {:icon-id icon + :size "s" + :aria-hidden true}])) + + (when-not ^boolean (= variant "icon-only") + [:span {:class (stl/css-case :header-label true + :header-label-dimmed (or empty-selected-id? dimmed?))} + (if ^boolean empty-selected-id? "--" label)])] [:> icon* {:icon-id i/arrow-down :class (stl/css :arrow) @@ -224,5 +242,6 @@ :options options :selected selected-id :focused focused-id + :align dropdown-alignment :empty-to-end empty-to-end :ref set-option-ref}])])) diff --git a/frontend/src/app/main/ui/ds/controls/select.scss b/frontend/src/app/main/ui/ds/controls/select.scss index d52be44549..0cb48866ca 100644 --- a/frontend/src/app/main/ui/ds/controls/select.scss +++ b/frontend/src/app/main/ui/ds/controls/select.scss @@ -22,6 +22,7 @@ } @include use-typography("body-small"); + position: relative; display: grid; grid-template-rows: auto; @@ -47,7 +48,6 @@ block-size: $sz-32; inline-size: 100%; padding: var(--sp-s); - border: none; border-radius: $br-8; outline: $b-1 solid var(--select-outline-color); border: $b-1 solid var(--select-border-color); @@ -91,6 +91,7 @@ .header-label { @include use-typography("body-small"); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -109,3 +110,8 @@ grid-template-columns: auto 1fr; color: var(--select-icon-color); } + +.header-icon-only { + grid-template-columns: 1fr; + color: var(--select-icon-color); +} diff --git a/frontend/src/app/main/ui/ds/controls/select.stories.jsx b/frontend/src/app/main/ui/ds/controls/select.stories.jsx index 3cf750d5d7..8a2005cd32 100644 --- a/frontend/src/app/main/ui/ds/controls/select.stories.jsx +++ b/frontend/src/app/main/ui/ds/controls/select.stories.jsx @@ -9,7 +9,7 @@ import Components from "@target/components"; const { Select } = Components; -const variants = ["default", "ghost"]; +const variants = ["default", "ghost", "icon-only"]; const options = [ { id: "option-code", label: "Code" }, @@ -75,3 +75,10 @@ export const EmptyToEnd = { emptyToEnd: true, }, }; + +export const OnlyWithIcons = { + args: { + options: optionsWithIcons, + variant: variants[2], + }, +}; diff --git a/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs b/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs new file mode 100644 index 0000000000..3f9fc2fa8b --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/shared/dropdown_navigation.cljs @@ -0,0 +1,89 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC +(ns app.main.ui.ds.controls.shared.dropdown-navigation + (:require + [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [app.util.object :as obj] + [rumext.v2 :as mf])) + +(defn use-dropdown-navigation + "Hook for keyboard navigation in dropdowns. + + Options: + - focusable-ids: vector of focusable ids (already filtered) + - nodes-ref: ref to a JS object mapping id -> DOM node + - on-enter: fn called with focused-id when Enter is pressed + - searchable: when true, nil focused-id means search input is focused + - search-input-ref: ref to the search input DOM node + - on-close: optional fn called when Esc/Tab is pressed" + + [{:keys [focusable-ids nodes-ref on-enter searchable search-input-ref on-close]}] + (let [focused-id* (mf/use-state nil) + focused-id (deref focused-id*) + + focus-input! + (mf/use-fn + (mf/deps search-input-ref) + (fn [] + (reset! focused-id* nil) + (when-let [input (mf/ref-val search-input-ref)] + (dom/focus! input)))) + + on-key-down + (mf/use-fn + (mf/deps focused-id focusable-ids searchable) + (fn [event] + (cond + (kbd/down-arrow? event) + (do + (dom/prevent-default event) + (dom/stop-propagation event) + (if (nil? focused-id) + (reset! focused-id* (first focusable-ids)) + (let [idx (or (first (keep-indexed #(when (= %2 focused-id) %1) focusable-ids)) -1) + next-idx (mod (inc idx) (count focusable-ids)) + wrap-to-input? (and ^boolean searchable + (= next-idx 0) + (= idx (dec (count focusable-ids))))] + (if wrap-to-input? + (focus-input!) + (reset! focused-id* (nth focusable-ids next-idx nil)))))) + + (kbd/up-arrow? event) + (do + (dom/prevent-default event) + (dom/stop-propagation event) + (if (nil? focused-id) + (reset! focused-id* (last focusable-ids)) + (let [idx (or (first (keep-indexed #(when (= %2 focused-id) %1) focusable-ids)) 0) + prev-idx (dec idx) + wrap-to-input? (and ^boolean searchable (= prev-idx -1))] + (if wrap-to-input? + (focus-input!) + (reset! focused-id* (nth focusable-ids (mod prev-idx (count focusable-ids)) nil)))))) + + (kbd/enter? event) + (when focused-id + (dom/prevent-default event) + (dom/stop-propagation event) + (on-enter focused-id)) + + (or (kbd/esc? event) (kbd/tab? event)) + (do + (dom/prevent-default event) + (dom/stop-propagation event) + (reset! focused-id* nil) + (when on-close (on-close))))))] + + (mf/with-effect [focused-id] + (when (some? focused-id) + (when-let [node (obj/get (mf/ref-val nodes-ref) focused-id)] + (dom/scroll-into-view-if-needed! node {:block "nearest" :inline "nearest"})))) + + {:focused-id focused-id + :focused-id* focused-id* + :on-key-down on-key-down})) \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.cljs b/frontend/src/app/main/ui/ds/controls/shared/option.cljs index 0542268bc1..b313bf4263 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/option.cljs @@ -22,6 +22,12 @@ [:focused {:optional true} :boolean] [:dimmed {:optional true} :boolean] [:label {:optional true} :string] + [:avatar {:optional true} + [:maybe + [:map + [:size {:optional true} :string] + [:organization {:optional true} :any] + [:render-fn {:optional true} fn?]]]] [:aria-label {:optional true} [:maybe :string]] [:on-click {:optional true} fn?]] [:fn {:error/message "invalid data: missing required props"} @@ -33,9 +39,13 @@ (mf/defc option* {::mf/schema schema:option} - [{:keys [id ref label icon aria-label on-click selected focused dimmed] :rest props}] - (let [class (stl/css-case :option true + [{:keys [id ref label icon avatar aria-label on-click selected focused dimmed] :rest props}] + (let [render-avatar-fn (when avatar + (get avatar :render-fn)) + + class (stl/css-case :option true :option-with-icon (some? icon) + :option-with-avatar (fn? render-avatar-fn) :option-selected selected :option-current focused)] @@ -57,6 +67,9 @@ :aria-hidden (when label true) :aria-label (when (not label) aria-label)}]) + (when (fn? render-avatar-fn) + [:> render-avatar-fn {:avatar avatar}]) + [:span {:class (stl/css-case :option-text true :option-text-dimmed dimmed)} label] diff --git a/frontend/src/app/main/ui/ds/controls/shared/option.scss b/frontend/src/app/main/ui/ds/controls/shared/option.scss index 0c2462206b..978110d1f3 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/option.scss @@ -14,8 +14,7 @@ --options-empty: var(--color-canvas); display: grid; - align-items: center; - justify-items: start; + place-items: center start; grid-template-columns: 1fr auto; gap: var(--sp-xs); width: 100%; @@ -26,6 +25,7 @@ outline-offset: calc(-1 * $b-1); background-color: var(--options-bg-color); color: var(--options-fg-color); + cursor: default; &:hover, &[aria-selected="true"] { @@ -37,6 +37,11 @@ grid-template-columns: auto 1fr auto; } +.option-with-avatar { + grid-template-columns: auto 1fr auto; + gap: var(--sp-s); +} + .option-text { white-space: nowrap; overflow: hidden; @@ -56,6 +61,7 @@ .option-current { --options-outline-color: var(--color-accent-primary); + outline: $b-1 solid var(--options-outline-color); } @@ -63,3 +69,8 @@ --options-fg-color: var(--color-accent-primary); --options-icon-fg-color: var(--color-accent-primary); } + +.option-check { + color: var(--token-options-icon-fg-color); + min-width: var(--sp-l); +} diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs index 0191891398..3ed3d6b3a3 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.cljs @@ -9,10 +9,8 @@ [app.main.style :as stl]) (:require [app.common.data :as d] - [app.common.weak :refer [weak-key]] - [app.main.ui.ds.controls.shared.option :refer [option*]] - [app.main.ui.ds.controls.shared.token-option :refer [token-option*]] - [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.ds.controls.shared.render-option :refer [render-option]] + [app.main.ui.ds.foundations.assets.icon :as i] [cuerdas.core :as str] [rumext.v2 :as mf])) @@ -20,16 +18,30 @@ [:and :string [:fn {:error/message "invalid data: invalid icon"} #(contains? i/icon-list %)]]) +(def ^:private + xf:filter-blank-id + (filter #(str/blank? (get % :id)))) + +(def ^:private + xf:filter-non-blank-id + (remove #(str/blank? (get % :id)))) + (def schema:option "A schema for the option data structure expected to receive on props for the `options-dropdown*` component." [:map [:id {:optional true} :string] [:resolved-value {:optional true} - [:or :int :string :float]] + [:maybe [:or :int :string :float]]] [:name {:optional true} :string] + [:value {:optional true} :keyword] [:icon {:optional true} schema:icon-list] [:label {:optional true} :string] + [:avatar {:optional true} + [:map + [:size {:optional true} :string] + [:organization {:optional true} :any] + [:render-fn {:optional true} fn?]]] [:aria-label {:optional true} :string]]) (def ^:private schema:options-dropdown @@ -44,66 +56,6 @@ [:empty-to-end {:optional true} [:maybe :boolean]] [:align {:optional true} [:maybe [:enum :left :right]]]]) -(def ^:private - xf:filter-blank-id - (filter #(str/blank? (get % :id)))) - -(def ^:private - xf:filter-non-blank-id - (remove #(str/blank? (get % :id)))) - -(defn- render-option - [option ref on-click selected focused] - (let [id (get option :id) - name (get option :name) - type (get option :type)] - - (mf/html - (case type - :group - [:li {:class (stl/css :group-option) - :role "presentation" - :key (weak-key option)} - [:> icon* - {:icon-id i/arrow-down - :size "m" - :class (stl/css :option-check) - :aria-hidden (when name true)}] - (d/name name)] - - :separator - [:hr {:key (weak-key option) :class (stl/css :option-separator)}] - - :empty - [:li {:key (weak-key option) :class (stl/css :option-empty) :role "presentation"} - (get option :label)] - - ;; Token option - :token - [:> token-option* {:selected (= id selected) - :key (weak-key option) - :id id - :name name - :resolved (get option :resolved-value) - :ref ref - :role "option" - :focused (= id focused) - :on-click on-click}] - - ;; Normal option - [:> option* {:selected (= id selected) - :key (weak-key option) - :id id - :label (get option :label) - :aria-label (get option :aria-label) - :icon (get option :icon) - :ref ref - :role "option" - :focused (= id focused) - :dimmed (true? (:dimmed option)) - :on-click on-click}])))) - - (mf/defc options-dropdown* {::mf/schema schema:options-dropdown} [{:keys [ref on-click options selected focused empty-to-end align wrapper-ref class] :rest props}] @@ -140,4 +92,4 @@ [:hr {:class (stl/css :option-separator)}]) (for [option options-blank] - (render-option option ref on-click selected focused))])])) + (render-option option ref on-click selected focused))])])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss index b7c3d2e40a..0041dc1a9c 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/options_dropdown.scss @@ -25,9 +25,13 @@ padding-block: var(--sp-xs); margin-block-end: 0; max-block-size: $sz-400; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; z-index: var(--z-index-dropdown); + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + + &:focus { + outline: none; + } } .left-align { @@ -40,23 +44,5 @@ .option-separator { border: $b-1 solid var(--options-dropdown-border-color); - margin-block-start: var(--sp-xs); - margin-block-end: var(--sp-xs); -} - -.group-option, -.option-empty { - @include use-typography("body-small"); - display: flex; - align-items: center; - gap: var(--sp-xs); - color: var(--color-foreground-secondary); - padding-inline: var(--sp-s); - block-size: var(--sp-xxxl); -} - -.option-empty { - justify-content: center; - text-align: center; - padding: 0 px2rem(40); + margin-block: var(--sp-xs) var(--sp-xs); } diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs new file mode 100644 index 0000000000..ef015467be --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.cljs @@ -0,0 +1,69 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.ds.controls.shared.render-option + (:require-macros + [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.weak :refer [weak-key]] + [app.main.ui.ds.controls.shared.option :refer [option*]] + [app.main.ui.ds.controls.shared.token-option :refer [token-option*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [rumext.v2 :as mf])) + +(defn render-option + [option ref on-click selected focused] + (let [id (get option :id) + name (get option :name) + type (get option :type)] + + (mf/html + (case type + :group + [:li {:class (stl/css :group-option) + :role "presentation" + :key (weak-key option)} + [:> icon* + {:icon-id i/arrow-down + :size "m" + :class (stl/css :option-check) + :aria-hidden (when name true)}] + (d/name name)] + + :separator + [:hr {:key (weak-key option) :class (stl/css :option-separator)}] + + :empty + [:li {:key (weak-key option) :class (stl/css :option-empty) :role "presentation"} + (get option :label)] + + ;; Token option + :token + [:> token-option* {:selected (= id selected) + :key (weak-key option) + :id id + :name name + :resolved (get option :resolved-value) + :value (get option :value) + :ref ref + :role "option" + :focused (= id focused) + :on-click on-click}] + + ;; Normal option + [:> option* {:selected (= id selected) + :key (weak-key option) + :id id + :label (get option :label) + :aria-label (get option :aria-label) + :icon (get option :icon) + :avatar (get option :avatar) + :ref ref + :role "option" + :focused (= id focused) + :dimmed (true? (:dimmed option)) + :on-click on-click}])))) \ No newline at end of file diff --git a/frontend/src/app/main/ui/ds/controls/shared/render_option.scss b/frontend/src/app/main/ui/ds/controls/shared/render_option.scss new file mode 100644 index 0000000000..232efb42a5 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/shared/render_option.scss @@ -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 + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/_utils.scss" as *; + +.left-align { + inset-inline-start: var(--dropdown-offset, 0); +} + +.right-align { + inset-inline-end: var(--dropdown-offset, 0); +} + +.option-separator { + border: $b-1 solid var(--options-dropdown-border-color); + margin-block: var(--sp-xs) var(--sp-xs); +} + +.group-option, +.option-empty { + @include use-typography("body-small"); + + display: flex; + align-items: center; + gap: var(--sp-xs); + color: var(--color-foreground-secondary); + padding-inline: var(--sp-s); + block-size: var(--sp-xxxl); +} + +.option-check { + color: var(--token-options-icon-fg-color); + min-width: var(--sp-l); +} diff --git a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs new file mode 100644 index 0000000000..6e5d8e7d51 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.cljs @@ -0,0 +1,149 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.ds.controls.shared.searchable-options-dropdown + (:require-macros + [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.main.ui.ds.controls.input :as ds] + [app.main.ui.ds.controls.shared.dropdown-navigation :refer [use-dropdown-navigation]] + [app.main.ui.ds.controls.shared.render-option :refer [render-option]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] + [app.util.dom :as dom] + [app.util.i18n :as i18n :refer [tr]] + [app.util.object :as obj] + [app.util.timers :as ts] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(def ^:private schema:icon-list + [:and :string + [:fn {:error/message "invalid data: invalid icon"} #(contains? i/icon-list %)]]) + +(def schema:option + "A schema for the option data structure expected to receive on props + for the `options-dropdown*` component." + [:map + [:id {:optional true} :string] + [:resolved-value {:optional true} + [:or :int :string :float :map]] + [:name {:optional true} :string] + [:value {:optional true} :keyword] + [:icon {:optional true} schema:icon-list] + [:label {:optional true} :string] + [:aria-label {:optional true} :string]]) + +(def ^:private schema:options-dropdown + [:map + [:ref {:optional true} fn?] + [:class {:optional true} :string] + [:wrapper-ref {:optional true} :any] + [:placeholder {:optional true} :string] + [:on-click fn?] + [:options [:vector schema:option]] + [:selected {:optional true} :any] + [:align {:optional true} [:maybe [:enum :left :right]]]]) + +(mf/defc searchable-options-dropdown* + {::mf/schema schema:options-dropdown} + [{:keys [on-click options selected align class placeholder] :rest props}] + (let [align (d/nilv align :left) + + search* (mf/use-state "") + search (deref search*) + search-input-ref (mf/use-ref nil) + + list-ref (mf/use-ref nil) + nodes-ref (mf/use-ref nil) + + filtered-options + (mf/with-memo [options search] + (if (seq search) + (filterv (fn [opt] + (or (not= :token (:type opt)) + (str/includes? (str/lower (:name opt "")) + (str/lower search)))) + options) + options)) + + focusable-ids + (mf/with-memo [filtered-options] + (mapv :id (csu/focusable-options filtered-options))) + + on-search-change + (mf/use-fn + (fn [event] + (reset! search* (dom/get-target-val event)))) + + set-option-ref + (mf/use-fn + (fn [node] + (when node + (let [state (d/nilv (mf/ref-val nodes-ref) #js {}) + id (dom/get-data node "id")] + (mf/set-ref-val! nodes-ref (obj/set! state id node)) + (fn [] + (let [state (d/nilv (mf/ref-val nodes-ref) #js {})] + (mf/set-ref-val! nodes-ref (obj/unset! state id)))))))) + + {:keys [focused-id focused-id* on-key-down]} + (use-dropdown-navigation + {:focusable-ids focusable-ids + :nodes-ref nodes-ref + :on-enter (fn [id] + (when-let [node (obj/get (mf/ref-val nodes-ref) id)] + (.click node))) + :searchable true + :search-input-ref search-input-ref + :on-close nil}) + + on-click-inner + (mf/use-fn + (mf/deps on-click) + (fn [event] + (dom/stop-propagation event) + (on-click event))) + + list-props + (mf/spread-props props + {:class [class (stl/css-case :option-list true + :left-align (= align :left) + :right-align (= align :right))] + :ref list-ref + :tab-index "-1" + :role "listbox" + :on-key-down on-key-down})] + + (mf/with-effect [] + (ts/schedule 0 + #(if (mf/ref-val search-input-ref) + (dom/focus! (mf/ref-val search-input-ref)) + (when-let [list (mf/ref-val list-ref)] + (dom/focus! list))))) + + (mf/with-effect [focused-id] + (when (some? focused-id) + (when-let [list (mf/ref-val list-ref)] + (when-not (dom/active? list) + (dom/focus! list))) + (when-let [node (obj/get (mf/ref-val nodes-ref) focused-id)] + (dom/scroll-into-view-if-needed! node {:block "nearest" :inline "nearest"})))) + + [:> :ul list-props + [:li {:class (stl/css :option-search) + :role "presentation"} + [:> ds/input* {:placeholder (or placeholder (tr "dashboard.search-placeholder")) + :value search + :ref search-input-ref + :variant "comfortable" + :on-change on-search-change + :on-click #(reset! focused-id* nil) + :on-key-down on-key-down}]] + + (for [option filtered-options] + (render-option option set-option-ref on-click-inner selected focused-id))])) diff --git a/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss new file mode 100644 index 0000000000..cbeae912d8 --- /dev/null +++ b/frontend/src/app/main/ui/ds/controls/shared/searchable_options_dropdown.scss @@ -0,0 +1,47 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/_borders.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as *; +@use "ds/_utils.scss" as *; + +.option-list { + --options-dropdown-icon-fg-color: var(--color-foreground-secondary); + --options-dropdown-bg-color: var(--color-background-tertiary); + --options-dropdown-outline-color: none; + --options-dropdown-border-color: var(--color-background-quaternary); + + position: absolute; + inset-block-start: $sz-36; + inline-size: var(--dropdown-width, 100%); + transform: translateX(var(--dropdown-translate-distance, 0)); + background-color: var(--options-dropdown-bg-color); + border-radius: $br-8; + border: $b-1 solid var(--options-dropdown-border-color); + padding-block: var(--sp-xs); + margin-block-end: 0; + max-block-size: $sz-400; + overflow: hidden auto; + z-index: var(--z-index-dropdown); + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); + + &:focus { + outline: none; + } +} + +.left-align { + inset-inline-start: var(--dropdown-offset, 0); +} + +.right-align { + inset-inline-end: var(--dropdown-offset, 0); +} + +.option-search { + padding: var(--sp-xs); +} diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs index 11667ba8f8..2d989bca02 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.cljs @@ -18,7 +18,8 @@ [:map [:id {:optiona true} :string] [:ref some?] - [:resolved {:optional true} [:or :int :string :float]] + [:resolved {:optional true} [:maybe [:or :int :string :float :map]]] + [:value {:optional true} [:maybe [:or :int :string :float :map]]] [:name {:optional true} :string] [:on-click {:optional true} fn?] [:selected {:optional true} :boolean] @@ -26,7 +27,7 @@ (mf/defc token-option* {::mf/schema schema:token-option} - [{:keys [id name on-click selected ref focused resolved] :rest props}] + [{:keys [id name on-click selected ref focused resolved value] :rest props}] (let [internal-id (mf/use-id) id (d/nilv id internal-id) element-ref (mf/use-ref nil)] @@ -55,10 +56,14 @@ :trigger-ref element-ref :id (dm/str id "-name") :class (stl/css :option-text)} - ;; Add ellipsis + [:span {:aria-labelledby (dm/str id "-name") + :class (stl/css :option-name) :ref element-ref} name]] - (when resolved - [:> :span {:class (stl/css :option-pill)} - resolved])])) + (when (and resolved (not (map? resolved))) + [:span {:class (stl/css :option-pill)} + resolved]) + (when (and (nil? resolved) value) + [:span {:class (stl/css :option-pill)} + "--"])])) diff --git a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss index 884ddfea54..14f4c6b3a8 100644 --- a/frontend/src/app/main/ui/ds/controls/shared/token_option.scss +++ b/frontend/src/app/main/ui/ds/controls/shared/token_option.scss @@ -7,15 +7,17 @@ @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as *; +@use "ds/mixins.scss" as *; .token-option { --token-options-fg-color: var(--color-foreground-primary); --token-options-bg-color: unset; --token-options-empty: var(--color-canvas); + @include use-typography("body-small"); + display: grid; - align-items: center; - justify-items: start; + place-items: center start; grid-template-columns: 1fr auto; gap: $sz-6; width: 100%; @@ -26,10 +28,10 @@ outline-offset: calc(-1 * $b-1); background-color: var(--token-options-bg-color); color: var(--token-options-fg-color); - overflow: hidden; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + &:hover, &[aria-selected="true"] { --token-options-bg-color: var(--color-background-quaternary); @@ -51,11 +53,13 @@ .option-current { --token-options-outline-color: var(--color-accent-primary); + outline: $b-1 solid var(--token-options-outline-color); } .option-pill { @include use-typography("code-font"); + color: var(--color-foreground-secondary); background-color: var(--color-background-primary); border-radius: $br-6; @@ -75,3 +79,7 @@ color: var(--token-options-icon-fg-color); min-width: var(--sp-l); } + +.option-name { + @include text-ellipsis; +} diff --git a/frontend/src/app/main/ui/ds/controls/switch.scss b/frontend/src/app/main/ui/ds/controls/switch.scss index c5abbf3a00..01eae97f00 100644 --- a/frontend/src/app/main/ui/ds/controls/switch.scss +++ b/frontend/src/app/main/ui/ds/controls/switch.scss @@ -13,10 +13,8 @@ .switch { --switch-label-foreground-color: var(--color-foreground-primary); - --switch-track-outline-color: none; --switch-track-shadow: inset 0 1px 2px var(--color-shadow-light); - --switch-thumb-shadow: 0 1px 2px var(--color-shadow-light); display: grid; @@ -29,7 +27,6 @@ &.off { --switch-track-justify-content: start; --switch-track-background-color: var(--color-foreground-secondary); - --switch-thumb-width: #{px2rem(14)}; --switch-thumb-height: #{px2rem(14)}; --switch-thumb-background-color: var(--color-accent-off); @@ -39,7 +36,6 @@ &.neutral { --switch-track-justify-content: center; --switch-track-background-color: var(--color-accent-tertiary); - --switch-thumb-width: #{px2rem(14)}; --switch-thumb-height: #{px2rem(4)}; --switch-thumb-background-color: var(--color-accent-off); @@ -49,7 +45,6 @@ &.on { --switch-track-justify-content: end; --switch-track-background-color: var(--color-accent-tertiary); - --switch-thumb-width: #{px2rem(14)}; --switch-thumb-height: #{px2rem(14)}; --switch-thumb-background-color: var(--color-accent-off); @@ -58,24 +53,21 @@ &[disabled] { pointer-events: none; + --switch-label-foreground-color: var(--color-foreground-secondary); - --switch-track-shadow: none; - --switch-thumb-shadow: none; } &.off[disabled] { --switch-track-background-color: var(--color-background-primary); --switch-track-border-color: var(--color-background-disabled); - --switch-thumb-background-color: var(--color-background-disabled); } &.on[disabled], &.neutral[disabled] { --switch-track-background-color: var(--color-background-disabled); - --switch-thumb-background-color: var(--color-background-primary); } @@ -90,6 +82,7 @@ .switch-label { @include t.use-typography("body-small"); + color: var(--switch-label-foreground-color); user-select: none; } diff --git a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss index 1112a3f816..97f1a12fda 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/hint_message.scss @@ -11,6 +11,7 @@ --hint-color: var(--color-foreground-secondary); @include use-typography("body-small"); + color: var(--hint-color); } diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs index 58a3202c80..c0ee6f245e 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.cljs @@ -37,6 +37,8 @@ has-hint hint-type max-length variant slot-start slot-end + data-option-focused + input-wrapper-ref aria-label] :rest props} ref] (let [input-ref (mf/use-ref) type (d/nilv type "text") @@ -74,7 +76,9 @@ (dom/select-node input-node) (dom/focus! input-node))))] - [:div {:class [inside-class class]} + [:div {:class [inside-class class] + :ref input-wrapper-ref + :data-option-focused data-option-focused} (when (some? slot-start) slot-start) (when (some? icon) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss index 80068f0c2b..adc4f5a301 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/input_field.scss @@ -22,7 +22,6 @@ align-items: center; position: relative; inline-size: 100%; - background: var(--input-bg-color); border-radius: $br-8; padding: 0 var(--input-padding-size, var(--sp-s)); @@ -41,6 +40,11 @@ --input-bg-color: var(--color-background-primary); --input-outline-color: var(--color-background-quaternary); } + + &[data-option-focused="true"]:has(*:focus-visible) { + --input-bg-color: var(--color-background-tertiary); + --input-outline-color: none; + } } .variant-dense, @@ -80,12 +84,10 @@ border: none; background: none; inline-size: 100%; - font-family: inherit; font-size: inherit; font-weight: inherit; line-height: inherit; - color: var(--input-fg-color); &:focus-visible { @@ -102,7 +104,6 @@ &:is(:autofill, :autofill:hover, :autofill:focus, :autofill:active) { -webkit-text-fill-color: var(--input-fg-color); - -webkit-background-clip: text; background-clip: text; caret-color: var(--input-bg-color); } diff --git a/frontend/src/app/main/ui/ds/controls/utilities/label.scss b/frontend/src/app/main/ui/ds/controls/utilities/label.scss index 405beb6c6f..4ba6a988dc 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/label.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/label.scss @@ -13,6 +13,7 @@ --label-optional-color: var(--color-foreground-secondary); @include use-typography("body-small"); + color: var(--label-color); display: flex; gap: var(--sp-xs); diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs index 7af90350e4..86146fe36b 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.cljs @@ -25,22 +25,29 @@ [:property {:optional true} [:maybe :string]] [:value :any] [:disabled {:optional true} :boolean] + [:is-open {:optional true} :boolean] [:slot-start {:optional true} [:maybe some?]] [:on-click {:optional true} fn?] [:on-token-key-down fn?] [:on-blur {:optional true} fn?] [:on-focus {:optional true} fn?] + [:tooltip-placement {:optional true} + [:maybe [:enum "top" "bottom" "left" "right" "top-right" "bottom-right" "bottom-left" "top-left"]]] [:detach-token fn?]]) (mf/defc token-field* {::mf/schema schema:token-field} [{:keys [id label value slot-start disabled class - on-click on-token-key-down on-blur detach-token - token-wrapper-ref token-detach-btn-ref on-focus property]}] + on-click on-token-key-down on-blur detach-token tooltip-placement + token-wrapper-ref token-detach-btn-ref on-focus property is-open + token-has-errors]}] (let [set-active? (some? id) - content (if set-active? - label - (tr "ds.inputs.token-field.no-active-token-option" label)) + + content (cond + token-has-errors (tr "workspace.tokens.ref-not-valid") + (not set-active?) (tr "ds.inputs.token-field.no-active-token-option" label) + :else label) + default-id (mf/use-id) id (d/nilv id default-id) pill-ref (mf/use-ref nil) @@ -77,20 +84,24 @@ [:button {:on-click on-click :ref pill-ref :class (stl/css-case :pill true - :no-set-pill (not set-active?) + :no-set-pill (or (not set-active?) + token-has-errors) :pill-disabled disabled) :disabled disabled :aria-labelledby (dm/str id "-pill") :on-key-down on-token-key-down} value - (when-not set-active? + (when (or (not set-active?) + token-has-errors) [:div {:class (stl/css :pill-dot)}])]]] (when-not ^boolean disabled [:> icon-button* {:variant "ghost" - :class (stl/css :invisible-button) + :class (stl/css-case :invisible-button true + :invisible-btn-dropdown-open is-open) :tooltip-class (stl/css :button-tooltip) + :tooltip-placement tooltip-placement :icon i/broken-link :ref token-detach-btn-ref - :aria-label (tr "ds.inputs.token-field.detach-token") + :aria-label (tr "token-actions.detach-token") :on-click detach-token}])]])) diff --git a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss index e96c5b583e..4cb3a61a0d 100644 --- a/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss +++ b/frontend/src/app/main/ui/ds/controls/utilities/token_field.scss @@ -18,10 +18,10 @@ --token-field-outline-color: none; --token-field-height: var(--sp-xxxl); --token-field-margin: unset; + display: inline-flex; column-gap: var(--sp-xs); align-items: center; - position: relative; inline-size: 100%; background: var(--token-field-bg-color); border-radius: $br-8; @@ -38,6 +38,7 @@ --token-field-outline-color: var(--color-accent-primary); } } + .token-field-wrapper { inline-size: 100%; } @@ -48,8 +49,10 @@ .token-field-disabled { user-select: none; + --token-field-bg-color: var(--color-background-primary); --token-field-outline-color: var(--color-background-quaternary); + &:hover { --token-field-bg-color: var(--color-background-primary); --token-field-outline-color: var(--color-background-quaternary); @@ -60,8 +63,10 @@ --pill-border-color: var(--color-token-border); --pill-bg-color: var(--color-background-tertiary); --pill-fg-color: var(--color-token-foreground); + @include t.use-typography("code-font"); - @include textEllipsis; + @include text-ellipsis; + display: block; block-size: var(--sp-xxl); inline-size: fit-content; @@ -72,24 +77,29 @@ border-radius: $br-6; padding-inline: $sz-6; max-inline-size: 100%; + &:hover { --pill-bg-color: var(--color-token-background); --pill-fg-color: var(--color-foreground-primary); --pill-border-color: var(--color-token-foreground); } + &:focus-visible { --pill-bg-color: var(--color-token-background); --pill-fg-color: var(--color-foreground-primary); --pill-border-color: var(--color-accent-primary); + outline: none; } } .pill-disabled { user-select: none; + --pill-bg-color: none; --pill-fg-color: var(--color-foreground-secondary); --pill-border-color: var(--color-token-border); + &:hover { --pill-bg-color: none; --pill-fg-color: var(--color-foreground-secondary); @@ -101,7 +111,9 @@ --pill-bg-color: none; --pill-fg-color: var(--color-foreground-secondary); --pill-border-color: var(--color-token-border); + position: relative; + &:hover { --pill-bg-color: none; --pill-fg-color: var(--color-foreground-secondary); @@ -127,16 +139,24 @@ inset-block-start: 0; opacity: var(--opacity-button); background-color: var(--color-background-quaternary); + &:hover { background-color: var(--color-background-quaternary); + --opacity-button: 1; } + &:focus { background-color: var(--color-background-quaternary); + --opacity-button: 1; } } +.invisible-btn-dropdown-open { + --opacity-button: 0; +} + .content-wrapper { inline-size: 100%; } diff --git a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs index 0e83173bee..f2ccc02d4a 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/icon.cljs @@ -245,12 +245,19 @@ (def ^:icon-id status-update "status-update") (def ^:icon-id status-wrong "status-wrong") (def ^:icon-id stroke-arrow "stroke-arrow") +(def ^:icon-id stroke-center "stroke-center") (def ^:icon-id stroke-circle "stroke-circle") +(def ^:icon-id stroke-dashed "stroke-dashed") (def ^:icon-id stroke-diamond "stroke-diamond") +(def ^:icon-id stroke-dotted "stroke-dotted") +(def ^:icon-id stroke-inside "stroke-inside") +(def ^:icon-id stroke-mixed "stroke-mixed") +(def ^:icon-id stroke-outside "stroke-outside") (def ^:icon-id stroke-rectangle "stroke-rectangle") (def ^:icon-id stroke-rounded "stroke-rounded") (def ^:icon-id stroke-size "stroke-size") (def ^:icon-id stroke-squared "stroke-squared") +(def ^:icon-id stroke-solid "stroke-solid") (def ^:icon-id stroke-triangle "stroke-triangle") (def ^:icon-id svg "svg") (def ^:icon-id swatches "swatches") @@ -315,22 +322,16 @@ (mf/defc icon* {::mf/schema schema:icon} [{:keys [icon-id size class] :rest props}] - (let [props (mf/spread-props props - {:class [class (stl/css :icon)] - :width icon-size-m - :height icon-size-m}) - - size-px (cond (= size "l") icon-size-l + (let [size-px (cond (= size "l") icon-size-l (= size "s") icon-size-s :else icon-size-m) - offset (if (or (= size "s") (= size "m")) - (/ (- icon-size-m size-px) 2) - 0)] + props (mf/spread-props props + {:class [class (stl/css :icon)] + :width size-px + :height size-px})] [:> :svg props [:use {:href (dm/str "#icon-" icon-id) :width size-px - :height size-px - :x offset - :y offset}]])) + :height size-px}]])) diff --git a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs index 428d582e97..56f5b076a5 100644 --- a/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs +++ b/frontend/src/app/main/ui/ds/foundations/assets/raw_svg.cljs @@ -20,6 +20,7 @@ (def ^:svg-id logo-error-screen "logo-error-screen") (def ^:svg-id logo-subscription "logo-subscription") (def ^:svg-id logo-subscription-light "logo-subscription-light") +(def ^:svg-id nitrate-welcome "nitrate-welcome") (def ^:svg-id marketing-arrows "marketing-arrows") (def ^:svg-id marketing-exchange "marketing-exchange") (def ^:svg-id marketing-file "marketing-file") @@ -38,3 +39,4 @@ (assert (contains? raw-svg-list id) "invalid raw svg id") [:> "svg" props [:use {:href (dm/str "#asset-" id)}]]) + diff --git a/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss b/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss index 207b2236af..a459eb5e74 100644 --- a/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss +++ b/frontend/src/app/main/ui/ds/foundations/utilities/token/token_status.scss @@ -5,6 +5,6 @@ // Copyright (c) KALEIDOS INC .token-icon { - fill: currentColor; + fill: currentcolor; stroke: none; } diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.cljs b/frontend/src/app/main/ui/ds/layers/layer_button.cljs index 315ad56e88..268e33f961 100644 --- a/frontend/src/app/main/ui/ds/layers/layer_button.cljs +++ b/frontend/src/app/main/ui/ds/layers/layer_button.cljs @@ -27,8 +27,7 @@ [{:keys [label description class is-expandable expanded icon on-toggle-expand on-context-menu children] :rest props}] (let [button-props (mf/spread-props props {:class [class (stl/css-case :layer-button true - :layer-button--expandable is-expandable - :layer-button--expanded expanded)] + :layer-button-expanded expanded)] :type "button" :on-click on-toggle-expand :on-context-menu on-context-menu})] diff --git a/frontend/src/app/main/ui/ds/layers/layer_button.scss b/frontend/src/app/main/ui/ds/layers/layer_button.scss index 56e59e8acf..2850af269f 100644 --- a/frontend/src/app/main/ui/ds/layers/layer_button.scss +++ b/frontend/src/app/main/ui/ds/layers/layer_button.scss @@ -16,9 +16,7 @@ display: flex; justify-content: space-between; - block-size: var(--layer-button-block-size); - background: var(--layer-button-background); color: var(--layer-button-text); } @@ -27,17 +25,15 @@ @include use-typography("body-small"); appearance: none; - flex: 1; display: flex; align-items: center; - border: none; background: none; color: inherit; } -.layer-button--expanded { +.layer-button-expanded { & .layer-button-name { color: var(--color-foreground-primary); } diff --git a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss index 90af8cf778..b8e7dd8f8d 100644 --- a/frontend/src/app/main/ui/ds/layout/tab_switcher.scss +++ b/frontend/src/app/main/ui/ds/layout/tab_switcher.scss @@ -10,15 +10,14 @@ .tabs { --tabs-bg-color: var(--color-background-secondary); + display: grid; grid-template-rows: auto 1fr; } .padding-wrapper { - padding-inline-start: var(--tabs-nav-padding-inline-start, 0); - padding-inline-end: var(--tabs-nav-padding-inline-end, 0); - padding-block-start: var(--tabs-nav-padding-block-start, 0); - padding-block-end: var(--tabs-nav-padding-block-end, 0); + padding-inline: var(--tabs-nav-padding-inline-start, 0) var(--tabs-nav-padding-inline-end, 0); + padding-block: var(--tabs-nav-padding-block-start, 0) var(--tabs-nav-padding-block-end, 0); } // TAB NAV @@ -44,6 +43,7 @@ grid-auto-flow: column; gap: var(--sp-xxs); width: 100%; + // Removing margin bottom from default ul margin-block-end: 0; border-radius: $br-8; @@ -68,7 +68,6 @@ height: $sz-32; border: none; border-radius: $br-8; - padding: 0 var(--sp-s); outline: $b-1 solid var(--tabs-item-outline-color); display: grid; grid-auto-flow: column; @@ -89,6 +88,7 @@ .tab-text { @include use-typography("headline-small"); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -102,6 +102,7 @@ .tab-panel { --tab-panel-outline-color: none; + &:focus { outline: none; } diff --git a/frontend/src/app/main/ui/ds/mixins.scss b/frontend/src/app/main/ui/ds/mixins.scss index 32e2dce255..f43b690c02 100644 --- a/frontend/src/app/main/ui/ds/mixins.scss +++ b/frontend/src/app/main/ui/ds/mixins.scss @@ -8,7 +8,7 @@ @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; -@mixin textEllipsis { +@mixin text-ellipsis { display: block; max-width: 99%; overflow: hidden; @@ -16,7 +16,7 @@ white-space: nowrap; } -@mixin twoLineTextEllipsis { +@mixin two-line-text-ellipsis { max-width: 99%; overflow: hidden; text-overflow: ellipsis; @@ -33,6 +33,7 @@ /// @param {Length} $border - Inner transparent border size /// @param {Bool} $include-selection - Include ::selection styles /// @param {Bool} $include-placeholder - Include placeholder styles + @mixin custom-scrollbar( $thumb-color: #aab5ba4d, $thumb-hover-color: #aab5bab3, @@ -84,12 +85,7 @@ @if $include-placeholder { &::placeholder { @include t.use-typography("body-small"); - color: var(--color-foreground-secondary); - } - // Legacy webkit - &::-webkit-input-placeholder { - @include t.use-typography("body-small"); color: var(--color-foreground-secondary); } } diff --git a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs index efa97a9247..a8fbe76c0e 100644 --- a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs +++ b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.cljs @@ -9,7 +9,6 @@ [app.main.style :as stl]) (:require [app.common.data.macros :as dm] - [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -29,13 +28,11 @@ [:level [:enum :default :info :warning :error :success]] [:type [:enum :toast :context]] [:appearance {:optional true} [:enum :neutral :ghost]] - [:is-html {:optional true} :boolean] - [:show-detail {:optional true} [:maybe :boolean]] - [:on-toggle-detail {:optional true} [:maybe fn?]]]) + [:is-html {:optional true} :boolean]]) (mf/defc notification-pill* {::mf/schema schema:notification-pill} - [{:keys [level type is-html appearance detail children show-detail on-toggle-detail]}] + [{:keys [level type is-html appearance detail children]}] (let [class (stl/css-case :appearance-neutral (= appearance :neutral) :appearance-ghost (= appearance :ghost) :with-detail detail @@ -60,16 +57,7 @@ children)] (when detail - [:div {:class (stl/css :error-detail)} - [:div {:class (stl/css :error-detail-title)} - [:> icon-button* - {:icon (if show-detail "arrow-down" "arrow") - :aria-label (tr "workspace.notification-pill.detail") - :icon-class (stl/css :expand-icon) - :variant "action" - :on-click on-toggle-detail}] - [:div {:on-click on-toggle-detail} - (tr "workspace.notification-pill.detail")]] - (when show-detail - [:div {:class (stl/css :error-detail-content) - :dangerouslySetInnerHTML #js {:__html detail}}])])])) + [:details {:class (stl/css :error-detail)} + [:summary {:class (stl/css :error-detail-summary)} (tr "workspace.notification-pill.detail")] + [:div {:class (stl/css :error-detail-content) + :dangerouslySetInnerHTML #js {:__html detail}}]])])) diff --git a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss index 3cf7b1bd2c..0124798e98 100644 --- a/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss +++ b/frontend/src/app/main/ui/ds/notifications/shared/notification_pill.scss @@ -22,10 +22,8 @@ border: $b-1 solid var(--notification-border-color); border-radius: $br-8; padding: var(--notification-padding); - display: flex; gap: var(--sp-s); - color: var(--notification-fg-color); // Targets the potential links included by the creator in the children props. @@ -100,20 +98,38 @@ } .error-detail { - overflow: auto; + list-style: none; + padding-inline-start: var(--sp-xxl); } -.error-detail-title { - display: flex; - align-items: center; +.error-detail-summary { + list-style: none; cursor: pointer; -} + position: relative; -.expand-icon { - --icon-fill-color: var(--color-foreground-primary); - --icon-stroke-color: var(--color-foreground-primary); + &::marker { + display: none; + } + + &::before { + content: "‣"; + position: absolute; + inset-block-start: 0; + inset-inline-start: -1.5rem; + inline-size: $sz-16; + text-box: trim-start cap alphabetic; + text-align: end; + font-size: 1lh; + line-height: 1; + font-weight: 700; + color: currentcolor; + } } .error-detail-content { - padding-left: var(--sp-xxxl); + padding-block-start: var(--sp-s); + + & ul { + list-style: disc inside; + } } diff --git a/frontend/src/app/main/ui/ds/notifications/toast.cljs b/frontend/src/app/main/ui/ds/notifications/toast.cljs index f83dbd5fd6..c00827eb89 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.cljs +++ b/frontend/src/app/main/ui/ds/notifications/toast.cljs @@ -21,13 +21,11 @@ [:level {:optional true} [:maybe [:enum :default :info :warning :error :success]]] [:appearance {:optional true} [:enum :neutral :ghost]] [:is-html {:optional true} :boolean] - [:show-detail {:optional true} [:maybe :boolean]] - [:on-close {:optional true} fn?] - [:on-toggle-detail {:optional true} [:maybe fn?]]]) + [:on-close {:optional true} fn?]]) (mf/defc toast* {::mf/schema schema:toast} - [{:keys [class level appearance type is-html children detail show-detail on-close on-toggle-detail] :rest props}] + [{:keys [class level appearance type is-html children detail on-close] :rest props}] (let [class (dm/str class " " (stl/css :toast)) level (if (string? level) (keyword level) @@ -47,9 +45,7 @@ :type type :is-html is-html :appearance appearance - :detail detail - :show-detail show-detail - :on-toggle-detail on-toggle-detail} children] + :detail detail} children] ;; TODO: this should be a buttom from the DS, but this variant is not designed yet. diff --git a/frontend/src/app/main/ui/ds/notifications/toast.scss b/frontend/src/app/main/ui/ds/notifications/toast.scss index c09629bfdd..9a7728d75c 100644 --- a/frontend/src/app/main/ui/ds/notifications/toast.scss +++ b/frontend/src/app/main/ui/ds/notifications/toast.scss @@ -18,7 +18,6 @@ min-inline-size: $sz-224; max-inline-size: $sz-480; - display: block; position: fixed; inset-block-start: var(--toast-inset-block-start-position); diff --git a/frontend/src/app/main/ui/ds/product/avatar.scss b/frontend/src/app/main/ui/ds/product/avatar.scss index 36952c13d7..a84777a71f 100644 --- a/frontend/src/app/main/ui/ds/product/avatar.scss +++ b/frontend/src/app/main/ui/ds/product/avatar.scss @@ -36,6 +36,7 @@ .is-selected { --border-color: var(--color-accent-primary); + padding: var(--sp-xxs); } diff --git a/frontend/src/app/main/ui/ds/product/empty_placeholder.scss b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss index 2850b67eb5..d0e2a8dfa1 100644 --- a/frontend/src/app/main/ui/ds/product/empty_placeholder.scss +++ b/frontend/src/app/main/ui/ds/product/empty_placeholder.scss @@ -22,8 +22,7 @@ .text-wrapper { display: grid; grid-auto-rows: auto; - align-self: center; - justify-self: center; + place-self: center center; max-width: $sz-400; } diff --git a/frontend/src/app/main/ui/ds/product/empty_state.scss b/frontend/src/app/main/ui/ds/product/empty_state.scss index b0612ecec0..60f05e85b9 100644 --- a/frontend/src/app/main/ui/ds/product/empty_state.scss +++ b/frontend/src/app/main/ui/ds/product/empty_state.scss @@ -31,6 +31,7 @@ .text { @include t.use-typography("body-small"); + text-align: center; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/ds/product/input_with_meta.scss b/frontend/src/app/main/ui/ds/product/input_with_meta.scss index a01190d120..17b7e8c65a 100644 --- a/frontend/src/app/main/ui/ds/product/input_with_meta.scss +++ b/frontend/src/app/main/ui/ds/product/input_with_meta.scss @@ -15,6 +15,7 @@ --input-meta-background: var(--color-background-tertiary); @include t.use-typography("body-small"); + border-radius: $br-8; background-color: var(--input-meta-background); padding: var(--sp-s); @@ -28,6 +29,7 @@ &:hover { --input-meta-background: var(--color-background-quaternary); + cursor: text; } } diff --git a/frontend/src/app/main/ui/ds/product/loader.scss b/frontend/src/app/main/ui/ds/product/loader.scss index 772049d573..f552bad7be 100644 --- a/frontend/src/app/main/ui/ds/product/loader.scss +++ b/frontend/src/app/main/ui/ds/product/loader.scss @@ -78,7 +78,7 @@ } .loader { - fill: currentColor; + fill: currentcolor; width: var(--icon-width); } diff --git a/frontend/src/app/main/ui/ds/product/milestone.scss b/frontend/src/app/main/ui/ds/product/milestone.scss index 5e276d23a0..6a60a52804 100644 --- a/frontend/src/app/main/ui/ds/product/milestone.scss +++ b/frontend/src/app/main/ui/ds/product/milestone.scss @@ -11,19 +11,13 @@ .milestone { border: $b-1 solid var(--border-color, transparent); border-radius: $br-8; - background: var(--color-background-primary); - display: grid; - grid-template-areas: - "avatar name button" - "avatar content button"; - grid-template-rows: auto 1fr; - grid-template-columns: calc(var(--sp-xxl) + var(--sp-l)) 1fr auto; - + grid-template: + "avatar name button" auto "avatar content button" 1fr / calc(var(--sp-xxl) + var(--sp-l)) + 1fr auto; padding: var(--sp-s) 0; align-items: center; - column-gap: var(--sp-s); &.is-selected, @@ -60,6 +54,7 @@ .date { @include t.use-typography("body-small"); + grid-area: content; color: var(--color-foreground-secondary); } diff --git a/frontend/src/app/main/ui/ds/product/milestone_group.scss b/frontend/src/app/main/ui/ds/product/milestone_group.scss index 43c71ce334..0903a24921 100644 --- a/frontend/src/app/main/ui/ds/product/milestone_group.scss +++ b/frontend/src/app/main/ui/ds/product/milestone_group.scss @@ -11,19 +11,13 @@ .milestone { border: $b-1 solid var(--border-color, transparent); border-radius: $br-8; - background: var(--color-background-primary); - display: grid; - grid-template-areas: - "avatar name button" - "avatar content button"; - grid-template-rows: auto 1fr; - grid-template-columns: calc(var(--sp-xxl) + var(--sp-l)) 1fr auto; - + grid-template: + "avatar name button" auto "avatar content button" 1fr / calc(var(--sp-xxl) + var(--sp-l)) + 1fr auto; padding: var(--sp-s) 0; align-items: center; - column-gap: var(--sp-s); &.is-selected, @@ -39,12 +33,14 @@ .name { @include t.use-typography("body-small"); + grid-area: name; color: var(--color-foreground-primary); } .toggle-message { @include t.use-typography("body-small"); + grid-area: name; } @@ -95,6 +91,7 @@ &:hover { color: var(--color-accent-primary); + --icon-stroke-color: var(--color-accent-primary); } } diff --git a/frontend/src/app/main/ui/ds/product/panel_title.scss b/frontend/src/app/main/ui/ds/product/panel_title.scss index e0419221e4..fcb59ea43b 100644 --- a/frontend/src/app/main/ui/ds/product/panel_title.scss +++ b/frontend/src/app/main/ui/ds/product/panel_title.scss @@ -19,6 +19,7 @@ .panel-title-text { @include t.use-typography("headline-small"); + flex-grow: 1; text-align: center; color: var(--color-foreground-primary); diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs index 5dca183533..10e9638cf2 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.cljs @@ -17,8 +17,52 @@ (def ^:private ^:const overlay-offset 32) +;; Global state for tooltip coordination (defonce active-tooltip (atom nil)) +;; Registry of visible tooltips to detect nested tooltips +;; Map: tooltip-id -> trigger-element +(defonce ^:private tooltip-registry (atom {})) + +;; Track tooltips that are "about to show" - used to prevent race conditions +;; when both parent and child schedule their show at the same time. +;; Map: tooltip-id -> trigger-element +(defonce ^:private pending-tooltips (atom {})) + +(defn- mark-pending + "Mark a tooltip as pending (scheduled to show soon). + Used to detect potential nested tooltips during race condition window." + [tooltip-id trigger-el] + (swap! pending-tooltips assoc tooltip-id trigger-el)) + +(defn- clear-pending + "Clear the pending state (tooltip showed or cancelled)." + [tooltip-id] + (swap! pending-tooltips dissoc tooltip-id)) + +(defn- register-tooltip + "Register this tooltip in the global registry when it becomes visible. + Used to detect nested tooltips." + [tooltip-id trigger-el] + (swap! tooltip-registry assoc tooltip-id trigger-el) + (clear-pending tooltip-id)) + +(defn- unregister-tooltip + "Unregister this tooltip from the global registry when it hides." + [tooltip-id] + (swap! tooltip-registry dissoc tooltip-id) + (clear-pending tooltip-id)) + +(defn- has-descendant-tooltip? + "Check if there's a registered or pending tooltip that is a descendant of trigger-el. + If so, we should NOT show the parent tooltip." + [trigger-el] + (let [all-tooltips (merge @tooltip-registry @pending-tooltips)] + (some (fn [[_ entry-el]] + (when (some? entry-el) + (dom/child? entry-el trigger-el))) + all-tooltips))) + (defn- clear-schedule [ref] (when-let [schedule (mf/ref-val ref)] @@ -175,7 +219,7 @@ (deref placement*) delay - (d/nilv delay 300) + (d/nilv delay 700) schedule-ref (mf/use-ref nil) @@ -191,15 +235,27 @@ (when-not (.-hidden js/document) (let [trigger-el (mf/ref-val trigger-ref)] (clear-schedule schedule-ref) - (add-schedule schedule-ref (d/nilv delay 300) - (fn [] - (when-let [active @active-tooltip] - (when (not= (:id active) tooltip-id) - (when-let [tooltip-el (dom/get-element (:id active))] - (dom/set-css-property! tooltip-el "display" "none")) - (reset! active-tooltip nil))) - (reset! active-tooltip {:id tooltip-id :trigger trigger-el}) - (reset! visible* true))))))) + + ;; Check if there's a registered or pending tooltip that is a descendant of our trigger. + ;; If so, skip showing this tooltip and let the innermost one show instead. + (when-not (has-descendant-tooltip? trigger-el) + ;; Mark as pending BEFORE scheduling (helps prevent race conditions) + (mark-pending tooltip-id trigger-el) + + (add-schedule schedule-ref (d/nilv delay 300) + (fn [] + ;; Double-check: don't show if another tooltip is now visible + (when-let [active @active-tooltip] + (when (not= (:id active) tooltip-id) + (when-let [tooltip-el (dom/get-element (:id active))] + (dom/set-css-property! tooltip-el "display" "none")) + (reset! active-tooltip nil))) + + ;; Register this tooltip as visible + (register-tooltip tooltip-id trigger-el) + + (reset! active-tooltip {:id tooltip-id :trigger trigger-el}) + (reset! visible* true)))))))) on-show-focus (mf/use-fn @@ -215,6 +271,10 @@ (fn [] (clear-schedule schedule-ref) (reset! visible* false) + + ;; Unregister from the global registry + (unregister-tooltip tooltip-id) + (when (= (:id @active-tooltip) tooltip-id) (reset! active-tooltip nil)))) diff --git a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss index 79fe80f774..dcb01cb95a 100644 --- a/frontend/src/app/main/ui/ds/tooltip/tooltip.scss +++ b/frontend/src/app/main/ui/ds/tooltip/tooltip.scss @@ -55,6 +55,7 @@ $arrow-side: 12px; "arrow" "content"; } + .tooltip-bottom .tooltip-arrow { justify-self: center; border-radius: var(--sp-xs) 0; @@ -111,7 +112,7 @@ $arrow-side: 12px; } .tooltip-bottom-right .tooltip-arrow { - margin: 0px var(--sp-s); + margin: 0 var(--sp-s); transform: rotate(45deg) translateX(var(--sp-s)); border-radius: var(--sp-xs) 0; border-block-start: $b-1 solid var(--color-accent-primary-muted); @@ -123,6 +124,7 @@ $arrow-side: 12px; "arrow" "content"; } + .tooltip-bottom-left .tooltip-arrow { justify-self: end; margin: 0 var(--sp-s); @@ -137,6 +139,7 @@ $arrow-side: 12px; "content" "arrow"; } + .tooltip-top-left .tooltip-arrow { margin: 0 var(--sp-s); justify-self: end; @@ -148,6 +151,7 @@ $arrow-side: 12px; .tooltip-content { @include t.use-typography("body-small"); + background-color: var(--color-background-primary); color: var(--color-foreground-secondary); border-radius: var(--sp-xs); diff --git a/frontend/src/app/main/ui/ds/typography.scss b/frontend/src/app/main/ui/ds/typography.scss index 6ca2fd6670..36b4086fba 100644 --- a/frontend/src/app/main/ui/ds/typography.scss +++ b/frontend/src/app/main/ui/ds/typography.scss @@ -8,11 +8,9 @@ $_font-weight-regular: 400; $_font-weight-medium: 500; - $_font-lineheight-dense: 1.2; $_font-lineheight-compact: 1.3; $_font-lineheight-normal: 1.4; - $_fs-12: px2rem(12); $_fs-14: px2rem(14); $_fs-16: px2rem(16); diff --git a/frontend/src/app/main/ui/ds/utilities/swatch.scss b/frontend/src/app/main/ui/ds/utilities/swatch.scss index a9eb3b6936..3052f5f6c3 100644 --- a/frontend/src/app/main/ui/ds/utilities/swatch.scss +++ b/frontend/src/app/main/ui/ds/utilities/swatch.scss @@ -11,7 +11,7 @@ @property --solid-color-overlay { syntax: ""; inherits: false; - initial-value: rgba(0, 0, 0, 0); + initial-value: rgb(0 0 0 / 0); } .swatch { @@ -19,13 +19,13 @@ --border-radius: #{$br-4}; --border-color-active: var(--color-foreground-primary); --border-color-active-inset: var(--color-background-primary); - - --checkerboard-background: repeating-conic-gradient(lightgray 0% 25%, white 0% 50%); + --checkerboard-background: repeating-conic-gradient(rgb(212 212 212) 0% 25%, rgb(255 255 255) 0% 50%); --checkerboard-size: 0.5rem 0.5rem; border: $b-1 solid var(--border-color); border-radius: var(--border-radius); overflow: hidden; + &:focus-visible { --border-color: var(--color-accent-primary); } @@ -80,6 +80,7 @@ &:hover { --border-color: var(--color-accent-primary-muted); + border-width: $b-2; } } @@ -114,7 +115,6 @@ /* solid‑colour overlay */ /* checkerboard pattern */ linear-gradient(var(--solid-color-overlay), var(--solid-color-overlay)), var(--checkerboard-background); - background-size: cover, var(--checkerboard-size); background-position: center, center; background-repeat: no-repeat, repeat; diff --git a/frontend/src/app/main/ui/exports/assets.cljs b/frontend/src/app/main/ui/exports/assets.cljs index feb7f52906..a32a2ca5f6 100644 --- a/frontend/src/app/main/ui/exports/assets.cljs +++ b/frontend/src/app/main/ui/exports/assets.cljs @@ -36,7 +36,7 @@ (mf/defc export-multiple-dialog* {::mf/private true} - [{:keys [exports title cmd no-selection origin]}] + [{:keys [exports title cmd no-selection origin name]}] (let [lstate (mf/deref refs/export) in-progress? (:in-progress lstate) exports (mf/use-state exports) @@ -59,7 +59,7 @@ (fn [event] (dom/prevent-default event) (st/emit! (modal/hide) - (de/request-multiple-export {:exports enabled-exports :cmd cmd}) + (de/request-multiple-export {:exports enabled-exports :cmd cmd :name name}) (de/export-shapes-event enabled-exports origin))) on-toggle-enabled @@ -185,25 +185,27 @@ (mf/defc export-shapes-dialog {::mf/register modal/components ::mf/register-as :export-shapes} - [{:keys [exports origin]}] + [{:keys [exports origin name]}] (let [title (tr "dashboard.export-shapes.title")] [:> export-multiple-dialog* {:exports exports :title title :cmd :export-shapes :no-selection shapes-no-selection - :origin origin}])) + :origin origin + :name name}])) (mf/defc export-frames {::mf/register modal/components ::mf/register-as :export-frames} - [{:keys [exports origin]}] + [{:keys [exports origin name]}] (let [title (tr "dashboard.export-frames.title")] [:> export-multiple-dialog* {:exports exports :title title :cmd :export-frames - :origin origin}])) + :origin origin + :name name}])) ;; FIXME: deprecated, should be refactored in two components and use ;; the generic progress reporter diff --git a/frontend/src/app/main/ui/exports/assets.scss b/frontend/src/app/main/ui/exports/assets.scss index 8bc4737a20..cc690c5d3b 100644 --- a/frontend/src/app/main/ui/exports/assets.scss +++ b/frontend/src/app/main/ui/exports/assets.scss @@ -8,7 +8,8 @@ // PROGRESS WIDGET .export-progress-widget { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-28; height: deprecated.$s-28; } @@ -19,6 +20,7 @@ --export-modal-fg-color: var(--alert-text-foreground-color-default); --export-modal-icon-color: var(--alert-icon-foreground-color-default); --export-modal-border-color: var(--alert-border-color-default); + position: absolute; right: deprecated.$s-16; top: deprecated.$s-48; @@ -41,13 +43,15 @@ --export-modal-fg-color: var(--alert-text-foreground-color-error); --export-modal-icon-color: var(--alert-icon-foreground-color-error); --export-modal-border-color: var(--alert-border-color-error); + grid-template-areas: "icon text close"; gap: deprecated.$s-8; padding-block: deprecated.$s-8; } .icon { - @extend .button-icon; + @extend %button-icon; + grid-area: icon; align-self: center; margin-inline-start: deprecated.$s-8; @@ -55,7 +59,8 @@ } .export-progress-title { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + display: grid; grid-template-columns: auto 1fr; gap: deprecated.$s-8; @@ -67,7 +72,8 @@ } .progress { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + padding-left: deprecated.$s-8; margin: 0; align-self: center; @@ -75,8 +81,9 @@ } .retry-btn { - @include deprecated.buttonStyle; - @include deprecated.bodySmallTypography; + @include deprecated.button-style; + @include deprecated.body-small-typography; + display: inline; text-align: left; color: var(--modal-link-foreground-color); @@ -85,13 +92,15 @@ } .progress-close-button { - @include deprecated.buttonStyle; + @include deprecated.button-style; + padding: 0; margin-inline-end: deprecated.$s-8; } .close-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--export-modal-icon-color); } @@ -102,14 +111,16 @@ // EXPORT MODAL .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; + &.transparent { background-color: transparent; } } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + max-height: calc(10 * deprecated.$s-80); } @@ -118,76 +129,96 @@ } .modal-title { - @include deprecated.headlineMediumTypography; + @include deprecated.headline-medium-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content, .no-selection { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin-bottom: deprecated.$s-24; + .modal-link { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + text-decoration: none; cursor: pointer; color: var(--modal-link-foreground-color); } + .selection-header { - @include deprecated.flexRow; + @include deprecated.flex-row; + height: deprecated.$s-32; margin-bottom: deprecated.$s-4; + .selection-btn { - @include deprecated.buttonStyle; - @extend .input-checkbox; - @include deprecated.flexCenter; + @include deprecated.button-style; + @extend %input-checkbox; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; padding: 0; margin-left: deprecated.$s-16; + span { - @extend .checkbox-icon; + @extend %checkbox-icon; } } + .selection-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); } } + .selection-wrapper { position: relative; width: 100%; height: fit-content; } + .selection-shadow { width: 100%; height: 100%; - &:after { + + &::after { position: absolute; bottom: 0; left: 0; width: 100%; height: 50px; - background: linear-gradient(to top, rgba(24, 24, 26, 1) 0%, rgba(24, 24, 26, 0) 100%); + background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); content: ""; pointer-events: none; } } + .selection-list { - @include deprecated.flexColumn; + @include deprecated.flex-column; + max-height: deprecated.$s-400; overflow-y: auto; padding-bottom: deprecated.$s-12; + .selection-row { - @include deprecated.flexRow; + @include deprecated.flex-row; + background-color: var(--entry-background-color); min-height: deprecated.$s-40; border-radius: deprecated.$br-8; + .selection-btn { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: grid; grid-template-columns: min-content auto 1fr auto auto; align-items: center; @@ -195,45 +226,57 @@ height: 10%; gap: deprecated.$s-8; padding: 0 deprecated.$s-16; + .checkbox-wrapper { - @extend .input-checkbox; - @include deprecated.flexCenter; + @extend %input-checkbox; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; padding: 0; + .checkobox-tick { - @extend .checkbox-icon; + @extend %checkbox-icon; } } + .selection-name { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; + flex-grow: 1; color: var(--modal-text-foreground-color); text-align: start; } + .selection-scale { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; + min-width: deprecated.$s-108; padding: deprecated.$s-12; color: var(--modal-text-foreground-color); } + .selection-extension { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; + min-width: deprecated.$s-72; padding: deprecated.$s-12; color: var(--modal-text-foreground-color); } } + .image-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + min-height: deprecated.$s-32; min-width: deprecated.$s-32; background-color: var(--app-white); border-radius: deprecated.$br-6; margin: auto 0; + img, svg { object-fit: contain; @@ -245,80 +288,98 @@ } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } + .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } + .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } .modal-scd-msg, .modal-subtitle, .modal-msg { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); } .export-option { - @extend .input-checkbox; + @extend %input-checkbox; + width: 100%; align-items: flex-start; + label { align-items: flex-start; + .modal-subtitle { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } } + span { margin-top: deprecated.$s-8; } } .option-content { - @include deprecated.flexColumn; - @include deprecated.bodyLargeTypography; + @include deprecated.flex-column; + @include deprecated.body-large-typography; } .file-entry { .file-name { - @include deprecated.flexRow; + @include deprecated.flex-row; + .file-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-16; width: deprecated.$s-16; svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--input-foreground); } } + .file-name-label { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; } } + &.loading { .file-name { color: var(--modal-text-foreground-color); } } + &.error { .file-name { color: var(--modal-text-foreground-color); + .file-icon svg { stroke: var(--modal-text-foreground-color); } } } + &.success { .file-name { color: var(--modal-text-foreground-color); + .file-icon svg { stroke: var(--modal-text-foreground-color); } diff --git a/frontend/src/app/main/ui/exports/files.cljs b/frontend/src/app/main/ui/exports/files.cljs index 9103852613..13652480c5 100644 --- a/frontend/src/app/main/ui/exports/files.cljs +++ b/frontend/src/app/main/ui/exports/files.cljs @@ -173,15 +173,22 @@ :on-click on-accept}]]]] (= status :exporting) - [:* - [:div {:class (stl/css :modal-content)} - (for [file (:files state)] - [:> export-entry* {:file file :key (dm/str (:id file))}])] + (let [in-progress? (->> state :files (some :loading))] + [:* + [:div {:class (stl/css :modal-content)} + (for [file (:files state)] + [:> export-entry* {:file file :key (dm/str (:id file))}]) - [:div {:class (stl/css :modal-footer)} - [:div {:class (stl/css :action-buttons)} - [:input {:class (stl/css :accept-btn) - :type "button" - :value (tr "labels.close") - :disabled (->> state :files (some :loading)) - :on-click on-cancel}]]]])]])) + (when in-progress? + [:div {:class (stl/css :status-message) + :role "status" + :aria-live "polite"} + (tr "labels.downloading-file")])] + + [:div {:class (stl/css :modal-footer)} + [:div {:class (stl/css :action-buttons)} + [:input {:class (stl/css :accept-btn) + :type "button" + :value (tr "labels.close") + :disabled in-progress? + :on-click on-cancel}]]]]))]])) diff --git a/frontend/src/app/main/ui/exports/files.scss b/frontend/src/app/main/ui/exports/files.scss index d6055ed184..e395c9c509 100644 --- a/frontend/src/app/main/ui/exports/files.scss +++ b/frontend/src/app/main/ui/exports/files.scss @@ -8,14 +8,16 @@ // EXPORT MODAL .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; + &.transparent { background-color: transparent; } } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + max-height: calc(10 * deprecated.$s-80); } @@ -24,75 +26,95 @@ } .modal-title { - @include deprecated.headlineMediumTypography; + @include deprecated.headline-medium-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin-bottom: deprecated.$s-24; + .modal-link { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + text-decoration: none; cursor: pointer; color: var(--modal-link-foreground-color); } + .selection-header { - @include deprecated.flexRow; + @include deprecated.flex-row; + height: deprecated.$s-32; margin-bottom: deprecated.$s-4; + .selection-btn { - @include deprecated.buttonStyle; - @extend .input-checkbox; - @include deprecated.flexCenter; + @include deprecated.button-style; + @extend %input-checkbox; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; padding: 0; margin-left: deprecated.$s-16; + span { - @extend .checkbox-icon; + @extend %checkbox-icon; } } + .selection-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); } } + .selection-wrapper { position: relative; width: 100%; height: fit-content; } + .selection-shadow { width: 100%; height: 100%; - &:after { + + &::after { position: absolute; bottom: 0; left: 0; width: 100%; height: 50px; - background: linear-gradient(to top, rgba(24, 24, 26, 1) 0%, rgba(24, 24, 26, 0) 100%); + background: linear-gradient(to top, rgb(24 24 26 / 1) 0%, rgb(24 24 26 / 0) 100%); content: ""; pointer-events: none; } } + .selection-list { - @include deprecated.flexColumn; + @include deprecated.flex-column; + max-height: deprecated.$s-400; overflow-y: auto; padding-bottom: deprecated.$s-12; + .selection-row { - @include deprecated.flexRow; + @include deprecated.flex-row; + background-color: var(--entry-background-color); min-height: deprecated.$s-40; border-radius: deprecated.$br-8; + .selection-btn { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: grid; grid-template-columns: min-content auto 1fr auto auto; align-items: center; @@ -100,45 +122,57 @@ height: 10%; gap: deprecated.$s-8; padding: 0 deprecated.$s-16; + .checkbox-wrapper { - @extend .input-checkbox; - @include deprecated.flexCenter; + @extend %input-checkbox; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; padding: 0; + .checkobox-tick { - @extend .checkbox-icon; + @extend %checkbox-icon; } } + .selection-name { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; + flex-grow: 1; color: var(--modal-text-foreground-color); text-align: start; } + .selection-scale { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; + min-width: deprecated.$s-108; padding: deprecated.$s-12; color: var(--modal-text-foreground-color); } + .selection-extension { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; + min-width: deprecated.$s-72; padding: deprecated.$s-12; color: var(--modal-text-foreground-color); } } + .image-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + min-height: deprecated.$s-32; min-width: deprecated.$s-32; background-color: var(--app-white); border-radius: deprecated.$br-6; margin: auto 0; + img, svg { object-fit: contain; @@ -149,83 +183,107 @@ } } +.status-message { + @include deprecated.body-small-typography; + + color: var(--modal-title-foreground-color); + font-style: italic; +} + .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } + .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } + .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } .modal-scd-msg, .modal-subtitle, .modal-msg { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); } .export-option { - @extend .input-checkbox; + @extend %input-checkbox; + width: 100%; align-items: flex-start; + label { align-items: flex-start; + .modal-subtitle { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); padding: 0.25rem 0; } } + span { margin-top: deprecated.$s-8; } } .option-content { - @include deprecated.flexColumn; - @include deprecated.bodyLargeTypography; + @include deprecated.flex-column; + @include deprecated.body-large-typography; } .file-entry { .file-name { - @include deprecated.flexRow; + @include deprecated.flex-row; .file-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-16; width: deprecated.$s-16; svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--input-foreground); } } + .file-name-label { - @include deprecated.bodyLargeTypography; - @include deprecated.textEllipsis; + @include deprecated.body-large-typography; + @include deprecated.text-ellipsis; } } + &.loading { .file-name { color: var(--modal-text-foreground-color); } } + &.error { .file-name { color: var(--modal-text-foreground-color); + .file-icon svg { stroke: var(--modal-text-foreground-color); } } } + &.success { .file-name { color: var(--modal-text-foreground-color); + .file-icon svg { stroke: var(--modal-text-foreground-color); } diff --git a/frontend/src/app/main/ui/forms.cljs b/frontend/src/app/main/ui/forms.cljs index 9aede980cf..c0426dcfaa 100644 --- a/frontend/src/app/main/ui/forms.cljs +++ b/frontend/src/app/main/ui/forms.cljs @@ -67,24 +67,38 @@ (mf/defc form-submit* [{:keys [disabled on-submit] :rest props}] + (let [form (mf/use-ctx context) - disabled? (or (and (some? form) - (or (not (:valid @form)) - (seq (:async-errors @form)) - (seq (:extra-errors @form)))) - (true? disabled)) + form-state (when form @form) + + disabled? (mf/use-memo + (mf/deps form form-state disabled) + (fn [] + (boolean + (or (nil? form) + (true? disabled) + (not (:valid form-state)) + (seq (:async-errors form-state)) + (seq (:extra-errors form-state)))))) + handle-key-down-save (mf/use-fn - (mf/deps on-submit form) + (mf/deps on-submit form disabled?) (fn [e] - (when (or (k/enter? e) (k/space? e)) + (when (and (or (k/enter? e) (k/space? e)) (not disabled?)) (dom/prevent-default e) (on-submit form e)))) props - (mf/spread-props props {:disabled disabled? - :on-key-down handle-key-down-save - :type "submit"})] + (mf/spread-props props {:on-key-down handle-key-down-save + :type "submit"}) + + props + (if disabled? + (mf/spread-props props {:disabled true + :on-key-down handle-key-down-save + :type "submit"}) + props)] [:> button* props])) diff --git a/frontend/src/app/main/ui/frame_preview.cljs b/frontend/src/app/main/ui/frame_preview.cljs index bff3da3abd..de15702d97 100644 --- a/frontend/src/app/main/ui/frame_preview.cljs +++ b/frontend/src/app/main/ui/frame_preview.cljs @@ -37,7 +37,6 @@ load-ref (mf/use-callback (fn [iframe-dom] - (.log js/console "load-ref" iframe-dom) (mf/set-ref-val! iframe-ref iframe-dom) (when (and iframe-dom @last-data*) (-> iframe-dom .-contentWindow .-document .open) diff --git a/frontend/src/app/main/ui/icons.cljs b/frontend/src/app/main/ui/icons.cljs index f5ca0d9117..1cc3569881 100644 --- a/frontend/src/app/main/ui/icons.cljs +++ b/frontend/src/app/main/ui/icons.cljs @@ -19,6 +19,7 @@ (def ^:icon logo-error-screen (icon-xref :logo-error-screen)) (def ^:icon logo-subscription (icon-xref :logo-subscription)) (def ^:icon logo-subscription-light (icon-xref :logo-subscription-light)) +(def ^:icon nitrate-welcome (icon-xref :nitrate-welcome)) (def ^:icon brand-openid (icon-xref :brand-openid)) (def ^:icon brand-github (icon-xref :brand-github)) diff --git a/frontend/src/app/main/ui/inspect/annotation.scss b/frontend/src/app/main/ui/inspect/annotation.scss index 431754d330..75054e7e3a 100644 --- a/frontend/src/app/main/ui/inspect/annotation.scss +++ b/frontend/src/app/main/ui/inspect/annotation.scss @@ -7,15 +7,16 @@ @use "refactor/common-refactor.scss" as deprecated; .attributes-block { - @include deprecated.flexColumn; + @include deprecated.flex-column; } .title-spacing-annotation { - @extend .attr-title; + @extend %attr-title; } .annotation-content { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--entry-foreground-color); } diff --git a/frontend/src/app/main/ui/inspect/attributes.scss b/frontend/src/app/main/ui/inspect/attributes.scss index 4eafa389eb..2fbcbd3e06 100644 --- a/frontend/src/app/main/ui/inspect/attributes.scss +++ b/frontend/src/app/main/ui/inspect/attributes.scss @@ -15,8 +15,7 @@ max-height: calc(100vh - px2rem(128)); // TODO: Fix this hardcoded value padding-top: var(--sp-s); padding-inline: var(--sp-m); - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; scrollbar-gutter: stable; background-color: var(--low-emphasis-background); } diff --git a/frontend/src/app/main/ui/inspect/attributes/blur.scss b/frontend/src/app/main/ui/inspect/attributes/blur.scss index 9ae8c464eb..240b8f8c20 100644 --- a/frontend/src/app/main/ui/inspect/attributes/blur.scss +++ b/frontend/src/app/main/ui/inspect/attributes/blur.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -25,12 +26,13 @@ } .blur-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .copy-btn-title { diff --git a/frontend/src/app/main/ui/inspect/attributes/common.scss b/frontend/src/app/main/ui/inspect/attributes/common.scss index 79d7b9a410..569af36772 100644 --- a/frontend/src/app/main/ui/inspect/attributes/common.scss +++ b/frontend/src/app/main/ui/inspect/attributes/common.scss @@ -5,7 +5,6 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; - @use "ds/_utils.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @@ -24,7 +23,7 @@ } .attributes-color-row { - @extend .attr-row; + @extend %attr-row; } .bullet-wrapper { @@ -41,6 +40,7 @@ .image-format { @include use-typography("headline-small"); + block-size: $sz-32; padding: var(--sp-s) 0; color: var(--color-foreground-secondary); @@ -56,6 +56,7 @@ .format-info { @include use-typography("body-small"); + padding-left: var(--sp-xxs); color: var(--color-foreground-secondary); } @@ -66,10 +67,12 @@ gap: var(--sp-xs); flex-grow: 1; max-inline-size: px2rem(144); + button { visibility: hidden; min-inline-size: px2rem(28); } + &:hover button { visibility: visible; } @@ -87,6 +90,7 @@ .color-name-wrapper { @include use-typography("body-small"); + display: flex; flex-direction: column; gap: var(--sp-xs); @@ -109,7 +113,8 @@ .color-value-wrapper { @include use-typography("body-small"); - @include textEllipsis; + @include text-ellipsis; + color: var(--menu-foreground-color); text-transform: uppercase; } @@ -120,6 +125,7 @@ .opacity-info { @include use-typography("body-small"); + color: var(--menu-foreground-color); text-transform: uppercase; inline-size: 100%; @@ -127,7 +133,6 @@ .second-row { min-block-size: $sz-16; - padding-right: var(--sp-s); inline-size: 100%; text-align: left; margin: 0; @@ -136,8 +141,9 @@ .color-name-library { @include use-typography("body-small"); + color: var(--color-foreground-secondary); - word-break: break-word; + overflow-wrap: break-word; } .image-download { diff --git a/frontend/src/app/main/ui/inspect/attributes/fill.scss b/frontend/src/app/main/ui/inspect/attributes/fill.scss index 3cede83d81..bb6fa49a90 100644 --- a/frontend/src/app/main/ui/inspect/attributes/fill.scss +++ b/frontend/src/app/main/ui/inspect/attributes/fill.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); diff --git a/frontend/src/app/main/ui/inspect/attributes/geometry.scss b/frontend/src/app/main/ui/inspect/attributes/geometry.scss index f1a90db1e3..40777a76d7 100644 --- a/frontend/src/app/main/ui/inspect/attributes/geometry.scss +++ b/frontend/src/app/main/ui/inspect/attributes/geometry.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -25,12 +26,13 @@ } .geometry-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .copy-btn-title { diff --git a/frontend/src/app/main/ui/inspect/attributes/layout.scss b/frontend/src/app/main/ui/inspect/attributes/layout.scss index 2164e152fc..4918983aba 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout.scss +++ b/frontend/src/app/main/ui/inspect/attributes/layout.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -25,12 +26,13 @@ } .layout-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .copy-btn-title { diff --git a/frontend/src/app/main/ui/inspect/attributes/layout_element.scss b/frontend/src/app/main/ui/inspect/attributes/layout_element.scss index a51009ab53..0e4cc28285 100644 --- a/frontend/src/app/main/ui/inspect/attributes/layout_element.scss +++ b/frontend/src/app/main/ui/inspect/attributes/layout_element.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -25,15 +26,15 @@ } .layout-element-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .copy-btn-title { max-inline-size: $sz-28; - max-inline-size: $sz-28; } diff --git a/frontend/src/app/main/ui/inspect/attributes/shadow.scss b/frontend/src/app/main/ui/inspect/attributes/shadow.scss index 8cfb86f0c3..d4e04e5b1b 100644 --- a/frontend/src/app/main/ui/inspect/attributes/shadow.scss +++ b/frontend/src/app/main/ui/inspect/attributes/shadow.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -25,10 +26,11 @@ } .shadow-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } diff --git a/frontend/src/app/main/ui/inspect/attributes/stroke.scss b/frontend/src/app/main/ui/inspect/attributes/stroke.scss index dd5bf8d4b4..70bd2a5ef5 100644 --- a/frontend/src/app/main/ui/inspect/attributes/stroke.scss +++ b/frontend/src/app/main/ui/inspect/attributes/stroke.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -31,12 +32,13 @@ } .stroke-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .attributes-content { diff --git a/frontend/src/app/main/ui/inspect/attributes/svg.scss b/frontend/src/app/main/ui/inspect/attributes/svg.scss index 1b7495e61d..dd50046496 100644 --- a/frontend/src/app/main/ui/inspect/attributes/svg.scss +++ b/frontend/src/app/main/ui/inspect/attributes/svg.scss @@ -11,6 +11,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -26,24 +27,28 @@ } .svg-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .attributes-subtitle { @include use-typography("headline-small"); + display: flex; justify-content: space-between; block-size: $sz-32; + span { block-size: $sz-32; display: flex; align-items: center; } + button { display: none; } diff --git a/frontend/src/app/main/ui/inspect/attributes/text.scss b/frontend/src/app/main/ui/inspect/attributes/text.scss index 9f3ecf1808..6a81b7bcd0 100644 --- a/frontend/src/app/main/ui/inspect/attributes/text.scss +++ b/frontend/src/app/main/ui/inspect/attributes/text.scss @@ -12,6 +12,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -33,16 +34,18 @@ } .text-row { - @extend .attr-row; + @extend %attr-row; + block-size: unset; min-block-size: $sz-36; + :global(.attr-value) { align-items: center; } } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .attributes-content-row { @@ -51,8 +54,10 @@ border-radius: $br-8; border: $b-1 solid var(--menu-border-color-disabled); margin-block-start: var(--sp-xs); + .content { @include use-typography("body-small"); + width: 100%; padding: var(--sp-xs) 0; color: var(--color-foreground-secondary); @@ -61,6 +66,7 @@ &:hover { border: $b-1 solid var(--color-background-tertiary); background-color: var(--menu-background-color); + .content { color: var(--menu-foreground-color-hover); } diff --git a/frontend/src/app/main/ui/inspect/attributes/variant.scss b/frontend/src/app/main/ui/inspect/attributes/variant.scss index 3d0df70402..050826a5db 100644 --- a/frontend/src/app/main/ui/inspect/attributes/variant.scss +++ b/frontend/src/app/main/ui/inspect/attributes/variant.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -25,12 +26,14 @@ } .variant-row { - @extend .attr-row; + @extend %attr-row; + block-size: fit-content; min-block-size: $sz-36; } .button-children { - @extend .copy-button-children; - word-break: break-word; + @extend %copy-button-children; + + overflow-wrap: break-word; } diff --git a/frontend/src/app/main/ui/inspect/attributes/visibility.scss b/frontend/src/app/main/ui/inspect/attributes/visibility.scss index c888735ff1..a4b20d8700 100644 --- a/frontend/src/app/main/ui/inspect/attributes/visibility.scss +++ b/frontend/src/app/main/ui/inspect/attributes/visibility.scss @@ -10,6 +10,7 @@ .attributes-block { --box-border-color: var(--color-background-primary); + display: flex; flex-direction: column; border-block-end: $b-2 solid var(--box-border-color); @@ -25,12 +26,13 @@ } .visibility-row { - @extend .attr-row; + @extend %attr-row; + block-size: $sz-36; } .button-children { - @extend .copy-button-children; + @extend %copy-button-children; } .copy-btn-title { diff --git a/frontend/src/app/main/ui/inspect/code.scss b/frontend/src/app/main/ui/inspect/code.scss index 7f88871edc..4f455da3a2 100644 --- a/frontend/src/app/main/ui/inspect/code.scss +++ b/frontend/src/app/main/ui/inspect/code.scss @@ -13,7 +13,6 @@ overflow: hidden; padding-bottom: deprecated.$s-16; overflow-y: auto; - overflow-x: hidden; padding-inline: var(--sp-m); } @@ -22,15 +21,17 @@ } .download-button { - @extend .button-secondary; - @include deprecated.uppercaseTitleTipography; + @extend %button-secondary; + @include deprecated.uppercase-title-typography; + height: deprecated.$s-32; width: 100%; margin: deprecated.$s-8 0; } .code-block { - @include deprecated.codeTypography; + @include deprecated.code-typography; + display: flex; flex-direction: column; height: 100%; @@ -62,7 +63,8 @@ } .code-lang { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + display: flex; align-items: center; } @@ -76,11 +78,14 @@ .expand-button, .css-copy-btn, .html-copy-btn { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } @@ -88,15 +93,19 @@ .code-lang-options { max-width: deprecated.$s-108; } + .code-lang-select { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + width: deprecated.$s-72; border: deprecated.$s-1 solid transparent; background-color: transparent; color: var(--menu-foreground-color-disabled); } + .code-lang-option { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + width: deprecated.$s-72; height: deprecated.$s-32; padding: deprecated.$s-8; @@ -111,32 +120,41 @@ } .toggle-btn { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: flex; align-items: center; padding: 0; color: var(--title-foreground-color); stroke: var(--title-foreground-color); + .collapsabled-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-24; border-radius: deprecated.$br-8; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + transform: rotate(90deg); stroke: var(--icon-foreground); } + &.rotated svg { transform: rotate(0deg); } } + &:hover { color: var(--title-foreground-color-hover); stroke: var(--title-foreground-color-hover); + .title { color: var(--title-foreground-color-hover); stroke: var(--title-foreground-color-hover); } + .collapsabled-icon svg { stroke: var(--title-foreground-color-hover); } diff --git a/frontend/src/app/main/ui/inspect/exports.cljs b/frontend/src/app/main/ui/inspect/exports.cljs index 7240ad59ea..04cd8260ac 100644 --- a/frontend/src/app/main/ui/inspect/exports.cljs +++ b/frontend/src/app/main/ui/inspect/exports.cljs @@ -47,7 +47,7 @@ (if (= :multiple type) (st/emit! (de/show-viewer-export-dialog {:shapes shapes :exports @exports - :filename filename + :name filename :page-id page-id :file-id file-id :share-id share-id})) diff --git a/frontend/src/app/main/ui/inspect/exports.scss b/frontend/src/app/main/ui/inspect/exports.scss index 4ca98720a8..690e83d06d 100644 --- a/frontend/src/app/main/ui/inspect/exports.scss +++ b/frontend/src/app/main/ui/inspect/exports.scss @@ -23,43 +23,51 @@ } .add-export { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } .element-set-content { - @include deprecated.flexColumn; + @include deprecated.flex-column; + margin-bottom: deprecated.$s-4; } .multiple-exports { - @include deprecated.flexRow; + @include deprecated.flex-row; + grid-column: 1 / span 9; } .label { - @extend .mixed-bar; + @extend %mixed-bar; } .actions { - @include deprecated.flexRow; + @include deprecated.flex-row; } .element-group { display: grid; grid-template-columns: repeat(9, 1fr); column-gap: deprecated.$s-4; + .action-btn { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; } } } @@ -84,6 +92,7 @@ .size-select { grid-column: span 2; padding: 0; + .dropdown-upwards { bottom: deprecated.$s-36; top: unset; @@ -92,14 +101,16 @@ } .suffix-input { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + grid-column: span 3; } .export-btn { - @extend .button-secondary; - @include deprecated.uppercaseTitleTipography; + @extend %button-secondary; + @include deprecated.uppercase-title-typography; + height: deprecated.$s-32; width: 100%; } diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.cljs b/frontend/src/app/main/ui/inspect/right_sidebar.cljs index 5e205b502a..9d4dfa0a1b 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.cljs +++ b/frontend/src/app/main/ui/inspect/right_sidebar.cljs @@ -188,7 +188,9 @@ :shapes shapes :from from :libraries libraries - :file-id file-id}] + :page-id page-id + :file-id file-id + :share-id share-id}] :computed [:> attributes* {:color-space color-space :page-id page-id diff --git a/frontend/src/app/main/ui/inspect/right_sidebar.scss b/frontend/src/app/main/ui/inspect/right_sidebar.scss index ca57b53f1c..1bc6a19a52 100644 --- a/frontend/src/app/main/ui/inspect/right_sidebar.scss +++ b/frontend/src/app/main/ui/inspect/right_sidebar.scss @@ -56,8 +56,9 @@ } .layer-title { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; + @include deprecated.body-small-typography; + @include deprecated.text-ellipsis; + block-size: $sz-32; padding: var(--sp-s) 0; color: var(--color-foreground-primary); @@ -69,8 +70,9 @@ } .layer-subtitle { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; + @include deprecated.body-small-typography; + @include deprecated.text-ellipsis; + color: var(--assets-item-name-foreground-color-rest); } @@ -97,6 +99,7 @@ .inspect-tab-switcher-label { @include use-typography("body-medium"); + color: var(--color-foreground-primary); flex: 0 1 40%; } diff --git a/frontend/src/app/main/ui/inspect/styles.cljs b/frontend/src/app/main/ui/inspect/styles.cljs index 3794ba61c7..21bc681ec5 100644 --- a/frontend/src/app/main/ui/inspect/styles.cljs +++ b/frontend/src/app/main/ui/inspect/styles.cljs @@ -15,6 +15,7 @@ [app.common.types.tokens-lib :as ctob] [app.main.data.style-dictionary :as sd] [app.main.refs :as refs] + [app.main.ui.inspect.exports :as exports] [app.main.ui.inspect.styles.panels.blur :refer [blur-panel*]] [app.main.ui.inspect.styles.panels.fill :refer [fill-panel*]] [app.main.ui.inspect.styles.panels.geometry :refer [geometry-panel*]] @@ -89,8 +90,20 @@ (:type first-shape)) :multiple)) +(def ^:private schema:styles-tab + [:map + [:color-space {:optional true} :string] ;; color format, e.g., "hex", "rgba", etc. + [:shapes :any] + [:libraries :map] + [:objects :map] + [:file-id :uuid] + [:page-id :uuid] + [:share-id {:optional true} [:maybe :uuid]] + [:from {:optional true} [:enum :workspace :viewer]]]) + (mf/defc styles-tab* - [{:keys [color-space shapes libraries objects file-id from]}] + {::mf/schema schema:styles-tab} + [{:keys [color-space shapes libraries objects file-id page-id share-id from]}] (let [data (dm/get-in libraries [file-id :data]) first-shape (first shapes) first-component (ctkl/get-component data (:component-id first-shape)) @@ -131,130 +144,139 @@ (mf/deps shorthands*) (fn [shorthand] (swap! shorthands* assoc (:panel shorthand) (:property shorthand))))] - [:ol {:class (stl/css-case :styles-tab true - :styles-tab-workspace (= from :workspace)) :aria-label (tr "labels.styles")} - ;; TOKENS PANEL - (when (or (seq active-themes) (seq active-sets)) - [:li - [:> style-box* {:panel :token} - [:> tokens-panel* {:theme-paths active-themes :set-names active-sets}]]]) - (for [panel panels] - [:li {:key (d/name panel)} - (case panel - ;; VARIANTS PANEL - :variant - [:> style-box* {:panel :variant} - [:> variants-panel* {:component first-component - :objects objects - :shape first-shape - :data data}]] - ;; GEOMETRY PANEL - :geometry - [:> style-box* {:panel :geometry - :shorthand (:geometry shorthands)} - [:> geometry-panel* {:shapes shapes - :objects objects - :resolved-tokens resolved-active-tokens - :on-geometry-shorthand set-shorthands}]] - ;; LAYOUT PANEL - :layout - (let [layout-shapes (->> shapes (filter ctl/any-layout?))] - (when (seq layout-shapes) - [:> style-box* {:panel :layout - :shorthand (:layout shorthands)} - [:> layout-panel* {:shapes layout-shapes - :objects objects - :resolved-tokens resolved-active-tokens - :on-layout-shorthand set-shorthands}]])) - ;; LAYOUT ELEMENT PANEL - :layout-element - (let [shapes (->> shapes (filter #(ctl/any-layout-immediate-child? objects %))) - some-layout-prop? (->> shapes - (mapcat (fn [shape] - (keep #(css/get-css-value objects shape %) layout-element-properties))) - (seq))] - (when some-layout-prop? - (let [only-flex? (every? #(ctl/flex-layout-immediate-child? objects %) shapes) - only-grid? (every? #(ctl/grid-layout-immediate-child? objects %) shapes) - panel (if only-flex? - :flex-element - (if only-grid? - :grid-element - :layout-element))] - [:> style-box* {:panel panel - :shorthand (:layout-element shorthands)} - [:> layout-element-panel* {:shapes shapes - :objects objects - :resolved-tokens resolved-active-tokens - :layout-element-properties layout-element-properties - :on-layout-element-shorthand set-shorthands}]]))) - ;; FILL PANEL - :fill - (let [shapes (filter has-fill? shapes)] - (when (seq shapes) - [:> style-box* {:panel :fill - :shorthand (:fill shorthands)} - [:> fill-panel* {:color-space color-space - :shapes shapes - :resolved-tokens resolved-active-tokens - :on-fill-shorthand set-shorthands}]])) + [:section {:class (stl/css-case :styles-tab true + :styles-tab-workspace (= from :workspace)) + :aria-label (tr "labels.styles")} + [:ol + ;; TOKENS PANEL + (when (or (seq active-themes) (seq active-sets)) + [:li + [:> style-box* {:panel :token} + [:> tokens-panel* {:theme-paths active-themes :set-names active-sets}]]]) + (for [panel panels] + [:li {:key (d/name panel)} + (case panel + ;; VARIANTS PANEL + :variant + [:> style-box* {:panel :variant} + [:> variants-panel* {:component first-component + :objects objects + :shape first-shape + :data data}]] + ;; GEOMETRY PANEL + :geometry + [:> style-box* {:panel :geometry + :shorthand (:geometry shorthands)} + [:> geometry-panel* {:shapes shapes + :objects objects + :resolved-tokens resolved-active-tokens + :on-geometry-shorthand set-shorthands}]] + ;; LAYOUT PANEL + :layout + (let [layout-shapes (->> shapes (filter ctl/any-layout?))] + (when (seq layout-shapes) + [:> style-box* {:panel :layout + :shorthand (:layout shorthands)} + [:> layout-panel* {:shapes layout-shapes + :objects objects + :resolved-tokens resolved-active-tokens + :on-layout-shorthand set-shorthands}]])) + ;; LAYOUT ELEMENT PANEL + :layout-element + (let [shapes (->> shapes (filter #(ctl/any-layout-immediate-child? objects %))) + some-layout-prop? (->> shapes + (mapcat (fn [shape] + (keep #(css/get-css-value objects shape %) layout-element-properties))) + (seq))] + (when some-layout-prop? + (let [only-flex? (every? #(ctl/flex-layout-immediate-child? objects %) shapes) + only-grid? (every? #(ctl/grid-layout-immediate-child? objects %) shapes) + panel (if only-flex? + :flex-element + (if only-grid? + :grid-element + :layout-element))] + [:> style-box* {:panel panel + :shorthand (:layout-element shorthands)} + [:> layout-element-panel* {:shapes shapes + :objects objects + :resolved-tokens resolved-active-tokens + :layout-element-properties layout-element-properties + :on-layout-element-shorthand set-shorthands}]]))) + ;; FILL PANEL + :fill + (let [shapes (filter has-fill? shapes)] + (when (seq shapes) + [:> style-box* {:panel :fill + :shorthand (:fill shorthands)} + [:> fill-panel* {:color-space color-space + :shapes shapes + :resolved-tokens resolved-active-tokens + :on-fill-shorthand set-shorthands}]])) - ;; STROKE PANEL - :stroke - (let [shapes (filter has-stroke? shapes)] - (when (seq shapes) - [:> style-box* {:panel :stroke - :shorthand (:stroke shorthands)} - [:> stroke-panel* {:color-space color-space - :shapes shapes - :objects objects - :resolved-tokens resolved-active-tokens - :on-stroke-shorthand set-shorthands}]])) + ;; STROKE PANEL + :stroke + (let [shapes (filter has-stroke? shapes)] + (when (seq shapes) + [:> style-box* {:panel :stroke + :shorthand (:stroke shorthands)} + [:> stroke-panel* {:color-space color-space + :shapes shapes + :objects objects + :resolved-tokens resolved-active-tokens + :on-stroke-shorthand set-shorthands}]])) - ;; VISIBILITY PANEL - :visibility - (let [shapes (filter has-visibility-props? shapes)] - (when (seq shapes) - [:> style-box* {:panel :visibility} - [:> visibility-panel* {:shapes shapes - :objects objects - :resolved-tokens resolved-active-tokens}]])) - ;; SVG PANEL - :svg - (let [shape (first shapes)] - (when (seq (:svg-attrs shape)) - [:> style-box* {:panel :svg} - [:> svg-panel* {:shape shape - :objects objects}]])) - ;; BLUR PANEL - :blur - (let [shapes (->> shapes (filter has-blur?))] - (when (seq shapes) - [:> style-box* {:panel :blur} - [:> blur-panel* {:shapes shapes + ;; VISIBILITY PANEL + :visibility + (let [shapes (filter has-visibility-props? shapes)] + (when (seq shapes) + [:> style-box* {:panel :visibility} + [:> visibility-panel* {:shapes shapes + :objects objects + :resolved-tokens resolved-active-tokens}]])) + ;; SVG PANEL + :svg + (let [shape (first shapes)] + (when (seq (:svg-attrs shape)) + [:> style-box* {:panel :svg} + [:> svg-panel* {:shape shape :objects objects}]])) - ;; TEXT PANEL - :text - (let [shapes (filter has-text? shapes)] - (when (seq shapes) - [:> style-box* {:panel :text - :shorthand (:text shorthands)} - [:> text-panel* {:shapes shapes - :color-space color-space - :resolved-tokens resolved-active-tokens - :on-font-shorthand set-shorthands}]])) + ;; BLUR PANEL + :blur + (let [shapes (->> shapes (filter has-blur?))] + (when (seq shapes) + [:> style-box* {:panel :blur} + [:> blur-panel* {:shapes shapes + :objects objects}]])) + ;; TEXT PANEL + :text + (let [shapes (filter has-text? shapes)] + (when (seq shapes) + [:> style-box* {:panel :text + :shorthand (:text shorthands)} + [:> text-panel* {:shapes shapes + :color-space color-space + :resolved-tokens resolved-active-tokens + :on-font-shorthand set-shorthands}]])) - ;; SHADOW PANEL - :shadow - (let [shapes (filter has-shadow? shapes)] - (when (seq shapes) - [:> style-box* {:panel :shadow - :shorthand (:shadow shorthands)} - [:> shadow-panel* {:shapes shapes - :resolved-tokens resolved-active-tokens - :color-space color-space - :on-shadow-shorthand set-shorthands}]])) + ;; SHADOW PANEL + :shadow + (let [shapes (filter has-shadow? shapes)] + (when (seq shapes) + [:> style-box* {:panel :shadow + :shorthand (:shadow shorthands)} + [:> shadow-panel* {:shapes shapes + :resolved-tokens resolved-active-tokens + :color-space color-space + :on-shadow-shorthand set-shorthands}]])) - ;; DEFAULT WIP - [:> style-box* {:panel panel} - [:div color-space]])])])) + ;; DEFAULT WIP + [:> style-box* {:panel panel} + [:div color-space]])])] + [:div {:class (stl/css :exports-wrapper)} + [:& exports/exports + {:shapes shapes + :type type + :page-id page-id + :file-id file-id + :share-id share-id}]]])) diff --git a/frontend/src/app/main/ui/inspect/styles.scss b/frontend/src/app/main/ui/inspect/styles.scss index 0680351132..d78617bb6b 100644 --- a/frontend/src/app/main/ui/inspect/styles.scss +++ b/frontend/src/app/main/ui/inspect/styles.scss @@ -13,3 +13,8 @@ .styles-tab-workspace { block-size: calc(100vh - px2rem(180)); // TODO: Fix this hardcoded value } + +.exports-wrapper { + padding-block: var(--sp-s); + padding-inline: var(--sp-m); +} diff --git a/frontend/src/app/main/ui/inspect/styles/panels/text.scss b/frontend/src/app/main/ui/inspect/styles/panels/text.scss index 0b1bbdd05c..3c68ea52c9 100644 --- a/frontend/src/app/main/ui/inspect/styles/panels/text.scss +++ b/frontend/src/app/main/ui/inspect/styles/panels/text.scss @@ -8,7 +8,7 @@ .text-content-wrapper { --border-color: var(--color-background-quaternary); - --border-radius: ${$br-8}; + --border-radius: #{$br-8}; border: $b-1 solid var(--border-color); border-radius: var(--border-radius); @@ -16,5 +16,6 @@ .text-content { --detail-color: var(--color-foreground-secondary); + color: var(--detail-color); } diff --git a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss index c1ddecdf4e..23c7fd9b52 100644 --- a/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss +++ b/frontend/src/app/main/ui/inspect/styles/property_detail_copiable.scss @@ -46,6 +46,7 @@ .property-detail-copied { --button-border-active: var(--color-accent-tertiary); + border: $b-1 solid var(--button-border-active); } @@ -61,11 +62,13 @@ .property-detail-text { @include use-typography("body-small"); + color: var(--detail-color); } .property-detail-text-token { @include use-typography("code-font"); + --detail-color: var(--color-token-foreground); line-height: 1.4; diff --git a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss index d5b8497c5c..44d90d99b1 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss +++ b/frontend/src/app/main/ui/inspect/styles/rows/color_properties_row.scss @@ -50,6 +50,7 @@ .color-image-preview-wrapper { --image-background: var(--color-background-secondary); + background: var(--image-background); } @@ -69,11 +70,13 @@ .tooltip-token-title { @include use-typography("body-small"); + color: var(--title-color); } .tooltip-token-value { @include use-typography("body-small"); + color: var(--title-value); } diff --git a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss index 19287bc219..0dace4fcc7 100644 --- a/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss +++ b/frontend/src/app/main/ui/inspect/styles/rows/properties_row.scss @@ -45,11 +45,13 @@ .tooltip-token-title { @include use-typography("body-small"); + color: var(--title-color); } .tooltip-token-value { @include use-typography("body-small"); + color: var(--title-value); } diff --git a/frontend/src/app/main/ui/inspect/styles/style_box.scss b/frontend/src/app/main/ui/inspect/styles/style_box.scss index a55a6b5fc4..7965049e59 100644 --- a/frontend/src/app/main/ui/inspect/styles/style_box.scss +++ b/frontend/src/app/main/ui/inspect/styles/style_box.scss @@ -25,7 +25,6 @@ padding-block: var(--sp-s); padding-inline: var(--sp-m); background-color: var(--low-emphasis-background); - border-block-end: 2px solid var(--box-border-color); } @@ -39,7 +38,6 @@ display: grid; place-items: center; color: var(--arrow-color); - appearance: none; background: none; padding: 0; @@ -49,6 +47,7 @@ .panel-title { @include use-typography("headline-small"); + flex: 1; color: var(--title-color); padding-block: var(--title-padding); diff --git a/frontend/src/app/main/ui/modal.scss b/frontend/src/app/main/ui/modal.scss index b78ff64bf4..9068cc6eda 100644 --- a/frontend/src/app/main/ui/modal.scss +++ b/frontend/src/app/main/ui/modal.scss @@ -11,5 +11,5 @@ } .modal-wrapper { - @extend .new-scrollbar; + @extend %new-scrollbar; } diff --git a/frontend/src/app/main/ui/nitrate/entry.cljs b/frontend/src/app/main/ui/nitrate/entry.cljs new file mode 100644 index 0000000000..4bcadf3216 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/entry.cljs @@ -0,0 +1,31 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.nitrate.entry + (:require + [app.main.data.auth :as da] + [app.main.data.nitrate :as dnt] + [app.main.router :as rt] + [app.main.store :as st] + [app.main.ui.ds.product.loader :refer [loader*]] + [app.util.i18n :refer [tr]] + [rumext.v2 :as mf])) + +(mf/defc nitrate-entry* + {::mf/private true} + [{:keys [profile]}] + (mf/with-effect [profile] + (dnt/activate-nitrate-entry-popup!) + (if (da/is-authenticated? profile) + (st/emit! (rt/nav :dashboard-recent {:team-id (:default-team-id profile)})) + (st/emit! (rt/nav :auth-register)))) + + [:> loader* {:title (tr "labels.loading") + :overlay true}]) + +(mf/defc nitrate-entry-page* + [props] + [:> nitrate-entry* props]) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs new file mode 100644 index 0000000000..0f68a5b5f7 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.cljs @@ -0,0 +1,67 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.nitrate.nitrate-activation-success-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data.macros :as dm] + [app.common.time :as ct] + [app.main.data.modal :as modal] + [app.main.data.nitrate :as dnt] + [app.main.refs :as refs] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*]] + [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.util.i18n :refer [tr]] + [rumext.v2 :as mf])) + +(mf/defc nitrate-activation-success-modal* + {::mf/register modal/components + ::mf/register-as :nitrate-activation-success + ::mf/wrap-props true} + [props] + + (let [profile (mf/deref refs/profile) + light? (= "light" (:theme profile)) + svg-id (if light? "logo-subscription-light" "logo-subscription") + + cancel-at (dm/get-in props [:subscription :cancel-at]) + date-str (when cancel-at + (ct/format-inst cancel-at "d MMMM, yyyy")) + + on-create-org + (mf/use-fn + (fn [] + (modal/hide!) + (dnt/go-to-nitrate-cc-create-org)))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:button {:class (stl/css :close-btn) :on-click modal/hide!} + [:> icon* {:icon-id "close" + :size "m"}]] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-start)} + [:> raw-svg* {:id svg-id}]] + + [:div {:class (stl/css :modal-end)} + [:div {:class (stl/css :modal-title)} + (tr "nitrate.activation-success.title")] + + [:p {:class (stl/css :modal-text-primary)} + (tr "nitrate.activation-success.active-until" date-str)] + + [:p {:class (stl/css :modal-text)} + (tr "nitrate.activation-success.manage-info")] + + [:p {:class (stl/css :modal-text)} + (tr "nitrate.activation-success.enjoy")] + + [:> button* {:variant "primary" + :on-click on-create-org + :class (stl/css :modal-button)} + (tr "nitrate.activation-success.create-org")]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss new file mode 100644 index 0000000000..5f1e8dd483 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_activation_success_modal.scss @@ -0,0 +1,79 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/_borders.scss" as *; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_utils.scss" as *; + +.modal-overlay { + @extend %modal-overlay-base; + + z-index: var(--z-index-notifications); +} + +.modal-dialog { + @extend %modal-container-base; + + max-block-size: initial; + min-inline-size: px2rem(608); + max-inline-size: px2rem(608); + padding: var(--sp-xxxl); +} + +.close-btn { + @extend %modal-close-btn-base; +} + +.modal-content { + display: flex; + gap: $sz-40; +} + +.modal-start { + display: flex; + justify-content: center; + min-inline-size: $sz-224; + + @media (width <= 640px) { + display: none; + } +} + +.modal-start svg { + inline-size: 100%; + block-size: auto; +} + +.modal-end { + color: var(--color-foreground-secondary); + display: flex; + flex-direction: column; + gap: var(--sp-m); +} + +.modal-title { + @include t.use-typography("title-large"); + + color: var(--modal-title-foreground-color); +} + +.modal-text-primary { + @include t.use-typography("body-large"); + + color: var(--color-foreground-primary); +} + +.modal-text { + @include t.use-typography("body-large"); +} + +.modal-button { + margin-block-start: var(--sp-s); + align-self: flex-start; +} diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs new file mode 100644 index 0000000000..131dfc257c --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.cljs @@ -0,0 +1,98 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.nitrate.nitrate-code-activation-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.main.data.modal :as modal] + [app.main.data.profile :as dprof] + [app.main.repo :as rp] + [app.main.store :as st] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.nitrate.nitrate-activation-success-modal] + [app.util.dom :as dom] + [app.util.i18n :refer [tr]] + [beicon.v2.core :as rx] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(mf/defc nitrate-code-activation-modal* + {::mf/register modal/components + ::mf/register-as :nitrate-code-activation} + [_props] + (let [value* (mf/use-state "") + error* (mf/use-state nil) + + on-change + (mf/use-fn + (fn [event] + (reset! error* nil) + (reset! value* (dom/get-target-val event)))) + + on-accept + (mf/use-fn + (mf/deps value*) + (fn [_] + (let [code (str/trim @value*)] + (when (seq code) + (->> (rp/cmd! ::redeem-nitrate-activation-code {:activation-code code}) + (rx/subs! + (fn [result] + (modal/hide!) + (st/emit! + (modal/show {:type :nitrate-activation-success :subscription result}) + (dprof/refresh-profile))) + (fn [error] + ;; TODO: "Already used" is not yet detectable (CC upserts on reuse). + (let [code (-> error ex-data :code)] + (reset! error* (case code + :expired-activation-code (tr "nitrate.activation-code.expired-error") + (tr "nitrate.activation-code.invalid-error"))))))))))) + + on-key-down + (mf/use-fn + (mf/deps on-accept) + (fn [event] + (when (and (= "Enter" (.-key event)) (.-ctrlKey event)) + (on-accept event))))] + + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:> icon-button* {:variant "ghost" + :class (stl/css :close-btn) + :aria-label (tr "labels.close") + :on-click modal/hide! + :icon i/close}] + + [:div {:class (stl/css :modal-header)} + [:h2 {:class (stl/css :modal-title)} (tr "nitrate.code-activation.title")]] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css-case :code-field true :invalid (some? @error*))} + [:label {:class (stl/css :code-label)} + (tr "nitrate.code-activation.input-label")] + [:textarea {:class (stl/css :code-textarea) + :auto-focus true + :value @value* + :placeholder (tr "nitrate.code-activation.placeholder") + :on-change on-change + :on-key-down on-key-down}] + (when @error* + [:span {:class (stl/css :error-msg)} @error*])] + + [:input + {:type "button" + :class (stl/css-case :accept-btn true + :global/disabled (empty? (str/trim @value*))) + :disabled (empty? (str/trim @value*)) + :value (tr "nitrate.code-activation.submit") + :on-click on-accept}] + [:div {:class (stl/css :footer-text)} + (tr "nitrate.code-activation.footer") " " + [:a {:class (stl/css :link) + :href "mailto:sales@nitrate.com"} + "sales@nitrate.com"]]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss new file mode 100644 index 0000000000..d241c38332 --- /dev/null +++ b/frontend/src/app/main/ui/nitrate/nitrate_code_activation_modal.scss @@ -0,0 +1,107 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/spacing.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; + +.close-btn { + @extend %modal-close-btn-base; +} + +.modal-overlay { + @extend %modal-overlay-base; + + z-index: var(--z-index-notifications); +} + +.modal-dialog { + @extend %modal-container-base; + + inline-size: $sz-480; + max-inline-size: $sz-480; + max-block-size: none; + max-height: none; + padding: var(--sp-xxxl); +} + +.modal-title { + @include t.use-typography("title-large"); + + color: var(--modal-title-foreground-color); + margin-block-end: var(--sp-xxxl); +} + +.modal-content { + display: flex; + flex-direction: column; + gap: var(--sp-m); + color: var(--color-foreground-secondary); +} + +.accept-btn { + @extend %modal-accept-btn; + + inline-size: 100%; +} + +.code-field { + display: flex; + flex-direction: column; + gap: var(--sp-xs); +} + +.code-label { + @include t.use-typography("body-medium"); + + color: var(--color-foreground-secondary); +} + +.code-textarea { + @include t.use-typography("body-medium"); + + block-size: $sz-200; + resize: vertical; + font-family: monospace; + word-break: break-all; + padding: var(--sp-s); + border-radius: $br-8; + border: $b-1 solid var(--input-border-color); + background-color: var(--input-background-color); + color: var(--color-foreground-primary); + outline: none; +} + +.code-textarea:focus { + border-color: var(--color-accent-primary); +} + +.invalid .code-textarea { + border-color: var(--input-border-color-error); +} + +.invalid .code-textarea:focus { + border-color: var(--input-border-color-error); +} + +.error-msg { + @include t.use-typography("body-small"); + + color: var(--element-foreground-error); +} + +.footer-text { + @include t.use-typography("body-medium"); + + color: var(--color-foreground-secondary); + margin-block-start: var(--sp-xxxl); +} + +.link { + color: var(--color-accent-primary); +} diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs index de55959c6f..13143d5337 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.cljs +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.cljs @@ -10,10 +10,14 @@ [app.common.schema :as sm] [app.main.data.modal :as modal] [app.main.data.nitrate :as dnt] + [app.main.refs :as refs] + [app.main.store :as st] [app.main.ui.components.forms :as fm] [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.nitrate.nitrate-code-activation-modal] + [app.util.i18n :refer [tr]] [rumext.v2 :as mf])) (def ^:private schema:nitrate-form @@ -27,6 +31,7 @@ [connectivity] (let [online? (:licenses connectivity) + profile (mf/deref refs/profile) initial (mf/with-memo [] {:subscription "yearly"}) form (fm/use-form :schema schema:nitrate-form @@ -35,7 +40,12 @@ (mf/use-fn (mf/deps form) (fn [] - (dnt/go-to-buy-nitrate-license (-> @form :clean-data :subscription name))))] + (dnt/go-to-buy-nitrate-license (-> @form :clean-data :subscription name)))) + + on-activate-click + (mf/use-fn + (fn [] + (st/emit! (modal/show {:type :nitrate-code-activation}))))] [:div {:class (stl/css :modal-overlay)} [:div {:class (stl/css :modal-dialog :subscription-success)} @@ -45,11 +55,11 @@ [:div {:class (stl/css :modal-success-content)} [:div {:class (stl/css :modal-start)} ;; TODO this svg is a placeholder. Use the proper one when created - [:> raw-svg* {:id "logo-subscription"}]] + [:> raw-svg* {:id "nitrate-welcome"}]] [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} - "Unlock Nitrate Features"] + (tr "nitrate.form.title")] [:p {:class (stl/css :modal-text-large)} "Prow scuttle parrel provost."] @@ -62,8 +72,8 @@ [:p {:class (stl/css :modal-text-large)} [:& fm/radio-buttons - {:options [{:label "Price Tag Montly" :value "monthly"} - {:label "Price Tag Yearly (Discount)" :value "yearly"}] + {:options [{:label (tr "nitrate.form.billing-monthly") :value "monthly"} + {:label (tr "nitrate.form.billing-yearly") :value "yearly"}] :name :subscription :class (stl/css :radio-btns)}]] @@ -72,20 +82,35 @@ [:> button* {:variant "primary" :on-click on-click :class (stl/css :modal-button)} - "UPGRADE TO NITRATE"] + (if (:subscription profile) + (tr "nitrate.form.upgrade") + (tr "nitrate.form.try-free"))] [:div {:class (stl/css :modal-text-small :modal-info)} - "Cancel anytime before your next billing cycle."]]] + (tr "nitrate.form.cancel-anytime")]]] + [:p {:class (stl/css :modal-text-medium)} + (tr "nitrate.form.have-code") " " [:a {:class (stl/css :link) + :on-click on-activate-click} + (tr "nitrate.form.enter-code")]] [:p {:class (stl/css :modal-text-medium)} [:a {:class (stl/css :link) :href dnt/go-to-subscription-url} - "See my current plan"]]] + (tr "nitrate.form.see-plan")]]] [:div {:class (stl/css :contact)} [:p {:class (stl/css :modal-text-large)} - "Contact us to upgrade to Nitrate:"] + (if (:subscription profile) + (tr "nitrate.form.contact-upgrade") + (tr "nitrate.form.contact-trial"))] [:p {:class (stl/css :modal-text-large)} [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} - "sales@penpot.app"]]])]]]])) + "sales@penpot.app"]] + [:div {:class (stl/css :activation-code)} + [:p {:class (stl/css :modal-text-large)} + (tr "nitrate.form.have-code")] + [:p {:class (stl/css :modal-text-large)} + [:a {:class (stl/css :link) + :on-click on-activate-click} + (tr "nitrate.form.enter-code")]]]])]]]])) diff --git a/frontend/src/app/main/ui/nitrate/nitrate_form.scss b/frontend/src/app/main/ui/nitrate/nitrate_form.scss index bc48fe7a6d..76942a6f7a 100644 --- a/frontend/src/app/main/ui/nitrate/nitrate_form.scss +++ b/frontend/src/app/main/ui/nitrate/nitrate_form.scss @@ -12,22 +12,31 @@ @use "ds/_utils.scss" as *; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; + z-index: var(--z-index-notifications); } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; + max-block-size: initial; - min-inline-size: px2rem(648); + min-inline-size: px2rem(1021); + padding: px2rem(80); + + @media (width <= 1024px) { + min-inline-size: px2rem(712); + padding: var(--sp-xxxl); + } } .close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-title { @include t.use-typography("title-large"); + margin-block-end: var(--sp-xxxl); color: var(--modal-title-foreground-color); display: flex; @@ -66,33 +75,42 @@ .modal-start { display: flex; justify-content: center; - max-inline-size: $sz-224; + min-inline-size: $sz-284; - svg { - inline-size: 100%; - block-size: auto; - } - - @media (max-inline-size: 992px) { + @media (width <= 992px) { display: none; } } -.radio-btns { - label { - @include t.use-typography("body-large"); - padding: 0; - display: flex; - align-items: center; - } +.modal-start svg { + inline-size: 100%; + block-size: auto; +} +.radio-btns { display: flex; flex-direction: column; padding: var(--sp-l) 0 0 0; gap: 0; } +.radio-btns label { + @include t.use-typography("body-large"); + + padding: 0; + display: flex; + align-items: center; +} + .contact { margin-block-start: $sz-96; color: var(--color-foreground-primary); } + +.activation-code { + margin-block-start: var(--sp-xxxl); +} + +.link { + color: var(--color-accent-primary); +} diff --git a/frontend/src/app/main/ui/notifications.cljs b/frontend/src/app/main/ui/notifications.cljs index de7161db99..e318946b6a 100644 --- a/frontend/src/app/main/ui/notifications.cljs +++ b/frontend/src/app/main/ui/notifications.cljs @@ -27,14 +27,7 @@ (= :floating (:position notification))) toast? (or (= :toast (:type notification)) (some? (:timeout notification))) - content (or (:content notification) "") - - show-detail* (mf/use-state false) - - handle-toggle-detail - (mf/use-fn - (fn [] - (swap! show-detail* not)))] + content (or (:content notification) "")] (when notification (cond @@ -43,9 +36,8 @@ {:level (or (:level notification) :info) :type (:type notification) :detail (:detail notification) - :on-close on-close - :show-detail @show-detail* - :on-toggle-detail handle-toggle-detail} content] + :on-close on-close} + content] inline? [:& inline-notification diff --git a/frontend/src/app/main/ui/notifications/badge.scss b/frontend/src/app/main/ui/notifications/badge.scss index 99941b8fb4..54f46964ce 100644 --- a/frontend/src/app/main/ui/notifications/badge.scss +++ b/frontend/src/app/main/ui/notifications/badge.scss @@ -7,10 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .badge-notification { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + --badge-notification-bg-color: var(--alert-background-color-default); --badge-notification-fg-color: var(--alert-text-foreground-color-default); --badge-notification-border-color: var(--alert-border-color-default); + box-sizing: border-box; display: grid; place-items: center; @@ -29,7 +31,8 @@ } .small { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + min-height: deprecated.$s-20; border-radius: deprecated.$br-6; } diff --git a/frontend/src/app/main/ui/notifications/context_notification.scss b/frontend/src/app/main/ui/notifications/context_notification.scss index 1b14e33cea..aa38cea54a 100644 --- a/frontend/src/app/main/ui/notifications/context_notification.scss +++ b/frontend/src/app/main/ui/notifications/context_notification.scss @@ -11,6 +11,7 @@ --context-notification-fg-color: var(--alert-text-foreground-color-default); --context-notification-icon-color: var(--alert-icon-foreground-color-default); --context-notification-border-color: var(--alert-border-color-default); + box-sizing: border-box; display: grid; grid-template-columns: deprecated.$s-16 1fr; @@ -60,13 +61,15 @@ } .icon { - @extend .button-icon; + @extend %button-icon; + align-self: self-start; stroke: var(--context-notification-icon-color); } .context-text { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + align-self: center; color: var(--context-notification-fg-color); margin: auto 0; @@ -78,7 +81,8 @@ .link, .contain-html .context-text a { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + align-self: center; display: inline; text-align: left; diff --git a/frontend/src/app/main/ui/notifications/inline_notification.scss b/frontend/src/app/main/ui/notifications/inline_notification.scss index ee71bc5c3d..4679db4026 100644 --- a/frontend/src/app/main/ui/notifications/inline_notification.scss +++ b/frontend/src/app/main/ui/notifications/inline_notification.scss @@ -20,5 +20,7 @@ } .link { + @extend %link; + margin: 0; } diff --git a/frontend/src/app/main/ui/onboarding/questions.scss b/frontend/src/app/main/ui/onboarding/questions.scss index 94444a493b..24ca56f535 100644 --- a/frontend/src/app/main/ui/onboarding/questions.scss +++ b/frontend/src/app/main/ui/onboarding/questions.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -15,8 +15,7 @@ max-height: fit-content; width: fit-content; padding-inline: deprecated.$s-100; - padding-block-start: deprecated.$s-40; - padding-block-end: deprecated.$s-72; + padding-block: deprecated.$s-40 deprecated.$s-72; border-radius: deprecated.$br-8; border: deprecated.$s-2 solid var(--modal-border-color); background-color: var(--modal-background-color); @@ -30,26 +29,28 @@ // STEP CONTAINER .paginator { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + height: deprecated.$s-20; text-align: right; color: var(--modal-text-foreground-color); } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } + .next-button { - @extend .modal-accept-btn; + @extend %modal-accept-btn; } .prev-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } .radio-btns label, .select-class span { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; } // STEP 1 @@ -61,21 +62,24 @@ } .modal-title { - @include deprecated.bigTitleTipography; + @include deprecated.big-title-typography; + color: var(--modal-title-foreground-color); min-height: deprecated.$s-32; margin-block: auto; } .modal-subtitle { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); margin: 0; padding: 0; } .modal-text { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); margin: 0; } @@ -88,6 +92,7 @@ max-width: deprecated.$s-540; width: deprecated.$s-540; } + .step-2 { grid-template-rows: deprecated.$s-20 auto auto deprecated.$s-32; } @@ -121,8 +126,7 @@ display: grid; grid-template-rows: 1fr 1fr; grid-template-columns: deprecated.$s-92 deprecated.$s-92 deprecated.$s-92; - row-gap: deprecated.$s-16; - column-gap: deprecated.$s-24; + gap: deprecated.$s-16 deprecated.$s-24; justify-content: center; } @@ -133,7 +137,7 @@ } .input-spacing input { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; } // STEP-4 diff --git a/frontend/src/app/main/ui/onboarding/team_choice.cljs b/frontend/src/app/main/ui/onboarding/team_choice.cljs index 0163de6c3d..49dd1cb4e8 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.cljs +++ b/frontend/src/app/main/ui/onboarding/team_choice.cljs @@ -237,7 +237,7 @@ [:div {:class (stl/css-case :modal-overlay true)} - [:div.animated.fadeIn {:class (stl/css :modal-container)} + [:div.animated.fade-in {:class (stl/css :modal-container)} [:h1 {:class (stl/css :modal-title)} (tr "onboarding-v2.welcome.title")] [:div {:class (stl/css :modal-sections)} diff --git a/frontend/src/app/main/ui/onboarding/team_choice.scss b/frontend/src/app/main/ui/onboarding/team_choice.scss index 067a1f1346..8b6487e53d 100644 --- a/frontend/src/app/main/ui/onboarding/team_choice.scss +++ b/frontend/src/app/main/ui/onboarding/team_choice.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -16,8 +16,7 @@ max-height: deprecated.$s-800; height: 100%; padding-inline: deprecated.$s-100; - padding-block-start: deprecated.$s-40; - padding-block-end: deprecated.$s-40; + padding-block: deprecated.$s-40 deprecated.$s-40; border-radius: deprecated.$br-8; background-color: var(--modal-background-color); border: deprecated.$s-2 solid var(--modal-border-color); @@ -35,7 +34,8 @@ } .paginator { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + position: absolute; top: deprecated.$s-40; right: deprecated.$s-100; @@ -54,12 +54,14 @@ } .modal-title { - @include deprecated.bigTitleTipography; + @include deprecated.big-title-typography; + color: var(--modal-title-foreground-color); } .modal-subtitle { - @include deprecated.medTitleTipography; + @include deprecated.med-title-typography; + color: var(--modal-title-foreground-color); } @@ -68,51 +70,59 @@ } .modal-text { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); margin: 0; } .modal-desc { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + margin: 0; color: var(--modal-title-foreground-color); } .team-features { - @include deprecated.flexColumn; + @include deprecated.flex-column; + gap: deprecated.$s-16; margin: 0; } .feature { - @include deprecated.flexRow; + @include deprecated.flex-row; + gap: deprecated.$s-16; } .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-32; width: deprecated.$s-32; border-radius: deprecated.$br-circle; border: deprecated.$s-1 solid var(--color-accent-primary); + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--color-accent-primary); } } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; + justify-content: flex-end; } .accept-button { - @extend .modal-accept-btn; + @extend %modal-accept-btn; } .back-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } // SEPARATOR @@ -120,7 +130,7 @@ width: deprecated.$s-8; height: 100%; border-radius: deprecated.$br-8; - opacity: 42%; + opacity: 0.42; background-color: var(--modal-separator-background-color); } @@ -140,7 +150,8 @@ .first-block, .second-block { - @include deprecated.flexColumn; + @include deprecated.flex-column; + gap: deprecated.$s-16; } @@ -151,10 +162,12 @@ } .team-name-input { - @extend .input-element-label; + @extend %input-element-label; + label { - @include deprecated.flexColumn; - @include deprecated.bodySmallTypography; + @include deprecated.flex-column; + @include deprecated.body-small-typography; + align-items: flex-start; width: 100%; border: none; @@ -162,7 +175,8 @@ height: 100%; input { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin-top: deprecated.$s-8; } } @@ -187,7 +201,8 @@ } .role-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + margin-block-end: deprecated.$s-8; color: var(--modal-title-foreground-color); } @@ -198,7 +213,8 @@ } .modal-hint { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--modal-text-foreground-color); text-align: right; } diff --git a/frontend/src/app/main/ui/releases.cljs b/frontend/src/app/main/ui/releases.cljs index 7919fc045b..7776e783a9 100644 --- a/frontend/src/app/main/ui/releases.cljs +++ b/frontend/src/app/main/ui/releases.cljs @@ -54,7 +54,7 @@ (let [slide* (mf/use-state :start) slide (deref slide*) - klass* (mf/use-state "fadeInDown") + klass* (mf/use-state "fade-in-down") klass (deref klass*) navigate @@ -79,7 +79,7 @@ (mf/with-effect [slide] (when (not= :start slide) - (reset! klass* "fadeIn")) + (reset! klass* "fade-in")) (let [sem (tm/schedule 300 #(reset! klass* nil))] (fn [] (reset! klass* nil) diff --git a/frontend/src/app/main/ui/releases.scss b/frontend/src/app/main/ui/releases.scss deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/frontend/src/app/main/ui/releases/common.cljs b/frontend/src/app/main/ui/releases/common.cljs index 4e3ce7cc5e..3da4516e0c 100644 --- a/frontend/src/app/main/ui/releases/common.cljs +++ b/frontend/src/app/main/ui/releases/common.cljs @@ -11,7 +11,7 @@ (defmulti render-release-notes :version) -(mf/defc navigation-bullets +(mf/defc navigation-bullets* [{:keys [slide navigate total]}] [:ul {:class (stl/css :step-dots)} (for [i (range total)] diff --git a/frontend/src/app/main/ui/releases/common.scss b/frontend/src/app/main/ui/releases/common.scss index 977411aec5..e3ab396ec1 100644 --- a/frontend/src/app/main/ui/releases/common.scss +++ b/frontend/src/app/main/ui/releases/common.scss @@ -15,8 +15,7 @@ width: fit-content; margin: 0; padding: 0; - align-self: center; - justify-self: flex-start; + place-self: center flex-start; } .dot { diff --git a/frontend/src/app/main/ui/releases/v1_11.cljs b/frontend/src/app/main/ui/releases/v1_11.cljs index 395cd72ee7..7542f9339b 100644 --- a/frontend/src/app/main/ui/releases/v1_11.cljs +++ b/frontend/src/app/main/ui/releases/v1_11.cljs @@ -45,7 +45,7 @@ [:p "Use dissolve, slide and push animations to fade screens and imitate gestures like swipe."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -64,7 +64,7 @@ [:p "Now you can decide to include their backgrounds on your exports or leave them out."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -83,7 +83,7 @@ [:p "We’ve also added two new options to scale your designs at the view mode that might help you to make your presentations look better."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_12.cljs b/frontend/src/app/main/ui/releases/v1_12.cljs index 65d7e2a41d..b38d7b12a1 100644 --- a/frontend/src/app/main/ui/releases/v1_12.cljs +++ b/frontend/src/app/main/ui/releases/v1_12.cljs @@ -45,7 +45,7 @@ [:p "Along with a better organization of panels (say hello to typography toolbar!) and new shortcuts that will speed your workflow."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "And they don’t come alone, but with some nice improvements to the rulers."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -82,7 +82,7 @@ [:p "Scrollbars at the design workspace will make it more obvious how to navigate it and easier for some users, for instance those who love using graphic tablets, from now on, will feel just as comfortable as those who use a mouseAnd they don’t come alone, but with some nice improvements to the rulers."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -101,7 +101,7 @@ [:p "This is a must if you’re working with grids (if you’re not, you should ;)), being able to adjust the movement to your baseline grid (8px? 5px?) is a huge timesaver that will improve your quality of life while designing."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_13.cljs b/frontend/src/app/main/ui/releases/v1_13.cljs index 39ad2c79ac..9d3c0e6b0a 100644 --- a/frontend/src/app/main/ui/releases/v1_13.cljs +++ b/frontend/src/app/main/ui/releases/v1_13.cljs @@ -45,7 +45,7 @@ [:p "Use the export window to manage your multiple exports and be informed about the download progress. Big exports will happen in the background so you can keep designing in the meantime ;)"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "This opens endless graphic possibilities such as combining gradients and blending modes in the same element to create sophisticated visual effects."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "A refreshed interface and two new features! The Invitations section allows you to check the status of current team invites plus you now have the ability to invite multiple members at the same time."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "As a side effect, this can give you a performance boost in massive designs."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_14.cljs b/frontend/src/app/main/ui/releases/v1_14.cljs index 334e993f62..106ffaaf49 100644 --- a/frontend/src/app/main/ui/releases/v1_14.cljs +++ b/frontend/src/app/main/ui/releases/v1_14.cljs @@ -45,7 +45,7 @@ [:p "Categories and filters will help you to find the shortcut you need. One of the most requested features by the community!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Play with the colors of a group without the hassles of individual selection!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Ideal for prototyping fixed headers, navbars and floating buttons."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "Until now you could only do it by renaming the groups, now with drag & drop it is much more user friendly."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_15.cljs b/frontend/src/app/main/ui/releases/v1_15.cljs index 9cdb26b079..0d04a305e4 100644 --- a/frontend/src/app/main/ui/releases/v1_15.cljs +++ b/frontend/src/app/main/ui/releases/v1_15.cljs @@ -45,7 +45,7 @@ [:p "Say goodbye to Artboards and hello to Boards!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Now you can thanks to new permissions that allow you to decide who can comment and/or inspect the code at a shared prototype link."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Also, comments inside boards will be associated with it, so that if you move a board its comments will maintain its place inside it."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "We’ve also made some adjustments to ensure the access to the options from small screens."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_16.cljs b/frontend/src/app/main/ui/releases/v1_16.cljs index 537507abb6..26db75b099 100644 --- a/frontend/src/app/main/ui/releases/v1_16.cljs +++ b/frontend/src/app/main/ui/releases/v1_16.cljs @@ -45,7 +45,7 @@ [:p "We heard the users before refreshing the interface, simplifying it to give prominence to the content. And yes, now that you ask, the dark theme is coming soon."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "You no longer need to to download most of them to the computer before importing."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "More relevant info and better explanations, a refined new team and invitation flow, a beginners tutorial and a walkthrough file that will help newcomers learn how to use and start designing with Penpot faster."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "This was a contribution by our community member @andrewzhurov <3"]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_17.cljs b/frontend/src/app/main/ui/releases/v1_17.cljs index 1965748c6a..668f879f0d 100644 --- a/frontend/src/app/main/ui/releases/v1_17.cljs +++ b/frontend/src/app/main/ui/releases/v1_17.cljs @@ -45,7 +45,7 @@ [:p "Penpot brings a layout system like no other. As described by one of our beta testers: 'I love the fact that Penpot is following the CSS FlexBox, which is making UI Design a step closer to the logic and behavior behind how things will be actually built after design.'"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Also, inspect mode provides a safer view-only mode and other improvements."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "While we are still working on a plugin system, this is a great and simple way to create integrations with other services."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "This release comes with improvements on color contrasts, alt texts, semantic labels, focusable items and keyboard navigation at login and dashboard, but more will come."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_18.cljs b/frontend/src/app/main/ui/releases/v1_18.cljs index cb6d73458c..ff1c06d179 100644 --- a/frontend/src/app/main/ui/releases/v1_18.cljs +++ b/frontend/src/app/main/ui/releases/v1_18.cljs @@ -45,7 +45,7 @@ [:p "And not only that, when creating Flex layouts, the spacing is predicted, helping you to maintain your design composition."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Now you can exclude elements from the Flex layout flow using absolute position."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "This is another capability that brings Penpot Flex layout even closer to the power of CSS standards."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "Activate the scale tool by pressing K and scale your elements, maintaining their visual aspect."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_19.cljs b/frontend/src/app/main/ui/releases/v1_19.cljs index 8543a0c45b..6b51d8faaf 100644 --- a/frontend/src/app/main/ui/releases/v1_19.cljs +++ b/frontend/src/app/main/ui/releases/v1_19.cljs @@ -72,7 +72,7 @@ " in particular and the Penpot community as a whole!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}]]]]]] @@ -99,7 +99,7 @@ "to the Penpot’s plugins system."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_4.cljs b/frontend/src/app/main/ui/releases/v1_4.cljs index bc80923258..37ecf7be42 100644 --- a/frontend/src/app/main/ui/releases/v1_4.cljs +++ b/frontend/src/app/main/ui/releases/v1_4.cljs @@ -45,7 +45,7 @@ [:p "To open a file you just have to double click it. You can also open a file in a new tab with right click."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Also, now you have an easy way to manage files and projects between teams."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "If you write in arabic, hebrew or other RTL language text direction will be automatically detected in text layers."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "This is why the standard blend modes and opacity level are now available for each element."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_5.cljs b/frontend/src/app/main/ui/releases/v1_5.cljs index 8d962515d7..d183bd7976 100644 --- a/frontend/src/app/main/ui/releases/v1_5.cljs +++ b/frontend/src/app/main/ui/releases/v1_5.cljs @@ -45,7 +45,7 @@ [:p "The usability and performance of the paths tool has been improved too."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -64,7 +64,7 @@ [:p "It is time to have all the libraries well organized and work more efficiently."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]] @@ -83,7 +83,7 @@ [:p "It's easier to specify by how much you want to change a value and work with measures and distances."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_6.cljs b/frontend/src/app/main/ui/releases/v1_6.cljs index c6636c4550..cf1c96bbe9 100644 --- a/frontend/src/app/main/ui/releases/v1_6.cljs +++ b/frontend/src/app/main/ui/releases/v1_6.cljs @@ -45,7 +45,7 @@ [:p "We hope you enjoy having more typography options and our brand new font selector."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Disabled by default, this tool is disabled back after being used."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "You should have the feeling that files and layers show up a bit faster :)"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "An easy way to increase speed by working with vectors!"]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_7.cljs b/frontend/src/app/main/ui/releases/v1_7.cljs index 32666d5158..3d0c2db7da 100644 --- a/frontend/src/app/main/ui/releases/v1_7.cljs +++ b/frontend/src/app/main/ui/releases/v1_7.cljs @@ -48,7 +48,7 @@ suits you better!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -70,7 +70,7 @@ components."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -90,7 +90,7 @@ [:p "Easily " [:strong "rename and ungroup"] " asset groups."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -108,7 +108,7 @@ [:p "Do you sometimes copy and paste component copies that belong to a library already shared by the original and destination files? From now on, those component copies are aware of this and will retain their linkage to the library."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_8.cljs b/frontend/src/app/main/ui/releases/v1_8.cljs index dfff4bd9f2..fcf214cae9 100644 --- a/frontend/src/app/main/ui/releases/v1_8.cljs +++ b/frontend/src/app/main/ui/releases/v1_8.cljs @@ -45,7 +45,7 @@ [:p "You can also create a shareable link deciding which pages will be available for the visitors. Sharing is caring!"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "You can select different styles for each end of an open path: arrows, square, circle, diamond or just a round ending are the available options."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Quick and easy :)"]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p "Now you can easily export all the artboards of a page to a single pdf file."]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v1_9.cljs b/frontend/src/app/main/ui/releases/v1_9.cljs index 6a8ddfba80..d359063a6e 100644 --- a/frontend/src/app/main/ui/releases/v1_9.cljs +++ b/frontend/src/app/main/ui/releases/v1_9.cljs @@ -45,7 +45,7 @@ [:p "Create overlays, back buttons or links to URLs to mimic the behavior of the product you’re designing."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -64,7 +64,7 @@ [:p "Flows allow you to define multiple starting points within the same page so you can better organize and present your prototypes."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -83,7 +83,7 @@ [:p "Using boolean operations will lead to countless graphic possibilities for your designs."]] [:div.modal-navigation [:button.btn-secondary {:on-click next} "Continue"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]] @@ -102,7 +102,7 @@ [:p [:a {:alt "Explore libraries & templates" :target "_blank" :href "https://penpot.app/libraries-templates"} "Explore libraries & templates"]]] [:div.modal-navigation [:button.btn-secondary {:on-click finish} "Start!"] - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}]]]]]]))) diff --git a/frontend/src/app/main/ui/releases/v2_0.cljs b/frontend/src/app/main/ui/releases/v2_0.cljs index 57f2b0847b..fd1299d0d7 100644 --- a/frontend/src/app/main/ui/releases/v2_0.cljs +++ b/frontend/src/app/main/ui/releases/v2_0.cljs @@ -92,7 +92,7 @@ " up the design as code to take it from there."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -126,7 +126,7 @@ " and adherence to other best practices."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -161,7 +161,7 @@ "that will help you to better manage your design systems."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -193,7 +193,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_0.scss b/frontend/src/app/main/ui/releases/v2_0.scss index 0d5bc38d2e..f47c7c9043 100644 --- a/frontend/src/app/main/ui/releases/v2_0.scss +++ b/frontend/src/app/main/ui/releases/v2_0.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -38,8 +38,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -48,7 +49,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -66,18 +68,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -91,7 +96,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_1.scss b/frontend/src/app/main/ui/releases/v2_1.scss index 7b2559bc96..4b9913e040 100644 --- a/frontend/src/app/main/ui/releases/v2_1.scss +++ b/frontend/src/app/main/ui/releases/v2_1.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -38,8 +38,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -48,7 +49,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -60,7 +62,8 @@ } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } @@ -72,7 +75,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_10.cljs b/frontend/src/app/main/ui/releases/v2_10.cljs index fcefb326c5..297c1e77d6 100644 --- a/frontend/src/app/main/ui/releases/v2_10.cljs +++ b/frontend/src/app/main/ui/releases/v2_10.cljs @@ -74,7 +74,7 @@ "This release has been shaped by our amazing community. A huge thank-you to everyone who shared ideas, feedback, and insights to make Penpot Variants possible <3"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -113,7 +113,7 @@ " now to join us 8-10 October, in Madrid!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -143,7 +143,7 @@ "This latest update brings—no more no less than—six new token types, significantly boosting your ability to manage design decisions, particularly in typography: Font Family, Font Weight, Text Case, Text Decoration, Letter Spacing token, and Number token (for unitless values)."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -174,7 +174,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_10.scss b/frontend/src/app/main/ui/releases/v2_10.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_10.scss +++ b/frontend/src/app/main/ui/releases/v2_10.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_11.cljs b/frontend/src/app/main/ui/releases/v2_11.cljs index a4b330f8bc..529a6cb0a7 100644 --- a/frontend/src/app/main/ui/releases/v2_11.cljs +++ b/frontend/src/app/main/ui/releases/v2_11.cljs @@ -74,7 +74,7 @@ "The Typography token also marks a big step forward for Penpot: it’s our first composite token! Composite tokens are special because they can hold multiple properties within one token. Shadow token will be the next composite token coming your way."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -110,7 +110,7 @@ "- Reorder your component properties by drag & drop: Because organization matters, now you can arrange your properties however makes the most sense to you, so you can keep the ones you use most often right where you want them."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -148,7 +148,7 @@ "Invited users will also get clearer emails, including a reminder sent one day before the invite expires (after seven days). Simple, clean, and much more efficient."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -179,7 +179,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_11.scss b/frontend/src/app/main/ui/releases/v2_11.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_11.scss +++ b/frontend/src/app/main/ui/releases/v2_11.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_12.cljs b/frontend/src/app/main/ui/releases/v2_12.cljs index 43ac723024..342f92100c 100644 --- a/frontend/src/app/main/ui/releases/v2_12.cljs +++ b/frontend/src/app/main/ui/releases/v2_12.cljs @@ -80,7 +80,7 @@ "Developers now get a clearer context during handoff. The Inspect panel shows the actual token used in your design, in a similar way to how styles are displayed. This small detail reduces ambiguity, aligns everyone on the same language, and strengthens collaboration across the team."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -116,7 +116,7 @@ "It’s a subtle improvement, but it removes friction you feel hundreds of times a week, and makes component work flow more naturally."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -152,7 +152,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_12.scss b/frontend/src/app/main/ui/releases/v2_12.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_12.scss +++ b/frontend/src/app/main/ui/releases/v2_12.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_13.cljs b/frontend/src/app/main/ui/releases/v2_13.cljs index 149d914c61..54a0badaee 100644 --- a/frontend/src/app/main/ui/releases/v2_13.cljs +++ b/frontend/src/app/main/ui/releases/v2_13.cljs @@ -74,7 +74,7 @@ "Highly requested, long overdue, and now officially here."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -108,7 +108,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] diff --git a/frontend/src/app/main/ui/releases/v2_13.scss b/frontend/src/app/main/ui/releases/v2_13.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_13.scss +++ b/frontend/src/app/main/ui/releases/v2_13.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_14.cljs b/frontend/src/app/main/ui/releases/v2_14.cljs index b424d4bfa8..9dd3013274 100644 --- a/frontend/src/app/main/ui/releases/v2_14.cljs +++ b/frontend/src/app/main/ui/releases/v2_14.cljs @@ -74,7 +74,7 @@ "One extra detail: if you edit the path and change group segments, the token is moved to its new group (creating it if needed), and empty groups are automatically cleaned up."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -104,7 +104,7 @@ "If you’ve been waiting to generate tokens, sync them, or manipulate them from your own tools, this is the missing piece. And yes, this one has been requested a lot."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -134,7 +134,7 @@ "Remapping is always optional, because sometimes you don’t want to keep the current connections. When enabled, it affects all tokens in the file and also takes libraries into account, so main components can propagate changes to child components, and applied tokens update on the elements using them."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -168,7 +168,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_14.scss b/frontend/src/app/main/ui/releases/v2_14.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_14.scss +++ b/frontend/src/app/main/ui/releases/v2_14.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_15.cljs b/frontend/src/app/main/ui/releases/v2_15.cljs index 8c2f61580f..76f6527f02 100644 --- a/frontend/src/app/main/ui/releases/v2_15.cljs +++ b/frontend/src/app/main/ui/releases/v2_15.cljs @@ -74,7 +74,7 @@ "You can run MCP in two ways. Remote MCP is hosted and simpler to set up. Local MCP runs on your machine and gives advanced teams extra control. Same vision, different operating model."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -115,7 +115,7 @@ "This is where MCP becomes workflow infrastructure. Less manual glue work, fewer handoff gaps, and faster iterations between designers and developers."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -149,7 +149,7 @@ "]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_15.scss b/frontend/src/app/main/ui/releases/v2_15.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_15.scss +++ b/frontend/src/app/main/ui/releases/v2_15.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_2.scss b/frontend/src/app/main/ui/releases/v2_2.scss index 34d030466f..beb1bdf674 100644 --- a/frontend/src/app/main/ui/releases/v2_2.scss +++ b/frontend/src/app/main/ui/releases/v2_2.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -38,8 +38,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -48,7 +49,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -60,7 +62,8 @@ } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } @@ -72,7 +75,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_3.cljs b/frontend/src/app/main/ui/releases/v2_3.cljs index 8b3040b8f4..6063642485 100644 --- a/frontend/src/app/main/ui/releases/v2_3.cljs +++ b/frontend/src/app/main/ui/releases/v2_3.cljs @@ -72,7 +72,7 @@ "Find everything you need in our full comprehensive documentation to start building your plugins now!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] @@ -105,7 +105,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] diff --git a/frontend/src/app/main/ui/releases/v2_3.scss b/frontend/src/app/main/ui/releases/v2_3.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_3.scss +++ b/frontend/src/app/main/ui/releases/v2_3.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_4.cljs b/frontend/src/app/main/ui/releases/v2_4.cljs index 1559911a4d..67a3985127 100644 --- a/frontend/src/app/main/ui/releases/v2_4.cljs +++ b/frontend/src/app/main/ui/releases/v2_4.cljs @@ -72,7 +72,7 @@ "Now, you can invite members to your teams who only need to view and comment on files. Team members, stakeholders, developers… pick your case. Anyone who doesn't need to edit can participate confidently."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -102,7 +102,7 @@ "Some versions are saved automatically, serving as an invaluable emergency backup. Additionally, you can manually save versions, giving you full control over the timeline associated with a file. This way, you can always restore specific versions that you've intentionally saved."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -131,7 +131,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_4.scss b/frontend/src/app/main/ui/releases/v2_4.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_4.scss +++ b/frontend/src/app/main/ui/releases/v2_4.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_5.cljs b/frontend/src/app/main/ui/releases/v2_5.cljs index c39c4aebba..cce9c83d70 100644 --- a/frontend/src/app/main/ui/releases/v2_5.cljs +++ b/frontend/src/app/main/ui/releases/v2_5.cljs @@ -72,7 +72,7 @@ "And that’s not all. We’ve also added quick actions to flip and rotate gradients, plus now you can adjust the radius for radial gradients. More control, more flexibility, more fun."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -102,7 +102,7 @@ "We’ve also added a new section in your profile where you can customize your notifications, choosing what to receive on your dashboard and via email. On top of that, comments got a UI refresh, making everything clearer and better organized. And this is just the first batch of improvements—expect even more comment-related upgrades in the next Penpot release."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -136,7 +136,7 @@ "Less manual work for a faster workflow. We hope you find it as useful as we do."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -165,7 +165,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_5.scss b/frontend/src/app/main/ui/releases/v2_5.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_5.scss +++ b/frontend/src/app/main/ui/releases/v2_5.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_6.cljs b/frontend/src/app/main/ui/releases/v2_6.cljs index 9d47c870f7..92b4109fd7 100644 --- a/frontend/src/app/main/ui/releases/v2_6.cljs +++ b/frontend/src/app/main/ui/releases/v2_6.cljs @@ -84,7 +84,7 @@ your product needs."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -120,7 +120,7 @@ interoperability by design through Open Source."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -159,7 +159,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_6.scss b/frontend/src/app/main/ui/releases/v2_6.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_6.scss +++ b/frontend/src/app/main/ui/releases/v2_6.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_7.cljs b/frontend/src/app/main/ui/releases/v2_7.cljs index 1a5c562e25..0f6c51abc7 100644 --- a/frontend/src/app/main/ui/releases/v2_7.cljs +++ b/frontend/src/app/main/ui/releases/v2_7.cljs @@ -71,7 +71,7 @@ "The highlight: you can now duplicate token sets directly from a menu item. A huge time-saver, especially when working from existing sets. We’ve also made it easier to create themes by letting you select their set right away, and we’ve polished some info indicators to make everything a bit clearer. Plus, we’ve fixed a bunch of early-stage bugs to keep things running smoothly."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -101,7 +101,7 @@ "This update gives editors and viewers the same ability to configure, create, copy, and delete sharing links. A capability that, until now, was limited to owners and admins."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] @@ -132,7 +132,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 3}] diff --git a/frontend/src/app/main/ui/releases/v2_7.scss b/frontend/src/app/main/ui/releases/v2_7.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_7.scss +++ b/frontend/src/app/main/ui/releases/v2_7.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_8.cljs b/frontend/src/app/main/ui/releases/v2_8.cljs index fbdce6ee04..8eae8ff74e 100644 --- a/frontend/src/app/main/ui/releases/v2_8.cljs +++ b/frontend/src/app/main/ui/releases/v2_8.cljs @@ -83,7 +83,7 @@ "- And we’ve a new language! Hi Serbians!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -116,7 +116,7 @@ "This is just one more step in the evolution of Design Tokens in Penpot. And there's more to come: typography tokens are already in the works!"]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -149,7 +149,7 @@ "- We have integrated AI-powered help, which is trained on Penpot documentation, directly into the design workspace. Get assistance without switching context, so you can stay in the flow."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] @@ -186,7 +186,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 4}] diff --git a/frontend/src/app/main/ui/releases/v2_8.scss b/frontend/src/app/main/ui/releases/v2_8.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_8.scss +++ b/frontend/src/app/main/ui/releases/v2_8.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/releases/v2_9.cljs b/frontend/src/app/main/ui/releases/v2_9.cljs index 600df72665..bd71956516 100644 --- a/frontend/src/app/main/ui/releases/v2_9.cljs +++ b/frontend/src/app/main/ui/releases/v2_9.cljs @@ -71,7 +71,7 @@ "And there’s more progress on Tokens, including support for importing multiple token files via .zip, and smarter token visibility, only showing the relevant tokens for each layer type."]] [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] @@ -102,7 +102,7 @@ [:div {:class (stl/css :navigation)} - [:& c/navigation-bullets + [:> c/navigation-bullets* {:slide slide :navigate navigate :total 2}] diff --git a/frontend/src/app/main/ui/releases/v2_9.scss b/frontend/src/app/main/ui/releases/v2_9.scss index e5d13841eb..40c8f5316f 100644 --- a/frontend/src/app/main/ui/releases/v2_9.scss +++ b/frontend/src/app/main/ui/releases/v2_9.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { @@ -42,8 +42,9 @@ } .version-tag { - @include deprecated.flexCenter; - @include deprecated.headlineSmallTypography; + @include deprecated.flex-center; + @include deprecated.headline-small-typography; + height: deprecated.$s-32; width: deprecated.$s-96; background-color: var(--communication-tag-background-color); @@ -52,7 +53,8 @@ } .modal-title { - @include deprecated.headlineLargeTypography; + @include deprecated.headline-large-typography; + color: var(--modal-title-foreground-color); } @@ -70,18 +72,21 @@ } .feature-title { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-title-foreground-color); } .feature-content { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + margin: 0; color: var(--modal-text-foreground-color); } .feature-list { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--modal-text-foreground-color); list-style: disc; display: grid; @@ -95,7 +100,8 @@ } .next-btn { - @extend .button-primary; + @extend %button-primary; + width: deprecated.$s-100; justify-self: flex-end; grid-area: button; diff --git a/frontend/src/app/main/ui/routes.cljs b/frontend/src/app/main/ui/routes.cljs index ca45bc5133..920a79605f 100644 --- a/frontend/src/app/main/ui/routes.cljs +++ b/frontend/src/app/main/ui/routes.cljs @@ -30,6 +30,9 @@ ["/recovery" :auth-recovery] ["/verify-token" :auth-verify-token]] + (when (contains? cf/flags :nitrate) + ["/subscribe-nitrate" :nitrate-entry]) + ["/settings" ["/profile" :settings-profile] ["/password" :settings-password] diff --git a/frontend/src/app/main/ui/settings.scss b/frontend/src/app/main/ui/settings.scss index 2963138812..91cbc781a5 100644 --- a/frontend/src/app/main/ui/settings.scss +++ b/frontend/src/app/main/ui/settings.scss @@ -33,6 +33,7 @@ &.dashboard-projects { user-select: none; } + &.dashboard-shared { width: calc(100vw - deprecated.$s-320); margin-right: deprecated.$s-52; @@ -48,13 +49,13 @@ width: 100%; justify-content: center; align-items: center; + a { color: var(--color-foreground-secondary); } } .form-container { - width: deprecated.$s-800; margin: deprecated.$s-48 auto deprecated.$s-32 deprecated.$s-120; display: flex; max-width: deprecated.$s-368; @@ -76,6 +77,7 @@ .custom-input, .custom-select { flex-direction: column-reverse; + label { position: relative; text-transform: uppercase; @@ -84,6 +86,7 @@ margin-bottom: deprecated.$s-12; margin-left: calc(-1 * deprecated.$s-4); } + input, select { background-color: var(--color-background-tertiary); @@ -91,20 +94,25 @@ border-color: transparent; color: var(--color-foreground-primary); padding: 0 deprecated.$s-16; + &:focus { outline: deprecated.$s-1 solid var(--color-accent-primary); } + ::placeholder { color: var(--color-foreground-secondary); } } + .help-icon { bottom: deprecated.$s-12; top: auto; + svg { fill: var(--color-foreground-secondary); } } + &.disabled { input { background-color: var(--input-background-color-disabled); @@ -112,30 +120,36 @@ color: var(--color-foreground-secondary); } } + .input-container { background-color: var(--color-background-tertiary); border-radius: deprecated.$s-8; border-color: transparent; margin-top: deprecated.$s-24; + .main-content { label { position: absolute; top: calc(-1 * deprecated.$s-24); } + span { color: var(--color-foreground-primary); } } + &:focus { border: deprecated.$s-1 solid var(--color-accent-primary); } } + textarea { border-radius: deprecated.$s-8; padding: deprecated.$s-12 deprecated.$s-16; background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); border: none; + &:focus { outline: deprecated.$s-1 solid var(--color-accent-primary); } @@ -145,6 +159,7 @@ .field-title { color: var(--color-foreground-primary); } + .field-title:not(:first-child) { margin-top: deprecated.$s-64; } @@ -152,6 +167,7 @@ .field-text { color: var(--color-foreground-secondary); } + button, .btn-secondary { width: 100%; @@ -159,15 +175,18 @@ text-transform: uppercase; background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); + &:hover { color: var(--color-accent-primary); background-color: var(--color-background-quaternary); } } + hr { display: none; } } + .links { margin-top: deprecated.$s-12; } diff --git a/frontend/src/app/main/ui/settings/change_email.scss b/frontend/src/app/main/ui/settings/change_email.scss index 71900cf9e4..60044a05a4 100644 --- a/frontend/src/app/main/ui/settings/change_email.scss +++ b/frontend/src/app/main/ui/settings/change_email.scss @@ -7,11 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + min-width: deprecated.$s-408; } @@ -20,37 +21,41 @@ } .modal-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.flexColumn; - @include deprecated.bodySmallTypography; + @include deprecated.flex-column; + @include deprecated.body-small-typography; + gap: deprecated.$s-24; margin-bottom: deprecated.$s-24; } .fields-row { - @include deprecated.flexColumn; + @include deprecated.flex-column; } .select-title { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--modal-title-foreground-color); } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; + button { - @extend .modal-accept-btn; + @extend %modal-accept-btn; } } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } diff --git a/frontend/src/app/main/ui/settings/delete_account.scss b/frontend/src/app/main/ui/settings/delete_account.scss index c69d17de53..4b0b6408c9 100644 --- a/frontend/src/app/main/ui/settings/delete_account.scss +++ b/frontend/src/app/main/ui/settings/delete_account.scss @@ -7,11 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + min-width: deprecated.$s-408; } @@ -20,41 +21,45 @@ } .modal-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.flexColumn; - @include deprecated.bodySmallTypography; + @include deprecated.flex-column; + @include deprecated.body-small-typography; + gap: deprecated.$s-24; margin-bottom: deprecated.$s-24; } .fields-row { - @include deprecated.flexColumn; + @include deprecated.flex-column; } .select-title { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--modal-title-foreground-color); } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } .accept-button { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } diff --git a/frontend/src/app/main/ui/settings/feedback.scss b/frontend/src/app/main/ui/settings/feedback.scss index ee91182b80..99e449267d 100644 --- a/frontend/src/app/main/ui/settings/feedback.scss +++ b/frontend/src/app/main/ui/settings/feedback.scss @@ -19,6 +19,7 @@ .feedback-description { @include t.use-typography("body-medium"); + border-radius: b.$br-8; padding: var(--sp-m); background-color: var(--color-background-tertiary); @@ -28,6 +29,7 @@ ::placeholder { color: var(--input-placeholder-color); } + &:focus { outline: b.$b-1 solid var(--color-accent-primary); } @@ -35,13 +37,15 @@ .field-label { @include t.use-typography("headline-small"); + block-size: $sz-32; color: var(--color-foreground-primary); margin-block-end: var(--sp-l); } .feedback-button-link { - @extend .button-primary; + @extend %button-primary; + margin-block-end: px2rem(72); } @@ -59,12 +63,14 @@ .link { @include t.use-typography("headline-small"); + color: var(--color-accent-tertiary); margin-block-end: var(--sp-s); } .download-button { @include t.use-typography("body-small"); + color: var(--color-foreground-primary); text-transform: lowercase; border: b.$b-1 solid var(--color-background-quaternary); diff --git a/frontend/src/app/main/ui/settings/integrations.scss b/frontend/src/app/main/ui/settings/integrations.scss index d7be475bb4..e1833c1c6e 100644 --- a/frontend/src/app/main/ui/settings/integrations.scss +++ b/frontend/src/app/main/ui/settings/integrations.scss @@ -5,7 +5,6 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; - @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/mixins.scss" as *; @@ -44,11 +43,12 @@ } .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + inline-size: $sz-400; max-block-size: fit-content; position: relative; @@ -187,7 +187,8 @@ } .item-title { - @include textEllipsis; + @include text-ellipsis; + align-content: center; block-size: $sz-64; padding: 0 var(--sp-l); @@ -222,6 +223,7 @@ .textarea { @include t.use-typography("body-small"); + border-radius: $br-8; background-color: var(--color-background-tertiary); color: var(--color-foreground-secondary); diff --git a/frontend/src/app/main/ui/settings/notifications.cljs b/frontend/src/app/main/ui/settings/notifications.cljs index 5779474c70..d9347b5ee9 100644 --- a/frontend/src/app/main/ui/settings/notifications.cljs +++ b/frontend/src/app/main/ui/settings/notifications.cljs @@ -82,6 +82,7 @@ [:> fm/submit-button* {:label (tr "dashboard.settings.notifications.submit") + :disabled (= (:data @form) (:initial @form)) :data-testid "submit-settings" :class (stl/css :update-btn)}]]]])) diff --git a/frontend/src/app/main/ui/settings/notifications.scss b/frontend/src/app/main/ui/settings/notifications.scss index 27a2273536..4aaf1ac096 100644 --- a/frontend/src/app/main/ui/settings/notifications.scss +++ b/frontend/src/app/main/ui/settings/notifications.scss @@ -9,7 +9,9 @@ .update-btn { margin-top: deprecated.$s-16; - @extend .button-primary; + + @extend %button-primary; + height: deprecated.$s-36; } diff --git a/frontend/src/app/main/ui/settings/options.cljs b/frontend/src/app/main/ui/settings/options.cljs index aa3ad3e064..7a2fc59413 100644 --- a/frontend/src/app/main/ui/settings/options.cljs +++ b/frontend/src/app/main/ui/settings/options.cljs @@ -80,6 +80,7 @@ [:> fm/submit-button* {:label (tr "dashboard.update-settings") + :disabled (= (:data @form) (:initial @form)) :data-testid "submit-lang-change" :class (stl/css :btn-primary)}]])) diff --git a/frontend/src/app/main/ui/settings/options.scss b/frontend/src/app/main/ui/settings/options.scss index 2df1d9235f..abe949897f 100644 --- a/frontend/src/app/main/ui/settings/options.scss +++ b/frontend/src/app/main/ui/settings/options.scss @@ -25,9 +25,8 @@ grid-auto-rows: auto; gap: var(--sp-s); width: $sz-500; - margin-block-start: var(--sp-xxxl); + margin-block: var(--sp-xxxl) $sz-120; /* FIXME: this should be a proper token */ padding-block-start: var(--sp-xxxl); - margin-block-end: $sz-120; /* FIXME: this should be a proper token */ border-block-start: $b-1 solid var(--color-background-quaternary); color: var(--color-foreground-primary); } diff --git a/frontend/src/app/main/ui/settings/password.scss b/frontend/src/app/main/ui/settings/password.scss index 5a0551333e..504a6da2e5 100644 --- a/frontend/src/app/main/ui/settings/password.scss +++ b/frontend/src/app/main/ui/settings/password.scss @@ -9,6 +9,8 @@ .update-btn { margin-top: deprecated.$s-16; - @extend .button-primary; + + @extend %button-primary; + height: deprecated.$s-36; } diff --git a/frontend/src/app/main/ui/settings/profile.cljs b/frontend/src/app/main/ui/settings/profile.cljs index 763ee3c836..b8903d4027 100644 --- a/frontend/src/app/main/ui/settings/profile.cljs +++ b/frontend/src/app/main/ui/settings/profile.cljs @@ -16,6 +16,7 @@ [app.main.store :as st] [app.main.ui.components.file-uploader :refer [file-uploader]] [app.main.ui.components.forms :as fm] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -25,13 +26,12 @@ [:fullname [::sm/text {:max 250}]] [:email ::sm/email]]) -(defn- on-success - [_] - (st/emit! (ntf/success (tr "notifications.profile-saved")))) - (defn- on-submit [form _event] - (let [data (:clean-data @form)] + (let [data (:clean-data @form) + on-success (fn [_] + (swap! form assoc :touched {}) + (st/emit! (ntf/success (tr "notifications.profile-saved"))))] (st/emit! (du/update-profile data) (du/persist-profile {:on-success on-success})))) @@ -92,6 +92,7 @@ [] (let [input-ref (mf/use-ref nil) profile (mf/deref refs/profile) + has-photo? (some? (:photo-id profile)) photo (mf/with-memo [profile] @@ -103,13 +104,32 @@ on-file-selected (fn [file] - (st/emit! (du/update-photo file)))] + (st/emit! (du/update-photo file))) + + on-delete-click + (mf/use-fn + (fn [event] + (dom/stop-propagation event) + (st/emit! (modal/show + {:type :confirm + :title (tr "labels.delete-profile-photo.title") + :message (tr "labels.delete-profile-photo.message") + :accept-label (tr "labels.delete") + :on-accept (fn [_] (st/emit! du/delete-photo))}))))] [:form {:class (stl/css :avatar-form)} [:div {:class (stl/css :image-change-field)} [:span {:class (stl/css :update-overlay) :on-click on-image-click} (tr "labels.update")] [:img {:src photo}] + (when has-photo? + [:button {:type "button" + :class (stl/css :delete-overlay) + :title (tr "labels.delete") + :aria-label (tr "labels.delete") + :on-click on-delete-click + :data-testid "profile-image-delete"} + [:> icon* {:icon-id i/delete :size "m"}]]) [:& file-uploader {:accept "image/jpeg,image/png" :multi false :ref input-ref diff --git a/frontend/src/app/main/ui/settings/profile.scss b/frontend/src/app/main/ui/settings/profile.scss index 4e8473b6e8..ce9d3b3b0f 100644 --- a/frontend/src/app/main/ui/settings/profile.scss +++ b/frontend/src/app/main/ui/settings/profile.scss @@ -11,17 +11,16 @@ width: 100%; justify-content: center; align-items: center; - a:not(.button-primary):not(.link) { + + a:not(.button-primary, .link) { color: var(--color-foreground-secondary); } } .form-container { display: flex; - justify-content: center; flex-direction: column; max-width: $s-500; - margin-bottom: $s-32; width: $s-580; margin: $s-80 auto $s-120 auto; justify-content: center; @@ -36,11 +35,13 @@ text-transform: uppercase; background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); + &:hover { color: var(--color-accent-primary); background-color: var(--color-background-quaternary); } } + hr { display: none; } @@ -48,6 +49,7 @@ .fields-row { --input-height: #{$s-40}; + margin-bottom: $s-20; flex-direction: column; @@ -78,6 +80,7 @@ .custom-input, .custom-select { flex-direction: column-reverse; + label { position: relative; text-transform: uppercase; @@ -86,6 +89,7 @@ margin-bottom: $s-12; margin-left: calc(-1 * $s-4); } + input, select { background-color: var(--color-background-tertiary); @@ -93,20 +97,25 @@ border-color: transparent; color: var(--color-foreground-primary); padding: 0 $s-16; + &:focus { outline: $s-1 solid var(--color-accent-primary); } + ::placeholder { color: var(--color-foreground-secondary); } } + .help-icon { bottom: $s-12; top: auto; + svg { fill: var(--color-foreground-secondary); } } + &.disabled { input { background-color: var(--input-background-color-disabled); @@ -114,30 +123,36 @@ color: var(--color-foreground-secondary); } } + .input-container { background-color: var(--color-background-tertiary); border-radius: $br-8; border-color: transparent; margin-top: $s-24; + .main-content { label { position: absolute; top: calc(-1 * $s-24); } + span { color: var(--color-foreground-primary); } } + &:focus { border: $s-1 solid var(--color-accent-primary); } } + textarea { border-radius: $br-8; padding: $s-12 $s-16; background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); border: none; + &:focus { outline: $s-1 solid var(--color-accent-primary); } @@ -265,6 +280,31 @@ form.avatar-form { z-index: $z-index-modal; } + .delete-overlay { + position: absolute; + top: $s-4; + inset-inline-end: $s-4; + display: flex; + align-items: center; + justify-content: center; + width: $s-32; + height: $s-32; + padding: 0; + border: none; + border-radius: 50%; + background: var(--color-background-primary); + color: var(--color-foreground-primary); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s ease-in-out; + z-index: calc(#{$z-index-modal} + 1); + + &:hover { + background: var(--color-background-quaternary); + color: var(--color-accent-primary); + } + } + input[type="file"] { width: 100%; height: 100%; @@ -279,6 +319,10 @@ form.avatar-form { .update-overlay { opacity: 0.8; } + + .delete-overlay { + opacity: 1; + } } } @@ -321,11 +365,13 @@ form.avatar-form { } .btn-secondary { - @extend .button-secondary; + @extend %button-secondary; + height: $s-32; } .btn-primary { - @extend .button-primary; + @extend %button-primary; + height: $s-32; } diff --git a/frontend/src/app/main/ui/settings/sidebar.scss b/frontend/src/app/main/ui/settings/sidebar.scss index a072c59b8f..e9571a7ab4 100644 --- a/frontend/src/app/main/ui/settings/sidebar.scss +++ b/frontend/src/app/main/ui/settings/sidebar.scss @@ -42,6 +42,7 @@ .settings-item { --settings-foreground-color: var(--menu-foreground-color-rest); --settings-background-color: transparent; + display: flex; align-items: center; padding: deprecated.$s-8 deprecated.$s-8 deprecated.$s-8 deprecated.$s-24; @@ -61,18 +62,20 @@ } .feedback-icon { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--settings-foreground-color); margin-right: deprecated.$s-8; } .element-title { - @include deprecated.textEllipsis; - @include deprecated.bodyMediumTypography; + @include deprecated.text-ellipsis; + @include deprecated.body-medium-typography; } .back-to-dashboard { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: flex; align-items: center; padding: deprecated.$s-12 deprecated.$s-16; @@ -84,7 +87,8 @@ } .arrow-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); transform: rotate(180deg); margin-right: deprecated.$s-12; diff --git a/frontend/src/app/main/ui/settings/subscription.cljs b/frontend/src/app/main/ui/settings/subscription.cljs index f9441aca1c..9e85635f3b 100644 --- a/frontend/src/app/main/ui/settings/subscription.cljs +++ b/frontend/src/app/main/ui/settings/subscription.cljs @@ -18,6 +18,7 @@ [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.ds.foundations.assets.raw-svg :refer [raw-svg*]] + [app.main.ui.nitrate.nitrate-activation-success-modal] [app.main.ui.notifications.badge :refer [badge-notification]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr c]] @@ -28,6 +29,7 @@ [{:keys [card-title card-title-icon price-value price-period + cancel-at benefits-title benefits cta-text cta-link @@ -35,6 +37,7 @@ cta-link-trial cta-text-with-icon cta-link-with-icon + show-activation-by-code editors recommended show-button-cta]}] @@ -56,11 +59,22 @@ (when (and price-value price-period) [:div {:class (stl/css :plan-price)} [:span {:class (stl/css :plan-price-value)} price-value] - [:span {:class (stl/css :plan-price-period)} " / " price-period]])] + [:span {:class (stl/css :plan-price-period)} " / " price-period]]) + (when cancel-at + [:div {:class (stl/css :plan-cancel)} + [:span {:class (stl/css :plan-cancel-date)} cancel-at]])] (when benefits-title [:h5 {:class (stl/css :benefits-title)} benefits-title]) [:ul {:class (stl/css :benefits-list)} (for [benefit benefits] [:li {:key (dm/str benefit) :class (stl/css :benefit)} "- " benefit])] + (when (and cta-link cta-text show-button-cta) + [:> button* {:variant "primary" + :type "button" + :class (stl/css-case :bottom-button (not (and cta-link-trial cta-text-trial))) + :on-click cta-link} cta-text]) + (when (and cta-link-trial cta-text-trial) + [:button {:class (stl/css :cta-button :bottom-link) + :on-click cta-link-trial} cta-text-trial]) (when (and cta-link-with-icon cta-text-with-icon) [:button {:class (stl/css :cta-button :more-info) :on-click cta-link-with-icon} cta-text-with-icon @@ -70,14 +84,10 @@ [:button {:class (stl/css-case :cta-button true :bottom-link (not (and cta-link-trial cta-text-trial))) :on-click cta-link} cta-text]) - (when (and cta-link cta-text show-button-cta) - [:> button* {:variant "primary" - :type "button" - :class (stl/css-case :bottom-button (not (and cta-link-trial cta-text-trial))) - :on-click cta-link} cta-text]) - (when (and cta-link-trial cta-text-trial) - [:button {:class (stl/css :cta-button :bottom-link) - :on-click cta-link-trial} cta-text-trial])]) + (when show-activation-by-code + [:button {:class (stl/css :cta-button :activate-by-code) + :on-click #(st/emit! (modal/show {:type :nitrate-code-activation}))} + (tr "subscription.settings.activate-by-code")])]) (defn- make-management-form-schema [min-editors] [:map {:title "SeatsForm"} @@ -339,14 +349,14 @@ [:div {:class (stl/css :modal-end)} [:div {:class (stl/css :modal-title)} - (tr "subscription.settings.sucess.dialog.title" subscription-name)] + (tr "subscription.settings.success.dialog.title" subscription-name)] (when (not= subscription-name "professional") [:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.thanks" subscription-name)]) [:p {:class (stl/css :modal-text-large)} (tr "subscription.settings.success.dialog.description")] [:p {:class (stl/css :modal-text-large)} - (tr "subscription.settings.sucess.dialog.footer")] + (tr "subscription.settings.success.dialog.footer")] [:div {:class (stl/css :success-action-buttons)} [:input @@ -355,37 +365,6 @@ :value (tr "labels.close") :on-click handle-close-dialog}]]]]]])) -(mf/defc nitrate-success-dialog - {::mf/register modal/components - ::mf/register-as :nitrate-success} - [] - ;; TODO add translations for this texts when we have the definitive ones - (let [profile (mf/deref refs/profile)] - - [:div {:class (stl/css :modal-overlay)} - [:div {:class (stl/css :modal-dialog :subscription-success)} - [:button {:class (stl/css :close-btn) :on-click modal/hide!} - [:> icon* {:icon-id "close" - :size "m"}]] - [:div {:class (stl/css :modal-success-content)} - [:div {:class (stl/css :modal-start)} - [:> raw-svg* {:id (if (= "light" (:theme profile)) "logo-subscription-light" "logo-subscription")}]] - - [:div {:class (stl/css :modal-end)} - [:div {:class (stl/css :modal-title)} - "You are Business Nitrate!"] - [:p {:class (stl/css :modal-text-large)} - (tr "subscription.settings.success.dialog.description")] - [:p {:class (stl/css :modal-text-large)} - (tr "subscription.settings.sucess.dialog.footer")] - - [:div {:class (stl/css :success-action-buttons)} - [:input - {:class (stl/css :primary-button) - :type "button" - :value "CREATE ORGANIZATION" - :on-click dnt/go-to-nitrate-cc}]]]]]])) - (mf/defc subscription-page* [{:keys [profile]}] (let [route (mf/deref refs/route) @@ -415,7 +394,7 @@ (-> profile :props :subscription) subscription-type - (get-subscription-type subscription) + (if (and (contains? cf/flags :nitrate) nitrate?) (:type nitrate-license) (get-subscription-type subscription)) subscription-is-trial? (= (:status subscription) "trialing") @@ -449,17 +428,25 @@ open-subscription-modal (mf/use-fn - (mf/deps subscription-editors) + (mf/deps subscription-editors nitrate-license) (fn [subscription-type current-subscription] (st/emit! (ev/event {::ev/name "open-subscription-modal" ::ev/origin "settings:in-app"})) (if (= subscription-type "nitrate") - (st/emit! (dnt/show-nitrate-popup :nitrate-dialog)) + (st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license})) (st/emit! (modal/show :management-dialog {:subscription-type subscription-type :current-subscription current-subscription - :editors subscription-editors :subscribe-to-trial (not (:type subscription))})))))] + :editors subscription-editors :subscribe-to-trial (not (:type subscription))}))))) + + open-contact-sales-modal + (mf/use-fn + (mf/deps nitrate-license) + (fn [current-subscription subscription-type] + (if (= current-subscription "unlimited") + (st/emit! (dnt/show-nitrate-popup :nitrate-dialog {:nitrate-license nitrate-license :show-contact-sales-option true})) + (st/emit! (modal/show :nitrate-contact-sales-dialog {:subscription-type subscription-type})))))] (mf/with-effect [] (dom/set-html-title (tr "subscription.labels"))) @@ -488,7 +475,7 @@ ^boolean show-subscription-success-modal? (st/emit! (if (= params-subscription "subscribed-to-penpot-nitrate") - (modal/show :nitrate-success {}) + (modal/show :nitrate-activation-success {}) (modal/show :subscription-success {:subscription-name (if (= params-subscription "subscribed-to-penpot-unlimited") (if (= success-modal-is-trial? "true") @@ -510,6 +497,8 @@ ;; TODO add translations for this texts when we have the definitive ones [:> plan-card* {:card-title "Business Nitrate" :card-title-icon i/character-b + :cancel-at (when (:cancel-at nitrate-license) + (tr "nitrate.subscription.active-until" (ct/format-inst (:cancel-at nitrate-license) "d MMMM, yyyy"))) :benefits-title "Loren ipsum", :benefits ["Loren ipsum", "Loren ipsum", @@ -611,7 +600,7 @@ (tr "subscription.settings.unlimited.autosave-benefit"), (tr "subscription.settings.unlimited.bill")] :cta-text (if (:type subscription) (tr "subscription.settings.subscribe") (tr "subscription.settings.try-it-free")) - :cta-link #(open-subscription-modal "unlimited" subscription) + :cta-link (if (and (contains? cf/flags :nitrate) nitrate?) #(open-contact-sales-modal subscription-type "Unlimited") #(open-subscription-modal "unlimited" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") :cta-link-with-icon go-to-pricing-page :recommended (= subscription-type "professional") @@ -637,15 +626,17 @@ [:> plan-card* {:card-title "Business Nitrate" :card-title-icon i/character-n :price-value "$25" - :price-period "org member" + :price-period (tr "subscription.settings.organization-member-month") :benefits-title (tr "subscription.settings.benefits.all-unlimited-benefits") :benefits ["Crea organizaciones y añade personas, que usarán Penpot con las reglas que configures." "Acceso exclusivo al Control Center" "Lorem ipsum"] - :cta-text (tr "subscription.settings.subscribe") - :cta-link #(open-subscription-modal "nitrate" subscription) + :cta-text (if nitrate-license (tr "subscription.settings.subscribe") "Try 14 days for free") + :cta-link (if (= subscription-type "unlimited") #(open-contact-sales-modal subscription-type "Nitrate") #(open-subscription-modal "nitrate" subscription)) :cta-text-with-icon (tr "subscription.settings.more-information") - :cta-link-with-icon go-to-pricing-page}])]]])) + :cta-link-with-icon go-to-pricing-page + :show-activation-by-code true + :show-button-cta (not nitrate-license)}])]]])) (def ^:private schema:nitrate-form @@ -655,7 +646,7 @@ (mf/defc subscribe-nitrate-dialog {::mf/register modal/components ::mf/register-as :nitrate-dialog} - [connectivity] + [{:keys [nitrate-license show-contact-sales-option] :as connectivity}] ;; TODO add translations for this texts when we have the definitive ones (let [online? (:licenses connectivity) initial (mf/with-memo [] @@ -688,7 +679,7 @@ [:div {:class (stl/css :modal-title :subscription-title)} "Subcribe to the Business Nitrate plan"] - (if online? + (if (and online? (not show-contact-sales-option)) [:div {:class (stl/css :modal-content)} @@ -723,16 +714,50 @@ :on-click handle-close-dialog}] [:> fm/submit-button* - {:label "TRY 14 DAYS FOR FREE" + {:label (if nitrate-license (tr "subscription.settings.subscribe") "TRY 14 DAYS FOR FREE") :class (stl/css :primary-button)}]]]]]] [:div {:class (stl/css :modal-content :modal-contact-content)} [:div {:class (stl/css :modal-text)} "Lorem ipsum lorem ipsum Lorem ipsum lorem ipsum Lorem ipsum lorem ipsum"] [:div {:class (stl/css :modal-text)} - "Contact us to upgrade to Nitrate:"] + (if nitrate-license "Contact us to upgrade to Nitrate:" "Contact us to try Nitrate for 14 days:")] [:div {:class (stl/css :modal-text)} - [:a {:class (stl/css :link) :href "mailto:sales@penpot.app"} + [:a {:class (stl/css :cta-button) :href "mailto:sales@penpot.app"} "sales@penpot.app"]]])]])) +(mf/defc nitrate-contact-sales-dialog + {::mf/register modal/components + ::mf/register-as :nitrate-contact-sales-dialog} + [{:keys [subscription-type]}] + (let [handle-close-dialog + (mf/use-fn + (fn [] + (modal/hide!)))] + [:div {:class (stl/css :modal-overlay)} + [:div {:class (stl/css :modal-dialog)} + [:button {:class (stl/css :close-btn) :on-click handle-close-dialog} + [:> icon* {:icon-id "close" + :size "m"}]] + [:div {:class (stl/css :modal-title :subscription-title)} + (str "Switch to " subscription-type " plan?")] + + [:div {:class (stl/css :modal-content)} + [:div {:class (stl/css :modal-text-medium)} + "When you downgrade:"] + [:ul {:class (stl/css :downgrade-list)} + [:li {:class (stl/css :downgrade-item)} "Your organization will be deleted."] + [:li {:class (stl/css :downgrade-item)} "The teams, projects and files will no longer be part of any organization but they will remain available."] + [:li {:class (stl/css :downgrade-item)} "Your total storage, auto-version history, and file recovery period will be limited."]] + + [:div {:class (stl/css :downgrade-warning)} + "To switch to this plan, please contact our sales team. +We’ll help you update your subscription and ensure everything is set up correctly."] + [:div {:class (stl/css :action-buttons)} + [:> button* {:variant "secondary" + :type "button" + :on-click handle-close-dialog} (tr "ds.confirm-cancel")] + [:> button* {:variant "primary" + :type "button" + :on-click #(dom/open-new-window "mailto:sales@penpot.app?subject=Switch%20to%20the%20Unlimited%20plan")} "Contact sales"]]]]])) diff --git a/frontend/src/app/main/ui/settings/subscription.scss b/frontend/src/app/main/ui/settings/subscription.scss index f98c1caef3..213624d901 100644 --- a/frontend/src/app/main/ui/settings/subscription.scss +++ b/frontend/src/app/main/ui/settings/subscription.scss @@ -20,12 +20,11 @@ .dashboard-content { display: flex; - justify-content: center; flex-direction: column; max-inline-size: $sz-500; margin-block-end: var(--sp-xxxl); inline-size: px2rem(580); - margin: px2rem(92) auto px2rem(120) auto; + margin: px2rem(92) auto px2rem(120); justify-content: center; } @@ -45,13 +44,14 @@ .membership-date { @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); margin-inline-start: var(--sp-s); } .subscription-member, .penpot-member { - @extend .button-icon; + @extend %button-icon; } .penpot-member { @@ -64,12 +64,14 @@ .title-section { @include t.use-typography("title-large"); + color: var(--color-foreground-primary); margin-block-end: var(--sp-xxl); } .plan-section-title { @include t.use-typography("headline-small"); + color: var(--color-foreground-primary); } @@ -98,14 +100,15 @@ } .plan-title-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--color-foreground-primary); block-size: var(--sp-xl); inline-size: var(--sp-xl); border-radius: 6px; border: 1.75px solid var(--color-foreground-primary); stroke-width: 2.25px; - padding: deprecated.$s-1; + padding: px2rem(3); svg { block-size: var(--sp-m); @@ -116,11 +119,13 @@ .plan-card-title, .plan-price-value { @include t.use-typography("title-medium"); + color: var(--color-foreground-primary); } .plan-editors { @include t.use-typography("body-medium"); + align-self: end; color: var(--color-foreground-primary); margin-block-end: 2px; @@ -128,6 +133,21 @@ .plan-price-period { @include t.use-typography("body-small"); + + color: var(--color-foreground-primary); +} + +.plan-cancel { + align-items: center; + background-color: var(--color-background-secondary); + border-radius: var(--sp-xs); + display: flex; + padding-inline: var(--sp-s); +} + +.plan-cancel-date { + @include t.use-typography("body-medium"); + color: var(--color-foreground-primary); } @@ -138,6 +158,7 @@ .benefits-title, .benefit { @include t.use-typography("body-medium"); + color: var(--color-foreground-secondary); } @@ -147,7 +168,8 @@ .cta-button { @include t.use-typography("body-medium"); - @include deprecated.buttonStyle; + @include deprecated.button-style; + align-items: center; color: var(--color-accent-primary); display: flex; @@ -156,7 +178,8 @@ } .cta-button svg { - @extend .button-icon; + @extend %button-icon; + block-size: var(--sp-l); inline-size: var(--sp-l); stroke: var(--color-accent-primary); @@ -176,11 +199,12 @@ } .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; + max-block-size: initial; min-inline-size: px2rem(548); } @@ -190,11 +214,12 @@ } .close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-title { @include t.use-typography("title-large"); + margin-block-end: var(--sp-xxxl); color: var(--modal-title-foreground-color); display: flex; @@ -240,7 +265,7 @@ } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .success-action-buttons { @@ -248,13 +273,15 @@ } .primary-button { - @extend .modal-accept-btn; + @extend %modal-accept-btn; + min-block-size: $sz-32; block-size: auto; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; + min-block-size: $sz-32; white-space: break-spaces; block-size: auto; @@ -270,13 +297,14 @@ block-size: auto; } - @media (max-inline-size: 992px) { + @media (width <= 992px) { display: none; } } .editors-text { @include t.use-typography("body-medium"); + margin: 0; } @@ -287,6 +315,7 @@ .editors-list { @include t.use-typography("body-medium"); + list-style-position: inside; list-style-type: none; margin-inline-start: var(--sp-xl); @@ -296,11 +325,13 @@ .input-field { --input-icon-padding: var(--sp-s); + inline-size: px2rem(80); } .error-message { @include t.use-typography("body-small"); + color: var(--color-foreground-error); margin-block-start: var(--sp-s); } @@ -319,6 +350,7 @@ .unlimited-capped-warning { @include t.use-typography("body-small"); + background-color: var(--color-background-tertiary); border-radius: var(--sp-s); margin-block-start: $sz-40; @@ -333,6 +365,7 @@ .radio-btns { label { @include t.use-typography("body-large"); + padding: 0; display: flex; align-items: center; @@ -348,3 +381,26 @@ .modal-contact-content { gap: var(--sp-xl); } + +.downgrade-warning { + @include t.use-typography("body-medium"); + + background-color: var(--color-background-tertiary); + border-radius: var(--sp-s); + padding-block: var(--sp-s); + padding-inline: var(--sp-m); + margin-block: var(--sp-m) var(--sp-xxxl); +} + +.downgrade-list { + list-style-position: outside; + list-style-type: disc; + margin-block: var(--sp-l) 0; + padding-inline-start: var(--sp-l); +} + +.downgrade-item { + @include t.use-typography("body-medium"); + + margin-block-end: var(--sp-l); +} diff --git a/frontend/src/app/main/ui/shapes/custom_stroke.cljs b/frontend/src/app/main/ui/shapes/custom_stroke.cljs index 02c3b5d07e..01d5c64c5b 100644 --- a/frontend/src/app/main/ui/shapes/custom_stroke.cljs +++ b/frontend/src/app/main/ui/shapes/custom_stroke.cljs @@ -509,7 +509,8 @@ (when (some? shape-strokes) [:> :g props - (for [[index value] (reverse (d/enumerate shape-strokes))] + (for [[index value] (reverse (d/enumerate shape-strokes)) + :when (not (:hidden value))] [:& shape-custom-stroke {:shape shape :stroke value :index index diff --git a/frontend/src/app/main/ui/static.cljs b/frontend/src/app/main/ui/static.cljs index a13d6f76d8..f426ae0874 100644 --- a/frontend/src/app/main/ui/static.cljs +++ b/frontend/src/app/main/ui/static.cljs @@ -67,10 +67,26 @@ [:span (tr "not-found.made-with-love")]]])) (mf/defc invalid-token - [] + [{:keys [reason]}] + ;; Map the specific failure reason to actionable copy. Falls back to + ;; the generic invitation-invalid message when the reason is missing + ;; or unknown so the UX never regresses for unhandled cases. + ;; + ;; The branches use `tr` with literal keys (instead of `(tr key-var)`) + ;; so the i18n usage scanner can statically track every key. [:> error-container* {} - [:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")] - [:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]]) + (case reason + :email-mismatch + [:* + [:div {:class (stl/css :main-message)} (tr "errors.invite-email-mismatch")]] + + :token-expired + [:* + [:div {:class (stl/css :main-message)} (tr "errors.invite-expired")]] + + [:* + [:div {:class (stl/css :main-message)} (tr "errors.invite-invalid")] + [:div {:class (stl/css :desc-message)} (tr "errors.invite-invalid.info")]])]) (mf/defc login-modal* {::mf/private true} diff --git a/frontend/src/app/main/ui/static.scss b/frontend/src/app/main/ui/static.scss index cf8cb1ec9a..32c80dce5e 100644 --- a/frontend/src/app/main/ui/static.scss +++ b/frontend/src/app/main/ui/static.scss @@ -106,7 +106,8 @@ } .login-header { - @extend .button-primary; + @extend %button-primary; + padding: deprecated.$s-8 deprecated.$s-16; font-size: deprecated.$fs-11; position: fixed; @@ -135,22 +136,26 @@ .main-message { @include t.use-typography("title-large"); + color: var(--color-foreground-primary); } .desc-message { @include t.use-typography("title-large"); + color: var(--color-foreground-secondary); } .desc-text { @include t.use-typography("title-large"); + color: var(--color-foreground-secondary); margin-block-end: 0; } .download-link { @include t.use-typography("code-font"); + color: var(--color-foreground-primary); text-transform: lowercase; } @@ -159,7 +164,8 @@ text-align: center; button { - @extend .button-primary; + @extend %button-primary; + text-transform: uppercase; padding: deprecated.$s-8 deprecated.$s-16; font-size: deprecated.$fs-11; @@ -196,12 +202,14 @@ } .project-name { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--title-foreground-color); } .file-name { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + text-transform: none; color: var(--title-foreground-color-hover); } @@ -230,7 +238,7 @@ top: 0; left: 0; z-index: 100; - background-color: rgba(0, 0, 0, 0.65); + background-color: rgb(0 0 0 / 0.65); display: flex; justify-content: center; align-items: center; @@ -274,14 +282,16 @@ margin-top: deprecated.$s-32; button { - @extend .button-primary; + @extend %button-primary; + text-transform: uppercase; padding: deprecated.$s-8 deprecated.$s-16; font-size: deprecated.$fs-11; } .cancel-button { - @extend .button-secondary; + @extend %button-secondary; + text-transform: uppercase; padding: deprecated.$s-8 deprecated.$s-16; font-size: deprecated.$fs-11; @@ -338,8 +348,10 @@ margin: deprecated.$s-20 0; } - form div { - margin-bottom: deprecated.$s-8; + form { + div { + margin-bottom: deprecated.$s-8; + } } } } diff --git a/frontend/src/app/main/ui/viewer.scss b/frontend/src/app/main/ui/viewer.scss index 6b46b2d5f4..6fbf27ce92 100644 --- a/frontend/src/app/main/ui/viewer.scss +++ b/frontend/src/app/main/ui/viewer.scss @@ -24,7 +24,8 @@ } .empty-state { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--empty-message-foreground-color); display: grid; place-items: center; @@ -46,7 +47,8 @@ } .thumbnails-close { - @include deprecated.buttonStyle; + @include deprecated.button-style; + grid-row: 1 / span 2; grid-column: 1 / span 1; z-index: deprecated.$z-index-10; @@ -58,14 +60,14 @@ } .viewer-section { - @extend .new-scrollbar; + @extend %new-scrollbar; + grid-row: 1 / span 2; grid-column: 1 / span 1; display: flex; align-items: center; - flex-wrap: nowrap; + flex-wrap: wrap; height: calc(100vh - deprecated.$s-48); - flex-flow: wrap; overflow: auto; } @@ -78,8 +80,9 @@ .viewer-go-prev, .viewer-go-next { - @extend .button-secondary; - @include deprecated.flexCenter; + @extend %button-secondary; + @include deprecated.flex-center; + position: absolute; right: deprecated.$s-8; height: deprecated.$s-64; @@ -88,8 +91,10 @@ z-index: deprecated.$z-index-2; background-color: var(--viewer-controls-background-color); transition: transform 400ms ease 300ms; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } @@ -101,6 +106,7 @@ .viewer-go-prev { left: deprecated.$s-8; right: unset; + svg { transform: rotate(180deg); } @@ -121,22 +127,26 @@ } .reset-button { - @extend .button-secondary; - @include deprecated.flexCenter; + @extend %button-secondary; + @include deprecated.flex-center; + height: deprecated.$s-32; width: deprecated.$s-28; margin-left: deprecated.$s-8; background-color: var(--viewer-controls-background-color); pointer-events: all; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } .counter { - @include deprecated.flexCenter; - @include deprecated.bodySmallTypography; + @include deprecated.flex-center; + @include deprecated.body-small-typography; + border-radius: deprecated.$br-8; width: deprecated.$s-64; height: deprecated.$s-32; @@ -153,8 +163,7 @@ display: grid; grid-template-rows: 1fr; grid-template-columns: 1fr; - justify-items: center; - align-items: center; + place-items: center center; overflow: hidden; } @@ -164,7 +173,7 @@ left: 0; &.visible { - background-color: rgb(0, 0, 0, 0.2); + background-color: rgb(0 0 0 / 0.2); } } diff --git a/frontend/src/app/main/ui/viewer/comments.scss b/frontend/src/app/main/ui/viewer/comments.scss index fa9ef1daf8..a6b2882ad2 100644 --- a/frontend/src/app/main/ui/viewer/comments.scss +++ b/frontend/src/app/main/ui/viewer/comments.scss @@ -8,7 +8,8 @@ // COMMENT DROPDOWN ON HEADER .view-options { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; position: relative; @@ -21,7 +22,8 @@ } .dropdown { - @extend .menu-dropdown; + @extend %menu-dropdown; + right: deprecated.$s-2; top: calc(deprecated.$s-2 + deprecated.$s-48); width: deprecated.$s-272; @@ -29,7 +31,8 @@ } .dropdown-title { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + flex-grow: 1; color: var(--input-foreground-color-active); } @@ -41,11 +44,14 @@ .icon, .icon-dropdown { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; width: deprecated.$s-16; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } @@ -55,16 +61,21 @@ } .dropdown-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; width: deprecated.$s-16; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } + &:hover .label { color: var(--input-foreground-color-active); } @@ -74,6 +85,7 @@ .label { color: var(--input-foreground-color-active); } + .icon svg { stroke: var(--input-foreground-color); } @@ -86,8 +98,8 @@ // FLOATING COMMENT .viewer-comments-container { position: absolute; - top: 0px; - left: 0px; + top: 0; + left: 0; width: 100%; height: 100%; z-index: deprecated.$z-index-1; @@ -95,11 +107,11 @@ .threads { position: absolute; - top: 0px; - left: 0px; + top: 0; + left: 0; } -//COMMENT SIDEBAR +// COMMENT SIDEBAR .comments-sidebar { position: absolute; right: 0; diff --git a/frontend/src/app/main/ui/viewer/header.scss b/frontend/src/app/main/ui/viewer/header.scss index f9814d3a44..c80da08171 100644 --- a/frontend/src/app/main/ui/viewer/header.scss +++ b/frontend/src/app/main/ui/viewer/header.scss @@ -45,39 +45,46 @@ } .sitemap-zone { - @include deprecated.flexColumn; + @include deprecated.flex-column; + position: relative; width: 100%; } .project-name { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--title-foreground-color); } .sitemap-text { - @include deprecated.flexRow; + @include deprecated.flex-row; } .breadcrumb { - @include deprecated.bodySmallTypography; - @include deprecated.flexRow; + @include deprecated.body-small-typography; + @include deprecated.flex-row; + color: var(--title-foreground-color); cursor: pointer; } .breadcrumb-text { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + max-width: 12vw; // This is a fallback max-width: 12cqw; // This is a unit refered to container } .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-16; width: deprecated.$s-16; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + transform: rotate(90deg); stroke: var(--icon-foreground); } @@ -88,7 +95,8 @@ } .dropdown-sitemap { - @extend .menu-dropdown; + @extend %menu-dropdown; + left: 0; top: calc(deprecated.$s-2 + deprecated.$s-48); width: deprecated.$s-272; @@ -96,62 +104,74 @@ } .dropdown-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + .icon-check { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; width: deprecated.$s-16; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } + &:hover .label { color: var(--input-foreground-color-active); } } .current-frame { - @include deprecated.bodySmallTypography; - @include deprecated.flexRow; + @include deprecated.body-small-typography; + @include deprecated.flex-row; + flex-grow: 1; color: var(--title-foreground-color-hover); cursor: pointer; + .icon svg { stroke: var(--title-foreground-color-hover); } } .frame-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + max-width: 17vw; // This is a fallback max-width: 17cqw; // This is a unit refered to container } // SECTION BUTTONS .mode-zone { - @include deprecated.flexRow; + @include deprecated.flex-row; + height: 100%; } .mode-zone-btn { - @extend .button-tertiary; - @include deprecated.flexCenter; + @extend %button-tertiary; + @include deprecated.flex-center; + height: deprecated.$s-32; width: deprecated.$s-28; padding: 0; + svg { - @extend .button-icon; + @extend %button-icon; } } .selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } // OPTION AREA .options-zone { - @include deprecated.flexRow; + @include deprecated.flex-row; + position: relative; justify-content: flex-end; gap: deprecated.$s-8; @@ -166,37 +186,45 @@ } .fullscreen-btn { - @extend .button-tertiary; - @include deprecated.flexCenter; + @extend %button-tertiary; + @include deprecated.flex-center; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } .share-btn { - @extend .button-primary; + @extend %button-primary; + height: deprecated.$s-32; min-width: deprecated.$s-72; margin-left: deprecated.$s-4; } .edit-btn { - @extend .button-tertiary; - @include deprecated.flexCenter; + @extend %button-tertiary; + @include deprecated.flex-center; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } .go-log-btn { - @extend .button-tertiary; - @include deprecated.bodySmallTypography; + @extend %button-tertiary; + @include deprecated.body-small-typography; + height: deprecated.$s-32; padding: 0 deprecated.$s-8; border-radius: deprecated.$br-8; @@ -205,13 +233,16 @@ // ZOOM WIDGET .zoom-widget { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + height: deprecated.$s-28; min-width: deprecated.$s-64; border-radius: deprecated.$br-8; + .label { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--button-tertiary-foreground-color-rest); } @@ -220,6 +251,7 @@ color: var(--button-tertiary-foreground-color-focus); } } + &.selected { .label { color: var(--button-tertiary-foreground-color-focus); @@ -228,7 +260,8 @@ } .dropdown { - @extend .menu-dropdown; + @extend %menu-dropdown; + right: deprecated.$s-2; top: calc(deprecated.$s-2 + deprecated.$s-48); width: deprecated.$s-272; @@ -246,19 +279,25 @@ } .zoom-btn { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-28; width: deprecated.$s-28; border-radius: deprecated.$br-8; + .zoom-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-24; height: deprecated.$s-32; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } + &:hover { .zoom-icon svg { stroke: var(--button-tertiary-foreground-color-hover); @@ -267,7 +306,8 @@ } .zoom-text { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; min-width: deprecated.$s-64; padding: 0; @@ -276,22 +316,27 @@ } .reset-btn { - @extend .button-tertiary; + @extend %button-tertiary; + color: var(--button-tertiary-foreground-color-hover); height: deprecated.$s-28; border-radius: deprecated.$br-8; } .zoom-option { - @extend .menu-item-base; + @extend %menu-item-base; + .shortcuts { - @extend .shortcut-base; + @extend %shortcut-base; + .shortcut-key { - @extend .shortcut-key-base; + @extend %shortcut-key-base; } } + &:hover { color: var(--menu-foreground-color-hover); + .shortcuts { .shortcut-key { color: var(--menu-foreground-color-hover); diff --git a/frontend/src/app/main/ui/viewer/inspect.scss b/frontend/src/app/main/ui/viewer/inspect.scss index 0ed6152256..171340752b 100644 --- a/frontend/src/app/main/ui/viewer/inspect.scss +++ b/frontend/src/app/main/ui/viewer/inspect.scss @@ -7,7 +7,8 @@ @use "refactor/common-refactor.scss" as deprecated; .inspect-svg-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + position: relative; flex-direction: column; flex: 1; @@ -30,7 +31,6 @@ position: relative; align-self: flex-start; width: var(--right-sidebar-width); - background-color: var(--panel-background-color); border-top: deprecated.$s-1 solid var(--search-bar-input-border-color); } diff --git a/frontend/src/app/main/ui/viewer/interactions.scss b/frontend/src/app/main/ui/viewer/interactions.scss index 8e7d03cab1..d52fb6d933 100644 --- a/frontend/src/app/main/ui/viewer/interactions.scss +++ b/frontend/src/app/main/ui/viewer/interactions.scss @@ -7,7 +7,8 @@ @use "refactor/common-refactor.scss" as deprecated; .view-options { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; position: relative; @@ -18,8 +19,10 @@ padding: deprecated.$s-8; cursor: pointer; } + .dropdown-title { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + flex-grow: 1; color: var(--input-foreground-color-active); } @@ -30,7 +33,8 @@ } .dropdown { - @extend .menu-dropdown; + @extend %menu-dropdown; + right: deprecated.$s-2; top: calc(deprecated.$s-2 + deprecated.$s-48); width: deprecated.$s-272; @@ -40,17 +44,23 @@ } .dropdown-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + min-height: deprecated.$s-32; + .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; width: deprecated.$s-16; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } + &:hover .label { color: var(--input-foreground-color-active); } @@ -60,6 +70,7 @@ .label { color: var(--input-foreground-color-active); } + .icon svg { stroke: var(--input-foreground-color); } @@ -67,11 +78,14 @@ .icon, .icon-dropdown { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; width: deprecated.$s-16; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } diff --git a/frontend/src/app/main/ui/viewer/login.scss b/frontend/src/app/main/ui/viewer/login.scss index f107742588..965a7e9ccf 100644 --- a/frontend/src/app/main/ui/viewer/login.scss +++ b/frontend/src/app/main/ui/viewer/login.scss @@ -7,11 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + width: deprecated.$s-368; } @@ -20,17 +21,19 @@ } .modal-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.flexColumn; - @include deprecated.bodySmallTypography; + @include deprecated.flex-column; + @include deprecated.body-small-typography; + gap: deprecated.$s-24; max-height: deprecated.$s-400; overflow: hidden auto; @@ -66,7 +69,8 @@ } a { - @extend .button-secondary; + @extend %button-secondary; + height: deprecated.$s-40; text-transform: uppercase; font-size: deprecated.$fs-11; diff --git a/frontend/src/app/main/ui/viewer/share_link.scss b/frontend/src/app/main/ui/viewer/share_link.scss index 2c8bcc60b1..a0dc26278d 100644 --- a/frontend/src/app/main/ui/viewer/share_link.scss +++ b/frontend/src/app/main/ui/viewer/share_link.scss @@ -16,7 +16,8 @@ } .share-link-dialog { - @extend .modal-container-base; + @extend %modal-container-base; + min-height: unset; } @@ -25,27 +26,30 @@ } .share-link-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-button { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.bodySmallTypography; - @include deprecated.flexColumn; + @include deprecated.body-small-typography; + @include deprecated.flex-column; + gap: deprecated.$s-24; } .share-link-section { - @include deprecated.flexColumn; + @include deprecated.flex-column; + gap: deprecated.$s-8; } .hint-wrapper { - @include deprecated.flexRow; + @include deprecated.flex-row; } .hint { @@ -54,7 +58,8 @@ } .custon-input-wrapper { - @include deprecated.flexRow; + @include deprecated.flex-row; + border-radius: deprecated.$br-8; height: deprecated.$s-32; width: 100%; @@ -62,12 +67,14 @@ } .input-text { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + color: var(--input-foreground-color-active); padding-left: deprecated.$s-8; margin: 0; flex-grow: 1; + &:focus { outline: none; border: deprecated.$s-1 solid var(--input-border-color-active); @@ -75,48 +82,55 @@ } .copy-button { - @extend .button-secondary; - @include deprecated.flexRow; + @extend %button-secondary; + @include deprecated.flex-row; + gap: deprecated.$s-8; height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground-hover); } } .description { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--modal-text-foreground-color); margin-bottom: deprecated.$s-24; } .actions { - @include deprecated.flexRow; + @include deprecated.flex-row; + justify-content: flex-end; } .button-active { - @extend .modal-accept-btn; + @extend %modal-accept-btn; } .button-cancel { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } .button-danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } .permissions-section { - @include deprecated.flexColumn; + @include deprecated.flex-column; + gap: deprecated.$s-8; } .manage-permissions { - @include deprecated.buttonStyle; - @include deprecated.uppercaseTitleTipography; + @include deprecated.button-style; + @include deprecated.uppercase-title-typography; + color: var(--menu-foreground-color-rest); height: deprecated.$s-32; display: flex; @@ -125,12 +139,16 @@ } .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + margin-right: deprecated.$s-6; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } + &.rotated { transform: rotate(90deg); } @@ -162,39 +180,51 @@ flex-grow: 1; color: var(--input-foreground-color-active); } + .select-all-row { - @include deprecated.flexRow; + @include deprecated.flex-row; + justify-content: space-between; height: deprecated.$s-32; border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); } + .select-all-label { color: var(--input-foreground-color-active); } + .pages-selection { margin: 0; + li { border-bottom: deprecated.$s-1 solid var(--input-border-color-disabled); } + li:last-child { border-bottom: none; } } + .count-pages, .current-tag { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--input-foreground-color); } .checkbox-wrapper { - @extend .input-checkbox; + @extend %input-checkbox; + height: deprecated.$s-32; padding: 0; + span.checked { background-color: var(--input-checkbox-background-color-active); border: deprecated.$s-1 solid var(--input-checkbox-background-color-active); + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--input-checkbox-foreground-color-active); } } diff --git a/frontend/src/app/main/ui/viewer/thumbnails.scss b/frontend/src/app/main/ui/viewer/thumbnails.scss index c0735ddce1..10bc4e7d31 100644 --- a/frontend/src/app/main/ui/viewer/thumbnails.scss +++ b/frontend/src/app/main/ui/viewer/thumbnails.scss @@ -33,22 +33,26 @@ } .counter { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--viewer-thumbnails-control-foreground-color); } .actions { - @include deprecated.flexRow; + @include deprecated.flex-row; + width: deprecated.$s-60; } .expand-btn, .close-btn { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; } } @@ -72,8 +76,9 @@ .right-scroll-handler, .left-scroll-handler { - @extend .button-tertiary; - @include deprecated.flexCenter; + @extend %button-tertiary; + @include deprecated.flex-center; + grid-column: 3 / span 1; grid-row: 1 / span 1; width: deprecated.$s-32; @@ -81,11 +86,14 @@ margin: auto 0; z-index: deprecated.$z-index-10; opacity: 0; + &:hover { opacity: 1; } + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } @@ -93,6 +101,7 @@ .left-scroll-handler { grid-column: 1 / span 1; grid-row: 1 / span 1; + svg { transform: rotate(180deg); } @@ -112,14 +121,16 @@ } .thumbnail-item { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: flex; flex-direction: column; padding: deprecated.$s-16; } .thumbnail-preview { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-132; min-height: deprecated.$s-132; height: deprecated.$s-132; @@ -142,8 +153,9 @@ } .thumbnail-info { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; + @include deprecated.body-small-typography; + @include deprecated.text-ellipsis; + text-align: center; color: var(--viewer-thumbnails-control-foreground-color); padding: deprecated.$s-8 0; diff --git a/frontend/src/app/main/ui/workspace.cljs b/frontend/src/app/main/ui/workspace.cljs index c0e600b835..0ef0936d22 100644 --- a/frontend/src/app/main/ui/workspace.cljs +++ b/frontend/src/app/main/ui/workspace.cljs @@ -36,6 +36,7 @@ [app.main.ui.workspace.tokens.import] [app.main.ui.workspace.tokens.import.modal] [app.main.ui.workspace.tokens.management.forms.modals] + [app.main.ui.workspace.tokens.management.forms.rename-node-modal] [app.main.ui.workspace.tokens.remapping-modal] [app.main.ui.workspace.tokens.settings] [app.main.ui.workspace.tokens.themes.create-modal] diff --git a/frontend/src/app/main/ui/workspace.scss b/frontend/src/app/main/ui/workspace.scss index 5cd617bab4..558c7a4170 100644 --- a/frontend/src/app/main/ui/workspace.scss +++ b/frontend/src/app/main/ui/workspace.scss @@ -7,24 +7,20 @@ @use "refactor/common-refactor.scss" as deprecated; .workspace { - @extend .new-scrollbar; + @extend %new-scrollbar; + width: 100vw; height: 100vh; max-height: 100vh; user-select: none; display: grid; - grid-template-areas: "left-sidebar viewport right-sidebar"; - grid-template-rows: 1fr; - grid-template-columns: auto 1fr auto; + grid-template: "left-sidebar viewport right-sidebar" 1fr / auto 1fr auto; overflow: hidden; } .workspace-loader { position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; z-index: var(--z-index-loaders); background-color: var(--color-background-primary); } diff --git a/frontend/src/app/main/ui/workspace/color_palette.cljs b/frontend/src/app/main/ui/workspace/color_palette.cljs index 7a8dae308c..5e2eb534f1 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.cljs +++ b/frontend/src/app/main/ui/workspace/color_palette.cljs @@ -15,7 +15,10 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.color-bullet :as cb] + [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.utilities.swatch :refer [swatch*]] [app.main.ui.icons :as deprecated-icon] [app.util.color :as uc] @@ -23,6 +26,7 @@ [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] [app.util.object :as obj] + [app.util.strings :refer [matches-search]] [okulary.core :as l] [potok.v2.core :as ptk] [rumext.v2 :as mf])) @@ -59,21 +63,54 @@ {::mf/wrap [mf/memo]} [{:keys [colors size width selected]}] (let [state (mf/use-state #(do {:show-menu false})) + search-term* (mf/use-state "") + search-term (deref search-term*) + search-open* (mf/use-state false) + search-open? (deref search-open*) + has-colors? (seq colors) + + filtered-colors + (mf/with-memo [colors search-term] + (if (empty? search-term) + colors + (filterv #(matches-search (or (uc/get-color-name %) "") search-term) + colors))) + + on-search-change + (mf/use-fn #(reset! search-term* %)) + + on-toggle-search + (mf/use-fn + (fn [_] + (when @search-open* + (reset! search-term* "")) + (swap! search-open* not))) + + on-search-clear + (mf/use-fn + (fn [_] + (reset! search-term* "") + (reset! search-open* false))) + offset-step (cond (<= size 64) 40 (<= size 80) 72 :else 72) + ;; Reserve room for the search bar, icon button, or nothing + search-width (cond (not has-colors?) 0 + search-open? 192 + :else 32) buttons-size (cond - (<= size 64) 164 - :else 132) + (<= size 64) (+ 164 search-width) + :else (+ 132 search-width)) width (- width buttons-size) visible (int (/ width offset-step)) - show-arrows? (> (count colors) visible) + show-arrows? (> (count filtered-colors) visible) visible (if show-arrows? (int (/ (- width 48) offset-step)) visible) offset (:offset @state 0) - max-offset (- (count colors) + max-offset (- (count filtered-colors) visible) container (mf/use-ref nil) bullet-size (cond @@ -121,16 +158,35 @@ width (obj/get dom "clientWidth")] (swap! state assoc :width width))) - (mf/with-effect [width colors] + (mf/with-effect [width filtered-colors] (when (not= 0 (:offset @state)) (swap! state assoc :offset 0))) + (mf/with-effect [has-colors?] + (when-not has-colors? + (reset! search-open* false) + (reset! search-term* ""))) + [:div {:class (stl/css-case :color-palette true :no-text (< size 64)) :style #js {"--bullet-size" (dm/str bullet-size "px") "--color-cell-width" (dm/str color-cell-width "px")}} + (when has-colors? + [:div {:class (stl/css-case :palette-search search-open? + :palette-search-collapsed (not search-open?))} + (when search-open? + [:> search-bar* {:on-change on-search-change + :on-clear on-search-clear + :value search-term + :placeholder (tr "workspace.assets.search") + :auto-focus true}]) + [:> icon-button* {:variant "ghost" + :icon i/search + :on-click on-toggle-search + :aria-label (tr "workspace.assets.search")}]]) + (when show-arrows? [:button {:class (stl/css :left-arrow) :disabled (= offset 0) @@ -138,18 +194,20 @@ [:div {:class (stl/css :color-palette-content) :ref container :on-wheel on-scroll} - (if (empty? colors) + (if (empty? filtered-colors) [:div {:class (stl/css :color-palette-empty) :style {:position "absolute" :left "50%" :top "50%" :transform "translate(-50%, -50%)"}} - (tr "workspace.libraries.colors.empty-palette")] + (if (empty? search-term) + (tr "workspace.libraries.colors.empty-palette") + (tr "workspace.assets.not-found"))] [:div {:class (stl/css :color-palette-inside) :style {:position "relative" :max-width (str width "px") :right (str (* offset-step offset) "px")}} - (for [[idx item] (map-indexed vector colors)] + (for [[idx item] (map-indexed vector filtered-colors)] [:> palette-item* {:color item :key idx :size size :selected selected}])])] (when show-arrows? diff --git a/frontend/src/app/main/ui/workspace/color_palette.scss b/frontend/src/app/main/ui/workspace/color_palette.scss index 7a4cb7c09b..2d240b86a8 100644 --- a/frontend/src/app/main/ui/workspace/color_palette.scss +++ b/frontend/src/app/main/ui/workspace/color_palette.scss @@ -11,19 +11,41 @@ display: flex; } +.palette-search, +.palette-search-collapsed { + display: flex; + align-items: center; + padding-inline: deprecated.$s-4; +} + +.palette-search { + gap: deprecated.$s-4; + width: deprecated.$s-192; + min-width: deprecated.$s-192; +} + +.palette-search-collapsed { + width: deprecated.$s-32; + min-width: deprecated.$s-32; +} + .left-arrow, .right-arrow { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + position: relative; height: 100%; width: deprecated.$s-24; padding: 0; z-index: deprecated.$z-index-5; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } + &::after { content: ""; position: absolute; @@ -39,20 +61,24 @@ ); pointer-events: none; } + &:hover { svg { stroke: var(--button-foreground-hover); } } + &:disabled { svg { stroke: var(--button-foreground-color-disabled); } + &::after { background-image: none; } } } + .left-arrow { &::after { left: deprecated.$s-24; @@ -98,12 +124,14 @@ height: 100%; &.no-text { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-32; } } .color-palette-empty { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--palette-text-color); } diff --git a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss index a3703f8588..5aa8ee06a7 100644 --- a/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/color_palette_ctx_menu.scss @@ -27,37 +27,50 @@ padding: deprecated.$s-8; border-radius: deprecated.$br-8; margin-bottom: deprecated.$s-4; + &:last-child { margin-bottom: 0; } + .option-wrapper { width: 100%; + .library-name { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--context-menu-foreground-color); display: grid; grid-template-columns: 1fr deprecated.$s-24; + .lib-name-wrapper { display: flex; max-width: deprecated.$s-400; + .lib-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + max-width: deprecated.$s-380; } + .lib-num { margin-left: deprecated.$s-4; } } + .icon-wrapper { margin-left: deprecated.$s-4; - @include deprecated.flexCenter; + + @include deprecated.flex-center; + svg { - @extend .button-icon-small; - @include deprecated.flexCenter; + @extend %button-icon-small; + @include deprecated.flex-center; + stroke: var(--icon-foreground); } } } + .color-sample { display: flex; flex-direction: row; @@ -70,11 +83,14 @@ &:hover { .option-wrapper .library-name { color: var(--context-menu-foreground-color-selected); + .icon-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + svg { - @include deprecated.flexCenter; - @extend .button-icon-small; + @include deprecated.flex-center; + @extend %button-icon-small; + stroke: var(--context-menu-foreground-color-selected); } } diff --git a/frontend/src/app/main/ui/workspace/colorpicker.cljs b/frontend/src/app/main/ui/workspace/colorpicker.cljs index d6d4300848..7b4c5bfa62 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker.cljs @@ -26,7 +26,6 @@ [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.file-uploader :refer [file-uploader]] - [app.main.ui.components.numeric-input :refer [numeric-input*]] [app.main.ui.components.radio-buttons :refer [radio-buttons radio-button]] [app.main.ui.components.select :refer [select]] [app.main.ui.ds.foundations.assets.icon :as i] @@ -83,6 +82,15 @@ hsl-from (cc/hsv->hsl [h 0.0 v]) hsl-to (cc/hsv->hsl [h 1.0 v]) + ;; HSL-mode gradients. For S: fix current lightness, sweep + ;; saturation 0 → 1. For L: fix current saturation, sweep + ;; lightness 0 → 0.5 (pure hue) → 1. All computed at the + ;; current hue. + [_ cur-hsl-s cur-hsl-l] (cc/rgb->hsl rgb) + hsl-sat-from [h 0.0 cur-hsl-l] + hsl-sat-to [h 1.0 cur-hsl-l] + lightness-mid [h cur-hsl-s 0.5] + format-hsl (fn [[h s l]] (str/fmt "hsl(%s, %s, %s)" h @@ -91,7 +99,10 @@ (dom/set-css-property! node "--color" (str/join ", " rgb)) (dom/set-css-property! node "--hue-rgb" (str/join ", " hue-rgb)) (dom/set-css-property! node "--saturation-grad-from" (format-hsl hsl-from)) - (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to))))) + (dom/set-css-property! node "--saturation-grad-to" (format-hsl hsl-to)) + (dom/set-css-property! node "--hsl-saturation-grad-from" (format-hsl hsl-sat-from)) + (dom/set-css-property! node "--hsl-saturation-grad-to" (format-hsl hsl-sat-to)) + (dom/set-css-property! node "--lightness-grad-mid" (format-hsl lightness-mid))))) (mf/defc colorpicker* [{:keys [data disable-gradient disable-opacity disable-image on-change on-accept origin combined-tokens color-origin on-token-change tab applied-token]}] @@ -129,10 +140,15 @@ active-color-tab* (hooks/use-persisted-state ::color-tab "ramp") active-color-tab (deref active-color-tab*) + ;; Inline HSB/HSL toggle inside the HSBA tab — shared between + ;; the slider selector (for labels) and the numeric inputs. + hsb-mode* (hooks/use-persisted-state ::hsb-mode :hsb) + hsb-mode (deref hsb-mode*) + drag?* (mf/use-state false) drag? (deref drag?*) - type (if (= active-color-tab "hsva") :hsv :rgb) + type (if (= active-color-tab "hsva") :hsb :rgb) fill-image-ref (mf/use-ref nil) @@ -341,11 +357,6 @@ (mapv #(assoc %2 :offset (:offset %1)) stops new-stops)] (st/emit! (dc/update-colorpicker-stops stops))))) - handle-change-gradient-opacity - (mf/use-fn - (fn [value] - (st/emit! (dc/update-colorpicker-gradient-opacity (/ value 100))))) - render-wasm? (features/use-feature "render-wasm/v1") @@ -357,7 +368,7 @@ {:aria-label "Harmony" :icon i/rgba-complementary :id "harmony"} - {:aria-label "HSVA" + {:aria-label "HSBA" :icon i/hsva :id "hsva"}]) @@ -394,17 +405,6 @@ [:div {:class (stl/css :top-actions)} [:div {:class (stl/css :top-actions-right)} - (when (and (= color-style :direct-color) - (= :gradient selected-mode)) - [:div {:class (stl/css :opacity-input-wrapper)} - [:span {:class (stl/css :icon-text)} "%"] - [:> numeric-input* - {:value (-> data :opacity opacity->string) - :on-change handle-change-gradient-opacity - :default 100 - :data-testid "opacity-global-input" - :min 0 - :max 100}]]) (when (and (= color-style :direct-color) (or (not disable-gradient) (not disable-image))) @@ -522,6 +522,7 @@ [:> hsva-selector* {:color current-color :disable-opacity disable-opacity + :mode hsb-mode :on-change handle-change-color :on-start-drag on-start-drag :on-finish-drag on-finish-drag}]))]] @@ -529,6 +530,8 @@ [:> color-inputs* {:type type :disable-opacity disable-opacity + :mode hsb-mode + :on-mode-change #(reset! hsb-mode* %) :color current-color :on-change handle-change-color}] diff --git a/frontend/src/app/main/ui/workspace/colorpicker.scss b/frontend/src/app/main/ui/workspace/colorpicker.scss index 1d7e303d41..5de25d32a1 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker.scss @@ -5,16 +5,16 @@ // Copyright (c) KALEIDOS INC @use "ds/typography.scss" as t; -@use "ds/spacing.scss"; +@use "ds/spacing"; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @use "ds/_utils.scss" as *; @use "refactor/basic-rules.scss" as *; .colorpicker-tooltip { - @extend .modal-background; + @extend %modal-background; + left: calc(10 * px2rem(140)); - width: auto; padding: var(--sp-m); width: $sz-284; overflow: auto; @@ -41,8 +41,9 @@ } .opacity-input-wrapper { - @extend .input-element; + @extend %input-element; @include t.use-typography("body-small"); + width: px2rem(68); } @@ -51,10 +52,8 @@ display: flex; justify-content: center; align-items: center; - border: none; background: none; cursor: pointer; - border-radius: $br-8; background-color: transparent; border: $b-1 solid transparent; height: var(--sp-xl); @@ -62,29 +61,37 @@ border-radius: $br-4; padding: 0; margin-top: var(--sp-xs); + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--button-tertiary-foreground-color-rest); } + &:hover { svg { stroke: var(--button-tertiary-foreground-color-focus); } } + &:focus, &:focus-visible { outline: none; + svg { stroke: var(--button-secondary-foreground-color-hover); } } + &:active { outline: none; border: $b-1 solid transparent; + svg { stroke: var(--button-tertiary-foreground-color-active); } } + &.selected { svg { stroke: var(--button-tertiary-foreground-color-active); @@ -99,11 +106,13 @@ } .gradient-btn { - @extend .button-tertiary; + @extend %button-tertiary; + height: var(--sp-xl); width: var(--sp-xl); border-radius: $br-4; border: $b-2 solid transparent; + &:hover { border: $b-2 solid var(--colorpicker-details-color-selected); } @@ -111,16 +120,18 @@ .linear-gradient-btn { background: linear-gradient(180deg, var(--color-foreground-secondary), transparent); + &.selected { - background: linear-gradient(to bottom, rgba(126, 255, 245, 1) 0%, rgba(126, 255, 245, 0.2) 100%); + background: linear-gradient(to bottom, rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%); border: $b-2 solid var(--colorpicker-details-color-selected); } } .radial-gradient-btn { background: radial-gradient(transparent, var(--color-foreground-secondary)); + &.selected { - background: radial-gradient(rgba(126, 255, 245, 1) 0%, rgba(126, 255, 245, 0.2) 100%); + background: radial-gradient(rgb(126 255 245 / 1) 0%, rgb(126 255 245 / 0.2) 100%); border: $b-2 solid var(--colorpicker-details-color-selected); } } @@ -132,7 +143,8 @@ .accept-color { @include t.use-typography("headline-small"); - @extend .button-primary; + @extend %button-primary; + width: 100%; height: var(--sp-xxxl); margin-top: var(--sp-s); @@ -180,6 +192,7 @@ height: px2rem(140); margin-bottom: $sz-6; margin-right: $sz-1; + img { height: fit-content; width: fit-content; @@ -190,20 +203,23 @@ } .choose-image { - @extend .button-secondary; + @extend %button-secondary; @include t.use-typography("headline-small"); + width: 100%; margin-top: var(--sp-m); height: var(--sp-xxxl); } .checkbox-option { - @extend .input-checkbox; + @extend %input-checkbox; + margin: var(--sp-l) 0 0 0; } .token-color-title { @include t.use-typography("title-small"); + color: var(--color-foreground-secondary); display: flex; align-items: center; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs index 09ad2d0e8c..69c466669c 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.cljs @@ -28,11 +28,23 @@ [val] (* (/ val 255) 100)) -(mf/defc color-inputs* [{:keys [type color disable-opacity on-change]}] +(mf/defc color-inputs* [{:keys [type color disable-opacity mode on-mode-change on-change]}] (let [{red :r green :g blue :b hue :h saturation :s value :v hex :hex alpha :alpha} color + ;; Sub-model selector for the HSB tab: users can toggle between + ;; HSB and HSL input display without leaving the tab. State is + ;; lifted to the colorpicker parent so the slider labels stay + ;; in sync with the inputs. + hsb-mode (or mode :hsb) + + ;; Compute HSL from current RGB (derived; not stored on the color map) + [_hsl-h hsl-s hsl-l] + (if (and red green blue) + (cc/rgb->hsl [red green blue]) + [0 0 0]) + refs {:hex (mf/use-ref nil) :r (mf/use-ref nil) :g (mf/use-ref nil) @@ -40,6 +52,8 @@ :h (mf/use-ref nil) :s (mf/use-ref nil) :v (mf/use-ref nil) + :hsl-s (mf/use-ref nil) + :hsl-l (mf/use-ref nil) :alpha (mf/use-ref nil)} setup-hex-color @@ -73,6 +87,7 @@ (let [val (case property :s (/ val 100) :v (value->hsv-value val) + (:hsl-s :hsl-l) (/ val 100) :alpha (/ val 100) val)] (cond @@ -87,6 +102,18 @@ :h h :s s :v v :r r :g g :b b})) + ;; HSL changes: recompute RGB/HSV from the new HSL triple, + ;; reusing the current hue when only S or L changes. + (#{:hsl-s :hsl-l} property) + (let [new-s (if (= property :hsl-s) val hsl-s) + new-l (if (= property :hsl-l) val hsl-l) + [r g b] (cc/hsl->rgb [hue new-s new-l]) + hex (cc/rgb->hex [r g b]) + [h s v] (cc/hex->hsv hex)] + (on-change {:hex hex + :h h :s s :v v + :r r :g g :b b})) + :else (let [{:keys [h s v]} (merge color (hash-map property val)) hex (cc/hsv->hex [h s v]) @@ -126,10 +153,13 @@ ;; Updates the inputs values when a property is changed in the parent (mf/use-effect - (mf/deps color type) + (mf/deps color type hsb-mode) (fn [] (doseq [ref-key (keys refs)] - (let [property-val (get color ref-key) + (let [property-val (case ref-key + :hsl-s hsl-s + :hsl-l hsl-l + (get color ref-key)) property-ref (get refs ref-key)] (when (and property-val property-ref) (when-let [node (mf/ref-val property-ref)] @@ -137,14 +167,32 @@ (case ref-key (:s :alpha) (mth/precision (* property-val 100) 2) :v (mth/precision (hsv-value->value property-val) 2) + (:hsl-s :hsl-l) (mth/precision (* property-val 100) 2) property-val)] (dom/set-value! node new-val)))))))) [:div {:class (stl/css-case :color-values true :disable-opacity disable-opacity)} + ;; Inline HSB/HSL switcher — only shown on the HSB tab so that + ;; designers can pick whichever hue-based model matches their + ;; workflow (HSB matches Figma/Sketch/XD, HSL matches CSS). + (when (and (not= type :rgb) on-mode-change) + [:div {:class (stl/css :model-switcher)} + [:button {:type "button" + :class (stl/css-case :model-pill true + :model-pill-active (= hsb-mode :hsb)) + :on-click #(on-mode-change :hsb)} + "HSB"] + [:button {:type "button" + :class (stl/css-case :model-pill true + :model-pill-active (= hsb-mode :hsl)) + :on-click #(on-mode-change :hsl)} + "HSL"]]) + [:div {:class (stl/css :colors-row)} - (if (= type :rgb) + (cond + (= type :rgb) [:* [:div {:class (stl/css :input-wrapper)} [:label {:for "red-value" :class (stl/css :input-label)} "R"] @@ -177,6 +225,42 @@ :on-change (on-change-property :b 255) :on-key-down (on-key-down-property :b 255)}]]] + (= hsb-mode :hsl) + [:* + [:div {:class (stl/css :input-wrapper)} + [:label {:for "hue-value" :class (stl/css :input-label)} "H"] + [:input {:id "hue-value" + :ref (:h refs) + :type "number" + :min 0 + :max 360 + :default-value hue + :on-change (on-change-property :h 360) + :on-key-down (on-key-down-property :h 360)}]] + [:div {:class (stl/css :input-wrapper)} + [:label {:for "hsl-saturation-value" :class (stl/css :input-label)} "S"] + [:input {:id "hsl-saturation-value" + :ref (:hsl-s refs) + :type "number" + :min 0 + :max 100 + :step 1 + :default-value (mth/precision (* hsl-s 100) 2) + :on-change (on-change-property :hsl-s 100) + :on-key-down (on-key-down-property :hsl-s 100)}]] + [:div {:class (stl/css :input-wrapper)} + [:label {:for "lightness-value" :class (stl/css :input-label)} "L"] + [:input {:id "lightness-value" + :ref (:hsl-l refs) + :type "number" + :min 0 + :max 100 + :step 1 + :default-value (mth/precision (* hsl-l 100) 2) + :on-change (on-change-property :hsl-l 100) + :on-key-down (on-key-down-property :hsl-l 100)}]]] + + :else [:* [:div {:class (stl/css :input-wrapper)} [:label {:for "hue-value" :class (stl/css :input-label)} "H"] @@ -200,8 +284,8 @@ :on-change (on-change-property :s 100) :on-key-down (on-key-down-property :s 100)}]] [:div {:class (stl/css :input-wrapper)} - [:label {:for "value-value" :class (stl/css :input-label)} "V"] - [:input {:id "value-value" + [:label {:for "brightness-value" :class (stl/css :input-label)} "B(V)"] + [:input {:id "brightness-value" :ref (:v refs) :type "number" :min 0 diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss index 6d653f34e3..3a3034bc95 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_inputs.scss @@ -7,28 +7,66 @@ @use "refactor/common-refactor.scss" as deprecated; .color-values { - @include deprecated.flexColumn; + @include deprecated.flex-column; + margin-top: deprecated.$s-8; + .model-switcher { + display: flex; + gap: deprecated.$s-4; + margin-bottom: deprecated.$s-8; + padding: deprecated.$s-2; + background-color: var(--color-background-tertiary); + border-radius: deprecated.$s-6; + align-self: flex-start; + + .model-pill { + @include deprecated.body-small-typography; + + padding: deprecated.$s-2 deprecated.$s-8; + border: none; + border-radius: deprecated.$s-4; + background: transparent; + color: var(--color-foreground-secondary); + cursor: pointer; + + &:hover { + color: var(--color-foreground-primary); + } + + &.model-pill-active { + background-color: var(--color-background-primary); + color: var(--color-accent-primary); + } + } + } + &.disable-opacity { grid-template-columns: 3.5rem repeat(3, 1fr); } + .colors-row { - @include deprecated.flexRow; + @include deprecated.flex-row; + .input-wrapper { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + width: deprecated.$s-84; display: flex; align-items: baseline; } } + .hex-alpha-wrapper { - @include deprecated.flexRow; + @include deprecated.flex-row; + .input-wrapper { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + width: deprecated.$s-84; + &.hex { width: deprecated.$s-172; display: flex; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss index 37ab3f3e40..ba963307bd 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/color_tokens.scss @@ -16,6 +16,7 @@ .color-token-item { --color-token-background: var(--color-background-primary); + background-color: var(--color-token-background); color: var(--color-foreground-primary); text-align: left; @@ -29,6 +30,7 @@ block-size: $sz-28; border: none; cursor: pointer; + &:hover { --color-token-background: var(--color-background-tertiary); } @@ -36,6 +38,7 @@ .color-token-empty-state { @include t.use-typography("body-small"); + padding: var(--sp-s) var(--sp-xxl); text-align: center; color: var(--color-foreground-secondary); @@ -57,6 +60,7 @@ .token-name { @include t.use-typography("body-small"); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; @@ -97,7 +101,9 @@ .set-title-bar { --title-color: var(--color-foreground-secondary); --arrow-color: var(--color-foreground-secondary); + @include t.use-typography("title-small"); + text-transform: none; display: flex; overflow: hidden; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss index d9e5b75633..af6a439328 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/gradients.scss @@ -46,7 +46,7 @@ background-size: deprecated.$s-8; border-radius: deprecated.$br-6; border: deprecated.$s-2 solid var(--color-foreground-primary); - box-shadow: 0px 0px deprecated.$s-4 0px var(--menu-shadow-color); + box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); height: calc(deprecated.$s-24 - deprecated.$s-2); left: var(--position); overflow: hidden; @@ -59,11 +59,12 @@ outline: deprecated.$s-2 solid var(--color-accent-primary); } } + .gradient-preview-stop-decoration { background: var(--color-foreground-primary); border-radius: 100%; bottom: deprecated.$s-32; - box-shadow: 0px 0px deprecated.$s-4 0px var(--menu-shadow-color); + box-shadow: 0 0 deprecated.$s-4 0 var(--menu-shadow-color); height: deprecated.$s-4; left: calc(var(--position) + deprecated.$s-8); position: absolute; @@ -109,8 +110,7 @@ flex-direction: column; gap: deprecated.$s-4; max-height: deprecated.$s-180; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; padding: 0 0 var(--sp-s) var(--sp-m); } @@ -120,7 +120,6 @@ padding: deprecated.$s-2; border-radius: deprecated.$br-12; border: deprecated.$s-1 solid transparent; - position: relative; &.is-selected { @@ -141,8 +140,9 @@ } .offset-input-wrapper { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + width: deprecated.$s-92; } diff --git a/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss b/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss index e2438dc416..74b34eab5d 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/harmony.scss @@ -15,7 +15,8 @@ } .hue-wheel-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + position: relative; } @@ -25,7 +26,8 @@ } .handler { - @extend .colorpicker-handler; + @extend %colorpicker-handler; + height: deprecated.$s-16; width: deprecated.$s-16; border: deprecated.$s-2 solid var(--colorpicker-handlers-color); @@ -37,7 +39,8 @@ } .handlers-wrapper { - @include deprecated.flexRow; + @include deprecated.flex-row; + height: deprecated.$s-200; width: deprecated.$s-52; flex-grow: 1; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs index 807d976314..802f59c8c2 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.cljs @@ -11,17 +11,45 @@ [app.main.ui.workspace.colorpicker.slider-selector :refer [slider-selector*]] [rumext.v2 :as mf])) -(mf/defc hsva-selector* [{:keys [color disable-opacity on-change on-start-drag on-finish-drag]}] - (let [{hue :h saturation :s value :v alpha :alpha} color - handle-change-slider (fn [key] - (fn [new-value] - (let [change (hash-map key new-value) - {:keys [h s v]} (merge color change) - hex (cc/hsv->hex [h s v]) - [r g b] (cc/hex->rgb hex)] - (on-change (merge change - {:hex hex - :r r :g g :b b}))))) +(mf/defc hsva-selector* [{:keys [color disable-opacity mode on-change on-start-drag on-finish-drag]}] + (let [{hue :h saturation :s value :v alpha :alpha + r-val :r g-val :g b-val :b} color + hsl-mode? (= mode :hsl) + + ;; Current HSL derived from RGB — used as the starting point + ;; for HSL saturation/lightness slider values and for + ;; recomputing the color when either is dragged. + [_ hsl-s hsl-l] (if (and r-val g-val b-val) + (cc/rgb->hsl [r-val g-val b-val]) + [0 0 0]) + + ;; HSB math — current default behavior. + handle-change-hsv + (fn [key] + (fn [new-value] + (let [change (hash-map key new-value) + {:keys [h s v]} (merge color change) + hex (cc/hsv->hex [h s v]) + [r g b] (cc/hex->rgb hex)] + (on-change (merge change + {:hex hex + :r r :g g :b b}))))) + + ;; HSL math — when the user drags the S or L slider in HSL mode, + ;; we recompute RGB from the updated HSL triple and derive HSV + ;; for the canonical color representation. + handle-change-hsl + (fn [key] + (fn [new-value] + (let [new-s (if (= key :hsl-s) new-value hsl-s) + new-l (if (= key :hsl-l) new-value hsl-l) + [r g b] (cc/hsl->rgb [hue new-s new-l]) + hex (cc/rgb->hex [r g b]) + [h s v] (cc/hex->hsv hex)] + (on-change {:hex hex + :h h :s s :v v + :r r :g g :b b})))) + on-change-opacity (fn [new-alpha] (on-change {:alpha new-alpha}))] [:div {:class (stl/css :hsva-selector)} [:div {:class (stl/css :hsva-row)} @@ -31,29 +59,47 @@ :type :hue :max-value 360 :value hue - :on-change (handle-change-slider :h) + :on-change (handle-change-hsv :h) :on-start-drag on-start-drag :on-finish-drag on-finish-drag}]] [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "S"] - [:> slider-selector* - {:class (stl/css :hsva-bar) - :type :saturation - :max-value 1 - :value saturation - :on-change (handle-change-slider :s) - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}]] + (if hsl-mode? + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :hsl-saturation + :max-value 1 + :value hsl-s + :on-change (handle-change-hsl :hsl-s) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :saturation + :max-value 1 + :value saturation + :on-change (handle-change-hsv :s) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])] [:div {:class (stl/css :hsva-row)} - [:span {:class (stl/css :hsva-selector-label)} "V"] - [:> slider-selector* - {:class (stl/css :hsva-bar) - :type :value - :max-value 255 - :value value - :on-change (handle-change-slider :v) - :on-start-drag on-start-drag - :on-finish-drag on-finish-drag}]] + [:span {:class (stl/css :hsva-selector-label)} (if hsl-mode? "L" "B(V)")] + (if hsl-mode? + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :lightness + :max-value 1 + :value hsl-l + :on-change (handle-change-hsl :hsl-l) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}] + [:> slider-selector* + {:class (stl/css :hsva-bar) + :type :value + :max-value 255 + :value value + :on-change (handle-change-hsv :v) + :on-start-drag on-start-drag + :on-finish-drag on-finish-drag}])] (when (not disable-opacity) [:div {:class (stl/css :hsva-row)} [:span {:class (stl/css :hsva-selector-label)} "A"] diff --git a/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss b/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss index 08def7607f..17e0b52fc6 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/hsva.scss @@ -7,9 +7,10 @@ @use "refactor/common-refactor.scss" as deprecated; .hsva-selector { - @include deprecated.flexColumn; + @include deprecated.flex-column; + padding: deprecated.$s-4; - grid-row-gap: deprecated.$s-8; + row-gap: deprecated.$s-8; margin-bottom: deprecated.$s-8; } @@ -19,7 +20,8 @@ } .hsva-selector-label { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + display: flex; align-items: center; justify-content: flex-start; diff --git a/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss b/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss index 0fc9028c86..1489da0fd0 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/libraries.scss @@ -23,13 +23,15 @@ .add-color-btn, .palette-btn { - @extend .button-secondary; + @extend %button-secondary; + height: deprecated.$s-24; width: deprecated.$s-24; border-radius: deprecated.$br-circle; padding: 0; + svg { - @extend .button-icon; + @extend %button-icon; } } diff --git a/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss b/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss index 00e5825af6..952f6f344b 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/ramp.scss @@ -7,7 +7,7 @@ @use "refactor/common-refactor.scss" as deprecated; .value-saturation-selector { - background-color: rgba(var(--hue-rgb)); + background-color: rgb(var(--hue-rgb)); position: relative; height: deprecated.$s-140; width: 100%; @@ -20,7 +20,7 @@ position: absolute; width: 100%; height: 100%; - background: linear-gradient(to right, #fff, rgba(255, 255, 255, 0)); + background: linear-gradient(to right, #fff, rgb(255 255 255 / 0)); } &::after { @@ -28,12 +28,13 @@ position: absolute; width: 100%; height: 100%; - background: linear-gradient(to top, #000, rgba(0, 0, 0, 0)); + background: linear-gradient(to top, #000, rgb(0 0 0 / 0)); } } .handler { - @extend .colorpicker-handler; + @extend %colorpicker-handler; + height: deprecated.$s-16; width: deprecated.$s-16; border: deprecated.$s-2 solid var(--colorpicker-handlers-color); diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs index f125b6368b..7021db6767 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.cljs @@ -53,7 +53,9 @@ :slider-selector true :hue (= type :hue) :opacity (= type :opacity) - :value (= type :value))) + :value (= type :value) + :hsl-saturation (= type :hsl-saturation) + :lightness (= type :lightness))) :data-testid (when (= type :opacity) "slider-opacity") :on-pointer-down handle-start-drag :on-pointer-up handle-stop-drag diff --git a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss index 460939c0a8..09b9942e22 100644 --- a/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss +++ b/frontend/src/app/main/ui/workspace/colorpicker/slider_selector.scss @@ -14,17 +14,14 @@ --gradient-direction: 0deg; --background-repeat: top; } + position: relative; align-self: center; height: deprecated.$s-24; inline-size: 100%; border: deprecated.$s-2 solid var(--colorpicker-details-color); border-radius: deprecated.$br-6; - background: linear-gradient( - var(--gradient-direction), - rgba(var(--color), 0) 0%, - rgba(var(--color), 1) 100% - ); + background: linear-gradient(var(--gradient-direction), rgb(var(--color), 0) 0%, rgb(var(--color), 1) 100%); cursor: pointer; &.vertical { @@ -65,8 +62,8 @@ height: 100%; background: linear-gradient( var(--gradient-direction), - rgba(var(--color), 0) 0%, - rgba(var(--color), 1) 100% + rgb(var(--color), 0) 0%, + rgb(var(--color), 1) 100% ); } } @@ -75,6 +72,18 @@ background: linear-gradient(var(--gradient-direction), #000 0%, #fff 100%); } + &.hsl-saturation { + background: linear-gradient( + var(--gradient-direction), + var(--hsl-saturation-grad-from) 0%, + var(--hsl-saturation-grad-to) 100% + ); + } + + &.lightness { + background: linear-gradient(var(--gradient-direction), #000 0%, var(--lightness-grad-mid) 50%, #fff 100%); + } + .handler { position: absolute; left: 50%; @@ -109,6 +118,7 @@ .slider-selector.value { background: linear-gradient(var(--gradient-direction), var(--hue-from, #000) 0%, var(--hue-to, #fff) 100%); } + .slider-selector.saturation { background: linear-gradient( var(--gradient-direction), diff --git a/frontend/src/app/main/ui/workspace/comments.scss b/frontend/src/app/main/ui/workspace/comments.scss index 1cd9c04b64..9b956523ca 100644 --- a/frontend/src/app/main/ui/workspace/comments.scss +++ b/frontend/src/app/main/ui/workspace/comments.scss @@ -23,8 +23,9 @@ } .mode-dropdown-wrapper { - @include deprecated.buttonStyle; - @extend .asset-element; + @include deprecated.button-style; + @extend %asset-element; + background-color: var(--color-background-tertiary); display: flex; width: 100%; @@ -45,18 +46,22 @@ } .arrow-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + transform: rotate(90deg); stroke: var(--icon-foreground); } } .comment-mode-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + top: deprecated.$s-92; left: deprecated.$s-12; max-width: deprecated.$s-256; @@ -68,29 +73,38 @@ } .dropdown-item { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + justify-content: space-between; + .icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-24; width: deprecated.$s-24; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: transparent; } } + .label { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; } + &:hover { .icon svg { stroke: transparent; } } + &.selected { .label { color: var(--menu-foreground-color); } + .icon svg { stroke: var(--icon-foreground-hover); } diff --git a/frontend/src/app/main/ui/workspace/context_menu.cljs b/frontend/src/app/main/ui/workspace/context_menu.cljs index c310b073be..c70764ffd3 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/context_menu.cljs @@ -21,6 +21,7 @@ [app.main.data.modal :as modal] [app.main.data.shortcuts :as scd] [app.main.data.workspace :as dw] + [app.main.data.workspace.guides :as dwg] [app.main.data.workspace.interactions :as dwi] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.selection :as dws] @@ -633,6 +634,25 @@ [:> menu-entry* {:title (tr "workspace.shape.menu.combine-as-variants") :on-click do-combine-as-variants}]])])) +(mf/defc context-menu-guides* + {::mf/props :obj + ::mf/private true} + [{:keys [shapes]}] + (let [frame-ids (into #{} (comp (filter cfh/frame-shape?) d/xf:map-id) shapes) + guides (mf/deref refs/workspace-page-guides) + has-guides? (some #(contains? frame-ids (:frame-id %)) (vals guides)) + + do-remove-guides + (mf/use-fn + (mf/deps frame-ids) + #(st/emit! (dwg/remove-frame-guides frame-ids)))] + + (when (and (seq frame-ids) has-guides?) + [:* + [:> menu-separator* {}] + [:> menu-entry* {:title (tr "workspace.shape.menu.clear-guides") + :on-click do-remove-guides}]]))) + (mf/defc context-menu-delete* {::mf/private true} [] @@ -673,6 +693,7 @@ (when is-not-variant-container? [:> context-menu-layout* props]) [:> context-menu-component* props] + [:> context-menu-guides* props] [:> context-menu-delete* props]]))) (mf/defc page-item-context-menu* @@ -778,6 +799,7 @@ [{:keys [mdata]}] (let [{:keys [grid cells]} mdata + grid-id (:id grid) single? (= (count cells) 1) can-merge? @@ -785,17 +807,53 @@ (mf/deps cells) #(ctl/valid-area-cells? cells)) + can-copy-rows? + (mf/use-memo + (mf/deps grid cells) + #(dwsl/complete-rows? grid cells)) + + can-copy-columns? + (mf/use-memo + (mf/deps grid cells) + #(dwsl/complete-columns? grid cells)) + + grid-edition-ref + (mf/use-memo + (mf/deps grid-id) + #(refs/workspace-grid-edition-id grid-id)) + + grid-edition (mf/deref grid-edition-ref) + has-copied-tracks? (some? (:copied-tracks grid-edition)) + do-merge-cells (mf/use-fn - (mf/deps grid cells) + (mf/deps grid-id cells) (fn [] - (st/emit! (dwsl/merge-cells (:id grid) (map :id cells))))) + (st/emit! (dwsl/merge-cells grid-id (map :id cells))))) do-create-board (mf/use-fn - (mf/deps grid cells) + (mf/deps grid-id cells) (fn [] - (st/emit! (dwsl/create-cell-board (:id grid) (map :id cells)))))] + (st/emit! (dwsl/create-cell-board grid-id (map :id cells))))) + + do-copy-rows + (mf/use-fn + (mf/deps grid-id) + (fn [] + (st/emit! (dwsl/copy-grid-tracks grid-id :row)))) + + do-copy-columns + (mf/use-fn + (mf/deps grid-id) + (fn [] + (st/emit! (dwsl/copy-grid-tracks grid-id :column)))) + + do-paste-tracks + (mf/use-fn + (mf/deps grid-id) + (fn [] + (st/emit! (dwsl/paste-grid-tracks grid-id))))] [:* (when (not single?) [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.merge") @@ -808,8 +866,64 @@ [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.create-board") :on-click do-create-board - :disabled (and (not single?) (not can-merge?))}]])) + :disabled (and (not single?) (not can-merge?))}] + [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.copy-rows") + :on-click do-copy-rows + :disabled (not can-copy-rows?)}] + + [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.copy-columns") + :on-click do-copy-columns + :disabled (not can-copy-columns?)}] + + [:> menu-entry* {:title (tr "workspace.context-menu.grid-cells.paste-tracks") + :on-click do-paste-tracks + :disabled (not has-copied-tracks?)}]])) + + +(def guide-color-presets + ["#ff3277" "#4dabf7" "#51cf66" "#fcc419" "#ff922b" "#cc5de8" "#ffffff" "#868e96"]) + +(mf/defc guide-color-context-menu* + {::mf/props :obj + ::mf/private true} + [{:keys [mdata]}] + (let [{:keys [guide]} mdata + guide-id (:id guide) + current-color (or (:color guide) (first guide-color-presets)) + + do-set-color + (mf/use-fn + (mf/deps guide-id) + (fn [event] + (let [color (dom/get-data (dom/get-current-target event) "color")] + (st/emit! dw/hide-context-menu + (dwg/update-guide-color guide-id color))))) + + do-remove-guide + (mf/use-fn + (mf/deps guide) + (fn [] + (st/emit! dw/hide-context-menu + (dwg/remove-guide guide))))] + + [:* + [:li {:class (stl/css :context-menu-item :guide-color-label)} + [:span {:class (stl/css :title)} + (tr "workspace.context-menu.guides.change-color")]] + [:li {:class (stl/css :guide-color-swatches)} + (for [color guide-color-presets] + [:span {:key color + :class (stl/css-case + :guide-color-swatch true + :selected (= color current-color)) + :data-color color + :on-click do-set-color + :title color + :style {:background-color color}}])] + [:> menu-separator* {}] + [:> menu-entry* {:title (tr "workspace.context-menu.guides.remove") + :on-click do-remove-guide}]])) ;; FIXME: optimize because it is rendered always @@ -848,4 +962,5 @@ :page [:> page-item-context-menu* {:mdata mdata}] :grid-track [:> grid-track-context-menu* {:mdata mdata}] :grid-cells [:> grid-cells-context-menu* {:mdata mdata}] + :guide [:> guide-color-context-menu* {:mdata mdata}] [:> viewport-context-menu* {:mdata mdata}]))]]])) diff --git a/frontend/src/app/main/ui/workspace/context_menu.scss b/frontend/src/app/main/ui/workspace/context_menu.scss index 8d347c2190..a6d1c799a3 100644 --- a/frontend/src/app/main/ui/workspace/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/context_menu.scss @@ -15,7 +15,8 @@ .context-list, .workspace-context-submenu { - @include deprecated.menuShadow; + @include deprecated.menu-shadow; + display: grid; width: deprecated.$s-240; padding: deprecated.$s-4; @@ -45,16 +46,21 @@ cursor: pointer; .title { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--menu-foreground-color); } + .shortcut { - @include deprecated.flexCenter; + @include deprecated.flex-center; + gap: deprecated.$s-2; color: var(--menu-shortcut-foreground-color); + .shortcut-key { - @include deprecated.bodySmallTypography; - @include deprecated.flexCenter; + @include deprecated.body-small-typography; + @include deprecated.flex-center; + height: deprecated.$s-20; padding: deprecated.$s-2 deprecated.$s-6; border-radius: deprecated.$br-6; @@ -63,19 +69,23 @@ } .submenu-icon svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--menu-foreground-color); } &:hover { background-color: var(--menu-background-color-hover); + .title { color: var(--menu-foreground-color-hover); } + .shortcut { color: var(--menu-shortcut-foreground-color-hover); } } + &:focus { border: 1px solid var(--menu-border-color-focus); background-color: var(--menu-background-color-focus); @@ -89,6 +99,7 @@ height: deprecated.$s-28; padding: deprecated.$s-6; border-radius: deprecated.$br-8; + &:hover { background-color: var(--menu-background-color-hover); } @@ -99,15 +110,18 @@ .selected-icon { svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--menu-foreground-color); } } .shape-icon { margin-left: deprecated.$s-2; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--menu-foreground-color); } } @@ -124,3 +138,30 @@ pointer-events: none; opacity: 0.6; } + +.guide-color-label { + cursor: default; + pointer-events: none; +} + +.guide-color-swatches { + display: flex; + flex-wrap: wrap; + gap: deprecated.$s-6; + padding: deprecated.$s-4 deprecated.$s-6 deprecated.$s-8; + list-style: none; +} + +.guide-color-swatch { + width: deprecated.$s-20; + height: deprecated.$s-20; + border-radius: 50%; + cursor: pointer; + flex-shrink: 0; + box-sizing: border-box; + border: deprecated.$s-2 solid var(--panel-border-color); + + &.selected { + border: deprecated.$s-2 solid var(--menu-foreground-color); + } +} diff --git a/frontend/src/app/main/ui/workspace/coordinates.scss b/frontend/src/app/main/ui/workspace/coordinates.scss index b338144d06..44c76da73e 100644 --- a/frontend/src/app/main/ui/workspace/coordinates.scss +++ b/frontend/src/app/main/ui/workspace/coordinates.scss @@ -11,7 +11,7 @@ $width-settings-bar: 256px; .container { background-color: var(--color-background-primary); border-radius: deprecated.$br-4; - bottom: 0px; + bottom: 0; padding: deprecated.$s-2 deprecated.$s-8; position: fixed; right: calc(#{$width-settings-bar} + #{deprecated.$s-24}); diff --git a/frontend/src/app/main/ui/workspace/left_header.scss b/frontend/src/app/main/ui/workspace/left_header.scss index 5ecac0793c..a0cd2153e5 100644 --- a/frontend/src/app/main/ui/workspace/left_header.scss +++ b/frontend/src/app/main/ui/workspace/left_header.scss @@ -14,11 +14,13 @@ } .main-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-32; height: deprecated.$s-32; min-height: deprecated.$s-32; margin-right: deprecated.$s-4; + svg { min-height: deprecated.$s-32; width: deprecated.$s-32; @@ -37,8 +39,9 @@ .project-name, .file-name { - @include deprecated.uppercaseTitleTipography; - @include deprecated.textEllipsis; + @include deprecated.uppercase-title-typography; + @include deprecated.text-ellipsis; + height: deprecated.$s-16; width: 100%; padding-bottom: deprecated.$s-2; @@ -47,7 +50,8 @@ } .file-name { - @include deprecated.smallTitleTipography; + @include deprecated.small-title-typography; + text-transform: none; color: var(--title-foreground-color-hover); align-items: center; @@ -56,11 +60,12 @@ } .file-name-label { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; } .file-name-input { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: 100%; margin: 0; border: 0; @@ -71,16 +76,19 @@ color: var(--input-foreground-color); z-index: deprecated.$z-index-20; white-space: break-spaces; + &:focus { outline: none; } } .shared-badge { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-16; height: deprecated.$s-32; margin-right: deprecated.$s-4; + svg { stroke: var(--button-secondary-foreground-color-rest); fill: none; @@ -119,9 +127,11 @@ 0% { transform: translateY(0); } + 50% { transform: translateY(-4px); } + 100% { transform: translateY(0); } diff --git a/frontend/src/app/main/ui/workspace/libraries.cljs b/frontend/src/app/main/ui/workspace/libraries.cljs index 08dad36701..00834db2ca 100644 --- a/frontend/src/app/main/ui/workspace/libraries.cljs +++ b/frontend/src/app/main/ui/workspace/libraries.cljs @@ -32,6 +32,7 @@ [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.context :as ctx] + [app.main.ui.ds.buttons.button :refer [button*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.ds.layout.tab-switcher :refer [tab-switcher*]] @@ -47,12 +48,6 @@ [cuerdas.core :as str] [rumext.v2 :as mf])) -(def ^:private close-icon - (deprecated-icon/icon-xref :close (stl/css :close-icon))) - -(def ^:private add-icon - (deprecated-icon/icon-xref :add (stl/css :add-icon))) - (defn- get-library-summary "Given a library data return a summary representation of this library" [data] @@ -169,12 +164,12 @@ [:div {:class (stl/css :sample-library-item) :key (dm/str id)} [:div {:class (stl/css :sample-library-item-name)} (:name library)] - [:input {:class (stl/css-case :sample-library-button true - :sample-library-add (nil? importing?) - :sample-library-adding (some? importing?)) - :type "button" - :value (if (= importing? id) (tr "labels.adding") (tr "labels.add")) - :on-click import-library}]])) + + [:> button* {:variant "secondary" + :disabled (some? importing?) + :on-click import-library + :class (stl/css :sample-library-button)} + (if (= importing? id) (tr "labels.adding") (tr "labels.add"))]])) (defn- empty-library? "Check if currentt library summary has elements or not" @@ -338,31 +333,31 @@ [:div {:class (stl/css :section-list-item)} [:div {:class (stl/css :item-content)} - [:div {:class (stl/css :item-name)} (tr "workspace.libraries.file-library")] + [:div {:class (stl/css :item-title)} (tr "workspace.libraries.file-library")] [:ul {:class (stl/css :item-contents)} [:> library-description* {:summary summary}]]] (if ^boolean is-shared - [:input {:class (stl/css :item-unpublish) - :type "button" - :value (tr "common.unpublish") - :on-click unpublish}] - [:input {:class (stl/css :item-publish) - :type "button" - :value (tr "common.publish") - :on-click publish}])] + [:> button* {:variant "secondary" + :type "button" + :on-click unpublish} + (tr "common.unpublish")] + + [:> button* {:variant "primary" + :type "button" + :on-click publish} + (tr "common.publish")])] (for [{:keys [id name data connected-to connected-to-names] :as library} linked-libraries] (let [disabled? (some #(contains? linked-libraries-ids %) connected-to) has-tokens? (and (has-tokens? library) (contains? cf/flags :token-import-from-library))] - [:div {:class (if has-tokens? - (stl/css :section-list-item-double-icon) - (stl/css :section-list-item)) + [:div {:class (stl/css :section-list-item) :key (dm/str id) :data-testid "library-item"} [:div {:class (stl/css :item-content)} - [:div {:class (stl/css :item-name)} name] + [:div {:class (stl/css-case :item-name true + :item-name-short has-tokens?)} name] [:ul {:class (stl/css :item-contents)} (let [summary (get-library-summary data)] [:* @@ -372,23 +367,23 @@ [:span "(" (tr "workspace.libraries.connected-to") " "] [:span {:class (stl/css :connected-to-values)} (str/join ", " connected-to-names)] [:span ")"]])])]] + [:div {:class (stl/css :library-actions)} + (when ^boolean has-tokens? + [:> icon-button* + {:type "button" + :aria-label (tr "workspace.tokens.import-tokens") + :icon i/import-export + :data-library-id (dm/str id) + :variant "secondary" + :on-click import-tokens}]) - (when ^boolean has-tokens? - [:> icon-button* - {:type "button" - :aria-label (tr "workspace.tokens.import-tokens") - :icon i/import-export - :data-library-id (dm/str id) - :variant "secondary" - :on-click import-tokens}]) - - [:> icon-button* {:type "button" - :aria-label (tr "workspace.libraries.unlink-library-btn") - :icon i/detach - :data-library-id (dm/str id) - :variant "secondary" - :disabled disabled? - :on-click unlink-library}]]))]] + [:> icon-button* {:type "button" + :aria-label (tr "workspace.libraries.unlink-library-btn") + :icon i/detach + :data-library-id (dm/str id) + :variant "secondary" + :disabled disabled? + :on-click unlink-library}]]]))]] [:div {:class (stl/css :shared-section)} [:> title-bar* {:collapsable false @@ -412,11 +407,12 @@ (adapt-backend-summary))] [:> library-description* {:summary summary}])]] - [:button {:class (stl/css :item-button-shared) - :data-library-id (dm/str id) - :title (tr "workspace.libraries.shared-library-btn") - :on-click link-library} - add-icon]])] + [:> icon-button* {:class (stl/css :item-button-shared) + :variant "secondary" + :data-library-id (dm/str id) + :icon "add" + :aria-label (tr "workspace.libraries.shared-library-btn") + :on-click link-library}]])] (when (empty? shared-libraries) [:div {:class (stl/css :section-list-empty)} @@ -437,6 +433,7 @@ (for [library sample-libraries] [:> sample-library-entry* {:library library + :key (dm/str (:id library)) :importing importing*}])]] :else @@ -536,17 +533,17 @@ [:div {:class (stl/css :section-list-item) :key (dm/str id)} [:div {:class (stl/css :item-content)} - [:div {:class (stl/css :item-name)} name] + [:div {:class (stl/css :item-name-long)} name] [:ul {:class (stl/css :item-contents)} (describe-library (count components) 0 (count colors) (count typographies))]] - [:button {:type "button" - :class (stl/css :item-update) - :disabled updating? - :data-library-id (dm/str id) - :on-click update} + [:> button* {:class (stl/css :item-update) + :disabled updating? + :variant "primary" + :data-library-id (dm/str id) + :on-click update} (tr "workspace.libraries.update")] [:div {:class (stl/css :libraries-updates)} @@ -680,11 +677,11 @@ :on-click close-dialog-outside :data-testid "libraries-modal"} [:div {:class (stl/css :modal-dialog)} - [:button {:class (stl/css :close-btn) - :on-click close-dialog - :aria-label (tr "labels.close") - :data-testid "close-libraries"} - close-icon] + [:> icon-button* {:class (stl/css :close-btn) + :on-click close-dialog + :aria-label (tr "labels.close") + :variant "ghost" + :icon i/close}] [:div {:class (stl/css :modal-title)} (tr "workspace.libraries.libraries")] @@ -756,5 +753,6 @@ "created in your files previously to this new version."]]] [:div {:class (stl/css :info-bottom)} - [:button {:class (stl/css :primary-button) - :on-click handle-gotit-click} "I GOT IT"]]]]])) + [:> button* {:class (stl/css :primary-button) + :variant "primary" + :on-click handle-gotit-click} "I GOT IT"]]]]])) diff --git a/frontend/src/app/main/ui/workspace/libraries.scss b/frontend/src/app/main/ui/workspace/libraries.scss index 978c2cbcba..e8a5d9a309 100644 --- a/frontend/src/app/main/ui/workspace/libraries.scss +++ b/frontend/src/app/main/ui/workspace/libraries.scss @@ -4,7 +4,6 @@ // // Copyright (c) KALEIDOS INC -@use "refactor/common-refactor.scss" as deprecated; @use "ds/_sizes.scss" as *; @use "ds/_borders.scss" as *; @use "ds/_utils.scss" as *; @@ -33,7 +32,7 @@ background-color: var(--modal-background-color); border: $b-2 solid var(--modal-border-color); display: grid; - grid-template-rows: auto 1fr; + grid-template-rows: 0 auto 1fr; min-width: $sz-364; min-height: $sz-192; height: $sz-520; @@ -42,25 +41,9 @@ max-width: $sz-712; } -// TODO: Remove this extended creating modal component -.close-btn { - @extend .modal-close-btn-base; -} - -.close-icon { - display: flex; - justify-content: center; - align-items: center; - height: $sz-16; - width: $sz-16; - color: transparent; - fill: none; - stroke-width: $b-1; - stroke: var(--icon-foreground); -} - .modal-title { @include t.use-typography("headline-medium"); + margin-block-end: var(--sp-l); color: var(--color-foreground-primary); } @@ -81,12 +64,6 @@ display: grid; grid-template-rows: auto 1fr; gap: var(--sp-s); - - .section-list { - .section-list-item:first-child { - border: none; - } - } } .shared-section { @@ -107,7 +84,7 @@ overflow-y: auto; } -.section-list-item { +%section-list-item-placeholder { display: grid; grid-template-columns: 1fr auto; gap: var(--sp-s); @@ -116,8 +93,17 @@ border-radius: $br-8; } +.section-list-item { + @extend %section-list-item-placeholder; + + &:first-child { + border: none; + } +} + .section-list-item-double-icon { - @extend .section-list-item; + @extend %section-list-item-placeholder; + grid-template-columns: 1fr auto auto; } @@ -125,44 +111,10 @@ height: fit-content; } -.item-publish, -.item-unpublish { - // TODO: remove this extended by using DS button component - @extend .button-primary; - @include t.use-typography("headline-small"); - height: $sz-32; - min-width: px2rem(92); - padding: var(--sp-s) var(--sp-xxl); - margin: 0; - border-radius: $br-8; -} - -.item-unpublish { - // TODO: remove this extended by using DS button component - @extend .button-secondary; -} - -.item-button, -.item-button-shared { - // TODO: remove this extended by using DS button component - @extend .button-secondary; - height: $sz-32; - width: $sz-32; - margin-inline-start: var(--sp-xxs); - padding: var(--sp-s); -} - -.detach-icon, -.add-icon { - display: flex; - justify-content: center; - align-items: center; - height: $sz-16; - width: $sz-16; - color: transparent; - fill: none; - stroke-width: $b-1; - stroke: var(--icon-foreground); +.close-btn { + position: absolute; + inset-block-start: var(--sp-s); + inset-inline-end: var(--sp-s); } .section-list-shared { @@ -171,30 +123,11 @@ .section-title { @include t.use-typography("headline-small"); + margin-block-end: var(--sp-m); color: var(--title-foreground-color); } -.search-icon { - display: flex; - justify-content: center; - align-items: center; - width: px2rem(20); - padding: 0 0 0 var(--sp-s); - - svg { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - fill: none; - height: px2rem(12); - width: px2rem(12); - stroke-width: 1.33px; - stroke: var(--icon-foreground); - } -} - // empty state .section-list-empty { display: grid; @@ -206,21 +139,13 @@ margin-block: var(--sp-l); } -.library-icon { - display: flex; - justify-content: center; - align-items: center; - color: transparent; - fill: none; - stroke-width: $b-1; - stroke: var(--icon-foreground); - height: $sz-32; - width: $sz-32; -} - // Update library tab .libraries-updates-see-all { - @extend .link; + background: unset; + border: none; + color: var(--link-foreground-color); + cursor: pointer; + text-decoration: none; direction: rtl; grid-column: span 3; margin-block-start: var(--sp-s); @@ -236,7 +161,7 @@ display: grid; grid-column: span 3; grid-template-columns: repeat(auto-fill, minmax(px2rem(160), 1fr)); - gap: deprecated.$s-24; + gap: var(--sp-xxl); margin-block-start: var(--sp-l); } @@ -246,7 +171,8 @@ } .libraries-updates-item { - @include deprecated.bodyLargeTypography; + @include t.use-typography("body-large"); + display: grid; grid-template-columns: auto 1fr; align-items: start; @@ -273,36 +199,60 @@ padding-inline-start: calc(var(--sp-xxl) + var(--sp-s)); } -.item-name { +%item-name { @include t.use-typography("body-large"); - @include textEllipsis; + @include text-ellipsis; + + margin: 0; + max-width: px2rem(236); + color: var(--library-name-foreground-color); +} + +.item-name { + @extend %item-name; +} + +.item-name-short { + max-width: px2rem(206); +} + +.item-name-long { + @extend %item-name; + + max-width: px2rem(450); +} + +.item-title { + @include t.use-typography("body-large"); + margin: 0; - max-width: px2rem(216); color: var(--library-name-foreground-color); } .item-update { - @extend .button-primary; @include t.use-typography("headline-small"); + height: $sz-32; min-width: px2rem(92); padding: var(--sp-s) var(--sp-xxl); margin-inline-end: var(--sp-xxs); border-radius: $br-8; - - &:disabled { - @extend .button-disabled; - } } .item-contents { @include t.use-typography("body-small"); + color: var(--library-content-foreground-color); display: flex; flex-wrap: wrap; margin: 0; } +.library-actions { + display: flex; + gap: var(--sp-xs); +} + .element-count { white-space: nowrap; @@ -329,6 +279,7 @@ .modal-v2-title { @include t.use-typography("headline-medium"); + color: var(--modal-title-foreground-color); } @@ -341,11 +292,10 @@ .info-block { display: grid; - grid-template-columns: auto 1fr; column-gap: var(--sp-xl); grid-template: - "icon title" - "icon content"; + "icon title" auto + "icon content" auto / auto 1fr; } .info-icon { @@ -368,12 +318,14 @@ .info-block-title { @include t.use-typography("body-large"); + grid-area: title; color: var(--modal-title-foreground-color); } .info-block-content { @include t.use-typography("body-medium"); + grid-area: content; color: var(--library-content-foreground-color); } @@ -386,13 +338,14 @@ } .primary-button { - @extend .button-primary; @include t.use-typography("headline-small"); + padding: 0 var(--sp-l); } .sample-libraries-info { @include t.use-typography("body-small"); + display: flex; flex-direction: column; margin: var(--sp-xxxl); @@ -401,6 +354,7 @@ .sample-libraries-link { @include t.use-typography("body-small"); + color: var(--color-accent-primary); &:hover { @@ -410,6 +364,7 @@ .sample-libraries-container { @include t.use-typography("body-small"); + display: flex; flex-direction: column; width: 100%; @@ -427,6 +382,7 @@ .sample-library-item-name { @include t.use-typography("body-medium"); + color: var(--color-foreground-primary); white-space: nowrap; overflow: hidden; @@ -434,18 +390,9 @@ max-width: px2rem(232); } -// TODO: Remove this extended using a DS component -.sample-library-add { - @extend .button-secondary; -} - -// TODO: Remove this extended using a DS component -.sample-library-adding { - @extend .button-disabled; -} - .sample-library-button { @include t.use-typography("headline-small"); + height: $sz-32; width: px2rem(80); margin: 0; diff --git a/frontend/src/app/main/ui/workspace/main_menu.cljs b/frontend/src/app/main/ui/workspace/main_menu.cljs index 367976be10..ac3a44b57b 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.cljs +++ b/frontend/src/app/main/ui/workspace/main_menu.cljs @@ -389,6 +389,17 @@ (tr "workspace.header.menu.show-guides"))] [:> shortcuts* {:id :toggle-guides}]] + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) + :on-click toggle-flag + :on-key-down (fn [event] + (when (kbd/enter? event) + (toggle-flag event))) + :data-testid "lock-guides" + :id "file-menu-lock-guides"} + [:span {:class (stl/css :item-name)} + (if (contains? layout :lock-guides) + (tr "workspace.header.menu.unlock-guides") + (tr "workspace.header.menu.lock-guides"))]] (when-not ^boolean read-only? [:* @@ -463,6 +474,12 @@ (mf/use-fn #(st/emit! (dw/select-all))) + find + (mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find)))) + + find-and-replace + (mf/use-fn (fn [] (on-close) (st/emit! (dw/open-layers-search :find-and-replace)))) + undo (mf/use-fn #(st/emit! dwu/undo)) @@ -485,6 +502,20 @@ (tr "workspace.header.menu.select-all")] [:> shortcuts* {:id :select-all}]] + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) + :on-click find + :on-key-down (fn [event] (when (kbd/enter? event) (find event))) + :id "file-menu-find"} + [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.find")] + [:> shortcuts* {:id :find}]] + + [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) + :on-click find-and-replace + :on-key-down (fn [event] (when (kbd/enter? event) (find-and-replace event))) + :id "file-menu-find-and-replace"} + [:span {:class (stl/css :item-name)} (tr "workspace.header.menu.find-and-replace")] + [:> shortcuts* {:id :find-and-replace}]] + (when can-edit [:> dropdown-menu-item* {:class (stl/css :base-menu-item :submenu-item) :on-click undo diff --git a/frontend/src/app/main/ui/workspace/main_menu.scss b/frontend/src/app/main/ui/workspace/main_menu.scss index 1b12e2cdbf..6f615a7263 100644 --- a/frontend/src/app/main/ui/workspace/main_menu.scss +++ b/frontend/src/app/main/ui/workspace/main_menu.scss @@ -72,13 +72,13 @@ &.plugins { max-height: calc(100vh - $sz-200); - overflow-x: hidden; - overflow-y: auto; + overflow: hidden auto; } } .base-menu-item { @include t.use-typography("body-small"); + display: grid; align-items: center; grid-template-columns: auto $sz-16 $sz-16; @@ -99,6 +99,7 @@ &.disabled { --menu-foreground-color: var(--color-foreground-secondary); + pointer-events: none; } } @@ -122,6 +123,7 @@ .item-indicator { --menu-indicator-color: var(--color-foreground-secondary); + grid-area: indicator; display: flex; align-items: center; @@ -171,6 +173,7 @@ .shortcut-key { @include t.use-typography("body-small"); + display: flex; align-items: center; justify-content: center; diff --git a/frontend/src/app/main/ui/workspace/nudge.scss b/frontend/src/app/main/ui/workspace/nudge.scss index 9ed244bedd..8b62ca7f10 100644 --- a/frontend/src/app/main/ui/workspace/nudge.scss +++ b/frontend/src/app/main/ui/workspace/nudge.scss @@ -7,11 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; + min-width: deprecated.$s-408; } @@ -20,30 +21,37 @@ } .modal-title { - @include deprecated.headlineMediumTypography; + @include deprecated.headline-medium-typography; + color: var(--modal-title-foreground-color); } + .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.flexColumn; + @include deprecated.flex-column; + gap: deprecated.$s-24; - @include deprecated.bodyLargeTypography; + + @include deprecated.body-large-typography; + margin-bottom: deprecated.$s-24; } .input-wrapper { - @extend .input-with-label; - @include deprecated.bodySmallTypography; + @extend %input-with-label; + @include deprecated.body-small-typography; + label { text-transform: none; } } .modal-msg { - @include deprecated.bodyLargeTypography; + @include deprecated.body-large-typography; + color: var(--modal-text-foreground-color); line-height: 1.5; } diff --git a/frontend/src/app/main/ui/workspace/palette.scss b/frontend/src/app/main/ui/workspace/palette.scss index 7dc42ffc37..53201a64bd 100644 --- a/frontend/src/app/main/ui/workspace/palette.scss +++ b/frontend/src/app/main/ui/workspace/palette.scss @@ -7,7 +7,6 @@ @use "ds/spacing.scss" as *; @use "ds/z-index.scss" as *; @use "ds/_sizes.scss" as *; - @use "refactor/common-refactor.scss" as deprecated; .palette-wrapper { @@ -30,11 +29,7 @@ right: 0; grid-area: color-palette; display: grid; - grid-template-areas: - "resize resize resize" - "buttons actions palette"; - grid-template-rows: deprecated.$s-8 1fr; - grid-template-columns: deprecated.$s-32 auto 1fr; + grid-template: "resize resize resize" deprecated.$s-8 "buttons actions palette" 1fr / deprecated.$s-32 auto 1fr; max-height: deprecated.$s-80; height: var(--height); width: fit-content; @@ -46,6 +41,7 @@ right 0.3s, opacity 0.2s, width 0.3s; + &.wide { width: 100%; } @@ -59,6 +55,7 @@ cursor: ns-resize; background-color: var(--palette-background-color); } + .palette-btn-list { grid-area: buttons; background-color: var(--palette-background-color); @@ -68,35 +65,44 @@ list-style: none; z-index: deprecated.$z-index-2; gap: deprecated.$s-2; + &.mid-palette, &.small-palette { display: flex; } + .palette-item { - @include deprecated.flexCenter; + @include deprecated.flex-center; + border-radius: deprecated.$br-8; opacity: deprecated.$op-10; transition: opacity 1s ease; + .palette-btn { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-32; border-radius: deprecated.$br-8; background-clip: padding-box; padding: 0; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } } } .palette-actions { - @extend .button-tertiary; + @extend %button-tertiary; + grid-area: actions; height: calc(var(--height) - deprecated.$s-16); width: deprecated.$s-32; @@ -105,11 +111,14 @@ border-radius: deprecated.$br-8; background-color: var(--palette-background-color); z-index: deprecated.$z-index-2; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } + .palette { grid-area: palette; width: 100%; @@ -118,10 +127,12 @@ } .handler { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + width: deprecated.$s-12; height: 100%; + .handler-btn { width: deprecated.$s-4; height: 100%; @@ -147,29 +158,35 @@ border-inline-start: 0; border-start-start-radius: 0; border-end-start-radius: 0; + .palette-btn-list { opacity: deprecated.$op-0; visibility: hidden; width: 0; + .palette-item { opacity: deprecated.$op-0; visibility: hidden; z-index: 0; } } + .resize-area { visibility: hidden; z-index: 0; width: 0; } + .palette-actions { visibility: hidden; z-index: 0; } + .palette { visibility: hidden; z-index: 0; } + .handler { padding-bottom: deprecated.$s-8; } @@ -179,21 +196,26 @@ .help-btn { z-index: var(--z-index-panels); flex-shrink: 0; - @extend .button-secondary; + + @extend %button-secondary; + inline-size: $sz-40; block-size: $sz-40; border-radius: deprecated.$br-circle; border: none; + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } + &:hover { border: none; } } .icon-help { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); inline-size: var(--sp-xxl); block-size: var(--sp-xxl); diff --git a/frontend/src/app/main/ui/workspace/plugins.cljs b/frontend/src/app/main/ui/workspace/plugins.cljs index fc319bf925..f4068c585a 100644 --- a/frontend/src/app/main/ui/workspace/plugins.cljs +++ b/frontend/src/app/main/ui/workspace/plugins.cljs @@ -302,7 +302,20 @@ [:div {:class (stl/css :permissions-list-entry)} deprecated-icon/oauth-1 [:p {:class (stl/css :permissions-list-text)} - (tr "workspace.plugins.permissions.allow-localstorage")]])]) + (tr "workspace.plugins.permissions.allow-localstorage")]]) + + (cond + (contains? permissions "clipboard:write") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.clipboard-write")]] + + (contains? permissions "clipboard:read") + [:div {:class (stl/css :permissions-list-entry)} + deprecated-icon/oauth-1 + [:p {:class (stl/css :permissions-list-text)} + (tr "workspace.plugins.permissions.clipboard-read")]])]) (mf/defc plugins-permissions-dialog {::mf/register modal/components diff --git a/frontend/src/app/main/ui/workspace/plugins.scss b/frontend/src/app/main/ui/workspace/plugins.scss index 87af034d70..06d774ded8 100644 --- a/frontend/src/app/main/ui/workspace/plugins.scss +++ b/frontend/src/app/main/ui/workspace/plugins.scss @@ -7,11 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; + display: grid; grid-template-rows: auto 1fr auto; max-height: initial; @@ -41,16 +42,18 @@ } .close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .close-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } .modal-title { - @include deprecated.headlineMediumTypography; + @include deprecated.headline-medium-typography; + margin-block-end: deprecated.$s-32; color: var(--modal-title-foreground-color); display: flex; @@ -82,8 +85,9 @@ } .primary-button { - @extend .button-primary; - @include deprecated.headlineSmallTypography; + @extend %button-primary; + @include deprecated.headline-small-typography; + padding: deprecated.$s-0 deprecated.$s-16; } @@ -93,18 +97,21 @@ } .cancel-button { - @extend .button-secondary; - @include deprecated.headlineSmallTypography; + @extend %button-secondary; + @include deprecated.headline-small-typography; + padding: deprecated.$s-0 deprecated.$s-16; } .search-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-20; padding: 0 0 0 deprecated.$s-8; svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } @@ -126,8 +133,7 @@ .plugins-list { padding-top: deprecated.$s-20; - overflow-x: hidden; - overflow-y: auto; + overflow: hidden auto; flex: 1; display: flex; flex-direction: column; @@ -157,12 +163,14 @@ } .plugin-title { - @include deprecated.bodyMediumTypography; + @include deprecated.body-medium-typography; + color: var(--color-foreground-primary); } .plugin-summary { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--color-foreground-secondary); } @@ -194,7 +202,8 @@ } .plugins-empty-text { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--color-foreground-primary); } @@ -203,7 +212,8 @@ div.input-error { } .info { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin-top: deprecated.$s-4; &.error { @@ -231,9 +241,6 @@ div.input-error { } } -.plugin-permissions { -} - .permissions-list { display: flex; flex-direction: column; @@ -255,13 +262,15 @@ div.input-error { } .permissions-list-text { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin: 0; color: var(--color-foreground-secondary); } .permissions-disclaimer { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + padding: deprecated.$s-16; background: var(--color-background-quaternary); color: var(--color-foreground-primary); @@ -274,7 +283,8 @@ div.input-error { } .discover { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--color-foreground-secondary); margin-top: deprecated.$s-24; diff --git a/frontend/src/app/main/ui/workspace/presence.scss b/frontend/src/app/main/ui/workspace/presence.scss index 03f6c8134e..76f488e1f2 100644 --- a/frontend/src/app/main/ui/workspace/presence.scss +++ b/frontend/src/app/main/ui/workspace/presence.scss @@ -12,7 +12,6 @@ .active-users-opened { background: none; cursor: pointer; - display: flex; flex-direction: row-reverse; justify-content: flex-end; @@ -33,6 +32,7 @@ %user-icon { @include t.use-typography("body-small"); + display: grid; place-content: center; height: $sz-24; @@ -48,6 +48,7 @@ .users-num { @extend %user-icon; + background-color: var(--user-count-background-color); color: var(--user-count-foreground-color); z-index: 3; // FIXME: this is hardcoded because of the way its component uses z-index from cljs @@ -57,6 +58,7 @@ .session-icon { @extend %user-icon; + margin-inline-start: var(--user-list-inline-margin, calc(-1 * var(--sp-xs))); } diff --git a/frontend/src/app/main/ui/workspace/right_header.cljs b/frontend/src/app/main/ui/workspace/right_header.cljs index c73b13e4eb..c719aae349 100644 --- a/frontend/src/app/main/ui/workspace/right_header.cljs +++ b/frontend/src/app/main/ui/workspace/right_header.cljs @@ -111,10 +111,8 @@ ;; --- Header Component (mf/defc right-header* - [{:keys [file layout page-id]}] - (let [file-id (:id file) - - threads-map (mf/deref refs/comment-threads) + [{:keys [file-id layout page-id]}] + (let [threads-map (mf/deref refs/comment-threads) zoom (mf/deref refs/selected-zoom) read-only? (mf/use-ctx ctx/workspace-read-only?) diff --git a/frontend/src/app/main/ui/workspace/right_header.scss b/frontend/src/app/main/ui/workspace/right_header.scss index e6d7ea2092..50cee33d7a 100644 --- a/frontend/src/app/main/ui/workspace/right_header.scss +++ b/frontend/src/app/main/ui/workspace/right_header.scss @@ -28,7 +28,8 @@ } .zoom-widget { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: flex; align-items: center; justify-content: center; @@ -38,7 +39,8 @@ border-radius: deprecated.$br-8; .label { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + height: 100%; padding: deprecated.$s-8 0; color: var(--button-tertiary-foreground-color-rest); @@ -58,7 +60,8 @@ } .dropdown { - @extend .menu-dropdown; + @extend %menu-dropdown; + right: deprecated.$s-2; top: calc(deprecated.$s-2 + deprecated.$s-48); width: deprecated.$s-272; @@ -76,7 +79,8 @@ } .zoom-text { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; min-width: deprecated.$s-48; padding: 0; @@ -85,20 +89,21 @@ } .reset-btn { - @extend .button-tertiary; + @extend %button-tertiary; + color: var(--button-tertiary-foreground-color-hover); height: deprecated.$s-28; border-radius: deprecated.$br-8; } .zoom-option { - @extend .menu-item-base; + @extend %menu-item-base; .shortcuts { - @extend .shortcut-base; + @extend %shortcut-base; .shortcut-key { - @extend .shortcut-key-base; + @extend %shortcut-key-base; } } @@ -114,7 +119,8 @@ } .comments-btn { - @extend .button-tertiary; + @extend %button-tertiary; + border-radius: deprecated.$br-8; margin: 0; height: deprecated.$s-28; @@ -122,7 +128,8 @@ border: none; svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); height: deprecated.$s-16; width: deprecated.$s-16; @@ -143,7 +150,8 @@ } .history-button { - @extend .button-tertiary; + @extend %button-tertiary; + border-radius: deprecated.$br-8; margin: 0; height: deprecated.$s-28; @@ -151,7 +159,8 @@ border: none; svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); height: deprecated.$s-16; width: deprecated.$s-16; @@ -172,20 +181,23 @@ } .persistence-status-widget { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-28; height: deprecated.$s-28; } .status-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-24; height: deprecated.$s-24; margin: 0; border-radius: deprecated.$br-circle; svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--status-widget-icon-foreground-color); } } @@ -213,7 +225,8 @@ .share-btn, .viewer-btn { - @extend .button-tertiary; + @extend %button-tertiary; + border-radius: deprecated.$br-8; margin: 0; width: deprecated.$s-28; @@ -221,7 +234,8 @@ border: none; svg { - @extend .button-icon; + @extend %button-icon; + height: deprecated.$s-16; width: deprecated.$s-16; stroke: var(--icon-foreground); @@ -239,7 +253,7 @@ height: 8px; border: 2px solid var(--color-background-tertiary); border-radius: 50%; - background: red; + background: var(--color-foreground-error); top: 6px; right: 6px; } diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss index d31fa44ef0..21a3db0497 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss +++ b/frontend/src/app/main/ui/workspace/shapes/text/v2_editor.scss @@ -8,7 +8,6 @@ .text-editor-container { height: 100%; position: relative; - cursor: text; } @@ -18,21 +17,17 @@ .text-editor-content { height: 100%; - font-family: sourcesanspro; - + font-family: "sourcesanspro", sans-serif; outline: none; user-select: text; white-space: pre-wrap; overflow-wrap: break-word; - caret-color: var(--text-editor-caret-color); - color: transparent; // Match Skia's text layout precision: prevent browser text-size // adjustments and ensure consistent kerning across browsers. text-size-adjust: none; - -webkit-text-size-adjust: none; font-kerning: normal; &::selection, @@ -41,16 +36,16 @@ -webkit-text-fill-color: transparent; // WebKit/Safari } - &::-moz-selection, - *::-moz-selection { + &::selection, + *::selection { color: transparent; } [data-itype="paragraph"] { line-height: inherit; user-select: text; - margin: 0px; - font-size: 0px; + margin: 0; + font-size: 0; } // Text spans emitted by @penpot/text-editor use `data-itype="span"`. @@ -61,11 +56,9 @@ display: inline; line-height: inherit; caret-color: var(--text-editor-caret-color); - white-space-collapse: pre; word-break: normal; overflow-wrap: break-word; tab-size: 2; - -o-tab-size: 2; } [data-itype="root"] { diff --git a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss index 8539a7ca29..5659b0646e 100644 --- a/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss +++ b/frontend/src/app/main/ui/workspace/shapes/text/v3_editor.scss @@ -6,7 +6,6 @@ width: 100%; height: 100%; position: absolute; - opacity: 0; overflow: hidden; white-space: pre; diff --git a/frontend/src/app/main/ui/workspace/sidebar.cljs b/frontend/src/app/main/ui/workspace/sidebar.cljs index ac219faa2f..b1bfe74db9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar.cljs @@ -42,6 +42,7 @@ [app.main.ui.workspace.sidebar.versions :refer [versions-toolbox*]] [app.main.ui.workspace.tokens.sidebar :refer [tokens-sidebar-tab*]] [app.util.debug :as dbg] + [app.util.dom :as dom] [app.util.i18n :refer [tr]] [potok.v2.core :as ptk] [rumext.v2 :as mf])) @@ -183,6 +184,7 @@ :data-testid "left-sidebar" :data-width (str width) :class aside-class + :on-context-menu dom/prevent-default-context-menu :style {:--left-sidebar-width (dm/str width "px")}} [:> left-header* {:file file @@ -280,7 +282,7 @@ [:> history-toolbox*]])])) (mf/defc right-sidebar* - [{:keys [layout section file page-id drawing-tool active-tokens] :as props}] + [{:keys [layout section file-id page-id drawing-tool active-tokens] :as props}] (let [is-comments? (= drawing-tool :comments) is-history? (contains? layout :document-history) is-inspect? (= section :inspect) @@ -329,6 +331,7 @@ :id "right-sidebar-aside" :data-testid "right-sidebar" :data-size (str width) + :on-context-menu dom/prevent-default-context-menu :style {:--right-sidebar-width (if can-be-expanded? (dm/str width "px") (dm/str right-sidebar-default-width "px"))}} @@ -340,7 +343,7 @@ :on-pointer-move on-pointer-move}]) [:> right-header* - {:file file + {:file-id file-id :layout layout :page-id page-id}] @@ -372,14 +375,26 @@ (ctob/get-tokens-in-active-sets tokens-lib) {})) + selected-token-set-id + (mf/deref refs/selected-token-set-id) + + active-tokens-force-set + (mf/with-memo [tokens-lib selected-token-set-id] + (if (and tokens-lib selected-token-set-id) + (ctob/get-tokens-in-active-sets-force tokens-lib selected-token-set-id) + {})) + tokenscript? (contains? cf/flags :tokenscript) - tokenscript-resolved-active-tokens - (mf/with-memo [tokens-lib tokenscript?] - (when tokenscript? (ts/resolve-tokens active-tokens))) - resolved-active-tokens - (sd/use-resolved-tokens* active-tokens)] + (sd/use-resolved-tokens* active-tokens) + + tokenscript-resolved-active-tokens-force-set + (mf/with-memo [active-tokens-force-set tokenscript?] + (when tokenscript? (ts/resolve-tokens active-tokens-force-set))) + + resolved-active-tokens-force-set + (sd/use-resolved-tokens* active-tokens-force-set)] [:* (if (:collapse-left-sidebar layout) @@ -388,10 +403,10 @@ :file file :page-id page-id :tokens-lib tokens-lib - :active-tokens active-tokens - :resolved-active-tokens (if (contains? cf/flags :tokenscript) - tokenscript-resolved-active-tokens - resolved-active-tokens)}]) + :active-tokens active-tokens-force-set + :resolved-active-tokens (if tokenscript? + tokenscript-resolved-active-tokens-force-set + resolved-active-tokens-force-set)}]) [:> right-sidebar* {:section section :selected selected :drawing-tool drawing-tool diff --git a/frontend/src/app/main/ui/workspace/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar.scss index 3c5360b4f4..5747dbcd19 100644 --- a/frontend/src/app/main/ui/workspace/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar.scss @@ -9,11 +9,7 @@ .left-settings-bar { display: grid; - grid-template-areas: - "header header" - "content resize"; - grid-template-rows: deprecated.$s-52 1fr; - grid-template-columns: 1fr 0; + grid-template: "header header" deprecated.$s-52 "content resize" 1fr / 1fr 0; position: relative; grid-area: left-sidebar; min-width: var(--left-sidebar-width); @@ -65,9 +61,11 @@ width: var(--right-sidebar-width); background-color: var(--panel-background-color); z-index: deprecated.$z-index-1; + &.not-expand { max-width: var(--right-sidebar-width); } + &.expanded { width: var(--right-sidebar-width, var(--right-sidebar-width)); } @@ -76,7 +74,6 @@ display: grid; grid-template-columns: 100%; grid-template-rows: 100%; - height: calc(100vh - deprecated.$s-52); overflow: hidden; } @@ -104,20 +101,24 @@ .collapse-sidebar-button { --collapse-icon-color: var(--color-foreground-secondary); - @include deprecated.flexCenter; - @include deprecated.buttonStyle; + + @include deprecated.flex-center; + @include deprecated.button-style; + height: 100%; width: deprecated.$s-24; border-radius: deprecated.$br-5; color: var(--collapse-icon-color); transform: rotate(180deg); + &:hover { --collapse-icon-color: var(--color-foreground-primary); } } .collapsed-sidebar { - @include deprecated.flexCenter; + @include deprecated.flex-center; + position: absolute; top: deprecated.$s-48; left: 0; @@ -126,27 +127,34 @@ background: var(--color-background-primary); margin-inline-start: var(--sp-m); } + .collapsed-title { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-36; width: deprecated.$s-24; border-radius: deprecated.$br-8; background: var(--color-background-secondary); } + .collapsed-button { - @include deprecated.buttonStyle; + @include deprecated.button-style; + height: deprecated.$s-24; width: deprecated.$s-16; padding: 0; border-radius: deprecated.$br-5; + svg { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-16; width: deprecated.$s-16; color: transparent; fill: none; stroke: var(--icon-foreground); } + &:hover { svg { stroke: var(--icon-foreground-hover); diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs index f4082ac55b..72a709bfb5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.cljs @@ -69,16 +69,24 @@ [v a b] (if (= v a) b a)) +;; Per-file, session-scoped (in-memory only) so the search term and section +;; filter survive switching between the Layers and Assets sidebar tabs without +;; leaking across files or persisting across reloads. +(defonce ^:private session-filters* + (atom {})) + (mf/defc assets-toolbox* {::mf/wrap [mf/memo]} [{:keys [size file-id]}] (let [read-only? (mf/use-ctx ctx/workspace-read-only?) filters* (mf/use-state - {:term "" - :section "all" - :ordering (dwa/get-current-assets-ordering) - :list-style (dwa/get-current-assets-list-style) - :open-menu false}) + (fn [] + (-> (or (get @session-filters* file-id) + {:term "" + :section "all"}) + (assoc :ordering (dwa/get-current-assets-ordering) + :list-style (dwa/get-current-assets-list-style) + :open-menu false)))) filters (deref filters*) term (:term filters) list-style (:list-style filters) @@ -161,6 +169,9 @@ :id "typographies" :handler on-section-filter-change}])] + (mf/with-effect [file-id term section] + (swap! session-filters* assoc file-id {:term term :section section})) + [:article {:class (stl/css :assets-bar)} [:div {:class (stl/css :assets-header)} (when-not ^boolean read-only? diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets.scss b/frontend/src/app/main/ui/workspace/sidebar/assets.scss index f89069cca5..d04fac7aa2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets.scss @@ -8,8 +8,8 @@ .assets-bar { display: grid; - height: 100%; grid-auto-rows: max-content; + // TODO: ugly hack :( Fix this! we shouldn't be hardcoding this height height: calc(100vh - deprecated.$s-92); scrollbar-gutter: stable; @@ -18,8 +18,9 @@ } .libraries-button { - @extend .button-secondary; - @include deprecated.uppercaseTitleTipography; + @extend %button-secondary; + @include deprecated.uppercase-title-typography; + gap: deprecated.$s-2; height: deprecated.$s-32; width: 100%; @@ -40,8 +41,9 @@ } .add-library-button { - @extend .button-primary; - @include deprecated.uppercaseTitleTipography; + @extend %button-primary; + @include deprecated.uppercase-title-typography; + gap: deprecated.$s-2; height: deprecated.$s-32; width: 100%; @@ -50,8 +52,9 @@ } .section-button { - @include deprecated.flexCenter; - @include deprecated.buttonStyle; + @include deprecated.flex-center; + @include deprecated.button-style; + height: deprecated.$s-32; width: deprecated.$s-32; margin: 0; @@ -98,13 +101,14 @@ } &.opened { - @extend .button-icon-selected; + @extend %button-icon-selected; } } .sections-container { - @include deprecated.menuShadow; - @include deprecated.flexColumn; + @include deprecated.menu-shadow; + @include deprecated.flex-column; + position: absolute; top: deprecated.$s-84; left: deprecated.$s-12; @@ -116,7 +120,8 @@ } .section-item { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; justify-content: space-between; @@ -126,7 +131,7 @@ } .section-btn { - @include deprecated.buttonStyle; + @include deprecated.button-style; } .assets-header { diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs index b2dc3e8e5b..b4e94bb92d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.cljs @@ -9,6 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.math :as mth] [app.common.path-names :as cpn] [app.config :as cf] [app.main.constants :refer [max-input-length]] @@ -28,7 +29,7 @@ [app.main.ui.workspace.sidebar.assets.groups :as grp] [app.util.color :as uc] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [app.util.keyboard :as kbd] [cuerdas.core :as str] [okulary.core :as l] @@ -61,10 +62,16 @@ menu-state (mf/use-state cmm/initial-context-menu-state) read-only? (mf/use-ctx ctx/workspace-read-only?) + opacity (:opacity color) + alpha-suffix (when (and (number? opacity) (< opacity 1)) + (dm/str " " (mth/round (* opacity 100)) "%")) default-name (cond (:gradient color) (uc/gradient-type->string (dm/get-in color [:gradient :type])) (:color color) (:color color) :else (:value color)) + display-name (if (and alpha-suffix (not (:gradient color))) + (dm/str default-name alpha-suffix) + default-name) rename-color (mf/use-fn @@ -231,16 +238,16 @@ :default-value (cpn/merge-path-item (:path color) (:name color))}] [:div {:title (if (= (:name color) default-name) - default-name - (dm/str (:name color) " (" default-name ")")) + display-name + (dm/str (:name color) " (" display-name ")")) :class (stl/css :name-block) :on-double-click rename-color-clicked} (if (= (:name color) default-name) - [:span {:class (stl/css :default-name)} default-name] + [:span {:class (stl/css :default-name)} display-name] [:* (:name color) - [:span {:class (stl/css :default-name :default-name-with-color)} default-name]])]) + [:span {:class (stl/css :default-name :default-name-with-color)} display-name]])]) (when local? [:> cmm/assets-context-menu* @@ -273,7 +280,7 @@ (mf/defc colors-group [{:keys [file-id prefix groups open-groups force-open? local? selected multi-colors? multi-assets? on-asset-click on-assets-delete - on-clear-selection on-group on-rename-group on-ungroup colors + on-clear-selection on-group on-rename-group on-ungroup on-delete-group colors selected-full]}] (let [group-open? (if (false? (get open-groups prefix)) ;; if the user has closed it specifically, respect that false @@ -318,7 +325,8 @@ :path prefix :is-group-open group-open? :on-rename on-rename-group - :on-ungroup on-ungroup}] + :on-ungroup on-ungroup + :on-delete-group on-delete-group}] (when group-open? [:* (let [colors (get groups "" [])] @@ -371,6 +379,7 @@ :on-group on-group :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :colors colors :selected-full selected-full}]))])])) @@ -492,6 +501,13 @@ file-id)))) (st/emit! (dwu/commit-undo-transaction undo-id))))) + on-delete-group + (mf/with-memo [colors on-clear-selection] + (cmm/make-delete-asset-group-fn + {:assets colors + :on-clear-selection on-clear-selection + :delete-events #(map (fn [c] (dwl/delete-color {:id (:id c)})) %)})) + on-asset-click (mf/use-fn (mf/deps groups on-asset-click) (partial on-asset-click groups))] @@ -526,5 +542,6 @@ :on-group on-group :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :colors colors :selected-full selected-full}]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss index e2c86936cb..aaa1b09f37 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/colors.scss @@ -38,30 +38,35 @@ $assets-button-width: deprecated.$s-28; &.editing { border: deprecated.$s-1 solid var(--input-border-color-focus); + input.element-name { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - @include deprecated.removeInputStyle; + @include deprecated.text-ellipsis; + @include deprecated.body-small-typography; + @include deprecated.remove-input-style; + flex-grow: 1; margin: 0; color: var(--layer-row-foreground-color); } } + &:hover { background-color: var(--assets-item-background-color-hover); } } .bullet-block { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; justify-content: flex-start; margin-inline-end: deprecated.$s-4; } .name-block { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; + @include deprecated.body-small-typography; + @include deprecated.text-ellipsis; + margin: 0; color: var(--assets-item-name-foreground-color); } @@ -76,7 +81,8 @@ $assets-button-width: deprecated.$s-28; } .element-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + color: var(--color-foreground-primary); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs index 4386069eb2..28eb7e4699 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.cljs @@ -174,7 +174,6 @@ [:> title-bar* {:collapsable (< 0 assets-count) :collapsed (not is-open) - :all-clickable true :on-collapsed on-collapsed :add-icon-gap (= 0 assets-count) :title title} @@ -199,6 +198,39 @@ (run! st/emit!)) (st/emit! (dwu/commit-undo-transaction undo-id)))) +(defn make-delete-asset-group-fn + "Build an `:on-delete-group` handler that filters `assets` by group + path, asks the user to confirm, and on accept emits every event + produced by `delete-events` inside one undo transaction. + + Options: + - `:assets` collection to filter. + - `:on-clear-selection` invoked before the deletes. + - `:delete-events` `(fn [matching-assets] => seq-of-events)`. + - `:path-filter` `(fn [asset-path group-path] => bool)` deciding + which assets fall under the group. Defaults to + `str/starts-with?`." + [{:keys [assets on-clear-selection delete-events path-filter] + :or {path-filter str/starts-with?}}] + (fn [path] + (let [matching (filter #(path-filter (:path %) path) assets) + undo-id (js/Symbol) + do-delete + (fn [] + (on-clear-selection) + (st/emit! (dwu/start-undo-transaction undo-id)) + (run! st/emit! (delete-events matching)) + (st/emit! (dwu/commit-undo-transaction undo-id)))] + (when (seq matching) + (st/emit! + (modal/show + {:type :confirm + :title (tr "modals.delete-asset-group.title") + :message (tr "modals.delete-asset-group.message" + (c (count matching))) + :accept-label (tr "labels.delete") + :on-accept do-delete})))))) + (defn on-drop-asset [event asset dragging* selected selected-full selected-paths rename] (let [create-typed-assets-group (partial create-assets-group rename)] diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss index 257bd24b3c..2188db46a8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/common.scss @@ -7,7 +7,8 @@ @use "refactor/common-refactor.scss" as deprecated; .title-name { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + display: flex; align-items: center; flex-grow: 1; @@ -15,7 +16,8 @@ } .title-tokens { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + text-transform: capitalize; } @@ -24,14 +26,17 @@ } .section-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + padding-right: deprecated.$s-2; + svg { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-16; width: deprecated.$s-16; fill: none; - stroke: currentColor; + stroke: currentcolor; } } @@ -42,7 +47,8 @@ } .num-assets { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; padding-left: deprecated.$s-8; } @@ -56,8 +62,9 @@ } .drag-counter { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; + @include deprecated.body-small-typography; + @include deprecated.text-ellipsis; + position: absolute; bottom: 0; left: 0; diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs index 077742d71d..e2f0637c02 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.cljs @@ -17,6 +17,7 @@ [app.main.data.workspace :as dw] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.media :as dwm] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.undo :as dwu] [app.main.data.workspace.variants :as dwv] [app.main.refs :as refs] @@ -32,7 +33,7 @@ [app.main.ui.workspace.sidebar.assets.groups :as grp] [app.util.dom :as dom] [app.util.dom.dnd :as dnd] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [okulary.core :as l] [potok.v2.core :as ptk] @@ -191,7 +192,7 @@ (mf/defc components-group* [{:keys [file-id prefix groups open-groups is-force-open renaming is-listing-thumbs selected on-asset-click - on-drag-start do-rename cancel-rename on-rename-group on-group on-ungroup on-context-menu + on-drag-start do-rename cancel-rename on-rename-group on-group on-ungroup on-delete-group on-context-menu selected-full is-local count-variants on-group-combine-variants]}] (let [group-open? (if (false? (get open-groups prefix)) ;; if the user has closed it specifically, respect that @@ -246,6 +247,7 @@ :is-can-combine can-combine? :on-rename on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-group-combine-variants on-group-combine-variants}] (when group-open? @@ -303,6 +305,7 @@ :cancel-rename cancel-rename :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-context-menu on-context-menu :on-group-combine-variants on-group-combine-variants :selected-full selected-full @@ -493,6 +496,33 @@ (map #(dwv/rename-comp-or-variant-and-main (:id %) (cmm/ungroup % path))))) (st/emit! (dwu/commit-undo-transaction undo-id))))) + on-delete-group + (mf/with-memo [components on-clear-selection] + (cmm/make-delete-asset-group-fn + {:assets components + :on-clear-selection on-clear-selection + :path-filter cpn/inside-path? + ;; Variants are handled via their variant container + ;; (matching the per-item delete dispatch in + ;; file_library.cljs); sibling variants sharing a + ;; container are deduplicated so we delete each container + ;; only once. + :delete-events + (fn [matching] + (let [{variants true non-variants false} + (group-by (comp boolean ctc/is-variant?) matching) + + variant-containers + (->> variants + (group-by :variant-id) + (map (fn [[_ comps]] (first comps))))] + (concat + (map #(dwsh/delete-shapes (:main-instance-page %) + #{(:variant-id %)}) + variant-containers) + (map #(dwl/delete-component {:id (:id %)}) + non-variants))))})) + on-group-combine-variants (mf/use-fn (mf/deps components on-clear-selection) @@ -602,6 +632,7 @@ :on-rename-group on-rename-group :on-group on-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-group-combine-variants on-group-combine-variants :on-context-menu on-context-menu :selected-full selected-full diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss index ec23a9d1f4..fa7242d60a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/components.scss @@ -20,6 +20,7 @@ border-radius: $br-8; background-color: var(--color-canvas); overflow: hidden; + &:hover { .component-item-grid-name { display: flex; @@ -32,10 +33,7 @@ &::before { content: " "; position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + inset: 0; border: calc($b-2 * 2) solid var(--color-background-primary); border-radius: $br-8; } @@ -50,7 +48,6 @@ left: var(--sp-xs); right: var(--sp-xs); bottom: var(--sp-xs); - padding: var(--sp-xxs) var(--sp-s); border-radius: $br-4; background-color: var(--color-background-primary); color: var(--color-foreground-primary); diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs index 2fc62e001d..0cb88cd0df 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.cljs @@ -101,7 +101,6 @@ :open is-open)} [:> title-bar* {:collapsable true :collapsed (not is-open) - :all-clickable true :on-collapsed toggle-open :title (if is-local (mf/html [:div {:class (stl/css :special-title)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss index 18de784336..e8c63bb18f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/file_library.scss @@ -6,6 +6,7 @@ @use "ds/typography.scss" as t; @use "refactor/common-refactor.scss" as deprecated; + .tool-window { padding: 0 0 deprecated.$s-24 deprecated.$s-12; display: grid; @@ -16,6 +17,7 @@ .file-name { @include t.use-typography("body-small"); + display: flex; justify-content: flex-start; align-items: center; @@ -25,6 +27,7 @@ .loading { @include t.use-typography("body-small"); + display: flex; align-items: center; justify-content: flex-start; @@ -34,19 +37,23 @@ } .special-title { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + color: var(--title-foreground-color-hover); margin-left: deprecated.$s-2; text-align: left; } .file-link { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; border-radius: deprecated.$br-8; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); fill: var(--title-foreground-color-hover); } @@ -68,13 +75,16 @@ } .no-found-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + background-color: var(--not-found-background-color); border-radius: deprecated.$br-circle; height: deprecated.$s-48; width: deprecated.$s-48; + svg { - @extend .button-icon; + @extend %button-icon; + height: deprecated.$s-24; width: deprecated.$s-24; stroke: var(--not-found-foreground-color); @@ -82,6 +92,7 @@ } .no-found-text { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--not-found-foreground-color); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs index 72a1be6996..c7e72c871b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.cljs @@ -23,7 +23,7 @@ [rumext.v2 :as mf])) (mf/defc asset-group-title* - [{:keys [file-id section path is-group-open on-rename on-ungroup on-group-combine-variants is-can-combine]}] + [{:keys [file-id section path is-group-open on-rename on-ungroup on-delete-group on-group-combine-variants is-can-combine on-add]}] (when-not (empty? path) (let [[other-path last-path truncated] (cpn/compact-path path 35 true) menu-state (mf/use-state cmm/initial-context-menu-state) @@ -51,7 +51,6 @@ :on-context-menu on-context-menu} [:> title-bar* {:collapsable true :collapsed (not is-group-open) - :all-clickable true :on-collapsed on-fold-group :title (mf/html [:* (when-not (empty? other-path) [:span {:class (stl/css :pre-path) @@ -70,6 +69,12 @@ {:name (tr "workspace.assets.ungroup") :id "assets-ungroup-group" :handler #(on-ungroup path)}] + on-delete-group + (conj + {:name (tr "workspace.assets.delete-group") + :id "assets-delete-group" + :handler #(on-delete-group path)}) + is-can-combine (conj {:name (tr "workspace.shape.menu.combine-as-variants") @@ -77,11 +82,27 @@ :handler #(on-group-combine-variants path)}))}]] [:div {:class (stl/css :title-menu)} + (when on-add + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.assets.typography.add-typography") + :on-click on-add + :icon i/add}]) [:> icon-button* {:variant "ghost" :aria-label (tr "workspace.assets.component-group-options") :on-click on-context-menu :icon i/menu}]]]))) +(defn- sort-groups + "Recursively sort subgroup keys alphabetically at every nesting level." + [groups reverse-sort?] + (let [cmp (if reverse-sort? #(compare %2 %1) compare) + sort-tree (fn sort-tree [m] + (into (sorted-map-by cmp) + (map (fn [[k v]] + [k (if (map? v) (sort-tree v) v)])) + m))] + (sort-tree groups))) + (defn group-assets "Convert a list of assets in a nested structure like this: @@ -93,19 +114,17 @@ " [assets reverse-sort?] (when-not (empty? assets) - (reduce (fn [groups {:keys [path] :as asset}] - (let [path (cpn/split-path (or path ""))] - (update-in groups - (conj path "") - (fn [group] - (if group - (conj group asset) - [asset]))))) - (sorted-map-by (fn [key1 key2] - (if reverse-sort? - (compare key2 key1) - (compare key1 key2)))) - assets))) + (-> (reduce (fn [groups {:keys [path] :as asset}] + (let [path (cpn/split-path (or path ""))] + (update-in groups + (conj path "") + (fn [group] + (if group + (conj group asset) + [asset]))))) + {} + assets) + (sort-groups reverse-sort?)))) (def ^:private schema:group-form [:map {:title "GroupForm"} diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss index 1237c53323..0152e5e52c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/groups.scss @@ -25,6 +25,7 @@ } .title-menu { + display: flex; visibility: hidden; } @@ -39,18 +40,19 @@ } .path { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + margin-left: deprecated.$s-2; text-transform: initial; color: var(--title-foreground-color-hover); } .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-container { - @extend .modal-container-base; + @extend %modal-container-base; } .modal-header { @@ -58,37 +60,40 @@ } .modal-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + color: var(--modal-title-foreground-color); } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .modal-content { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin-bottom: deprecated.$s-24; } .input-wrapper { - @extend .input-with-label; - @include deprecated.bodySmallTypography; + @extend %input-with-label; + @include deprecated.body-small-typography; + margin-bottom: deprecated.$s-8; } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; } .cancel-button { - @extend .modal-cancel-btn; + @extend %modal-cancel-btn; } .accept-btn { - @extend .modal-accept-btn; + @extend %modal-accept-btn; &.danger { - @extend .modal-danger-btn; + @extend %modal-danger-btn; } } diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs index d222ffafd0..174648e8ca 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.cljs @@ -26,7 +26,7 @@ [app.main.ui.workspace.sidebar.assets.groups :as grp] [app.main.ui.workspace.sidebar.options.menus.typography :refer [typography-entry]] [app.util.dom :as dom] - [app.util.i18n :as i18n :refer [tr]] + [app.util.i18n :refer [tr]] [cuerdas.core :as str] [okulary.core :as l] [potok.v2.core :as ptk] @@ -125,7 +125,8 @@ :editing? editing? :renaming? renaming? :focus-name? rename? - :external-open* open*}] + :external-open* open* + :is-asset? true}] (when ^boolean dragging? [:div {:class (stl/css :dragging)}])])) @@ -133,7 +134,7 @@ {::mf/wrap-props false} [{:keys [file-id prefix groups open-groups force-open? file local? selected local-data editing-id renaming-id on-asset-click handle-change on-rename-group - on-ungroup on-context-menu selected-full]}] + on-ungroup on-delete-group on-context-menu selected-full is-read-only]}] (let [group-open? (if (false? (get open-groups prefix)) ;; if the user has closed it specifically, respect that false (get open-groups prefix true)) @@ -164,7 +165,14 @@ (mf/use-fn (mf/deps dragging* prefix selected-paths selected-full move-typography) (fn [event] - (cmm/on-drop-asset-group event dragging* prefix selected-paths selected-full move-typography)))] + (cmm/on-drop-asset-group event dragging* prefix selected-paths selected-full move-typography))) + + add-typography-to-group + (mf/use-fn + (mf/deps file-id prefix) + (fn [_] + (st/emit! (dw/set-assets-section-open file-id :typographies true) + (dwt/add-typography file-id prefix))))] [:div {:class (stl/css :typographies-group) :on-drag-enter on-drag-enter @@ -176,7 +184,10 @@ :path prefix :is-group-open group-open? :on-rename on-rename-group - :on-ungroup on-ungroup}] + :on-ungroup on-ungroup + :on-delete-group on-delete-group + :on-add (when (and local? (not is-read-only)) + add-typography-to-group)}] (when group-open? [:* @@ -228,8 +239,10 @@ :handle-change handle-change :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-context-menu on-context-menu - :selected-full selected-full}]))])])) + :selected-full selected-full + :is-read-only is-read-only}]))])])) (mf/defc typographies-section* [{:keys [file file-id typographies open-status-ref selected @@ -341,6 +354,13 @@ (cmm/ungroup % path))))) (st/emit! (dwu/commit-undo-transaction undo-id))))) + on-delete-group + (mf/with-memo [typographies on-clear-selection] + (cmm/make-delete-asset-group-fn + {:assets typographies + :on-clear-selection on-clear-selection + :delete-events #(map (fn [t] (dwl/delete-typography (:id t))) %)})) + on-context-menu (mf/use-fn (mf/deps selected on-clear-selection read-only?) @@ -430,8 +450,10 @@ :handle-change handle-change :on-rename-group on-rename-group :on-ungroup on-ungroup + :on-delete-group on-delete-group :on-context-menu on-context-menu - :selected-full selected-full}] + :selected-full selected-full + :is-read-only read-only?}] (if is-local [:> cmm/assets-context-menu* diff --git a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss index d00eeb20f9..d44f702823 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/assets/typographies.scss @@ -27,6 +27,7 @@ margin-bottom: deprecated.$s-4; border-radius: deprecated.$br-8; background-color: var(--assets-item-background-color); + max-inline-size: var(--options-width); } .dragging { diff --git a/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss b/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss index b5a372a431..5627355c0c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/common/sidebar.scss @@ -43,10 +43,15 @@ $column-number: 8; // total number of columns // -> 8 columns (32px each) + 7 gaps (4px each) = 284px // Derived widths -$options-width: calc(#{$column-width} * #{$column-number} + #{$column-gap} * calc(#{$column-number} - 1)); -$seven-column-width: calc( - #{$column-width} * calc(#{$column-number} - 1) + #{$column-gap} * calc(#{$column-number} - 2) -); +@function grid-width($cols) { + @return calc(#{$column-width} * #{$cols} + #{$column-gap} * #{$cols - 1}); +} + +$options-width: grid-width($column-number); +$two-column-width: grid-width(2); +$three-column-width: grid-width(3); +$four-column-width: grid-width(4); +$seven-column-width: grid-width(7); // ------------------------------------------------------------ // Grid mixin — applies the standard structure to any container @@ -73,17 +78,30 @@ $seven-column-width: calc( // |___|-|___|-|___|-|___|-|___|-|___|-|___|-|___| // -> 8 columns (32px each) + 7 gaps (4px each) = 284px // -// But one block (grid-exception-input) doesn’t fit perfectly: +// But two blocks don’t fit perfectly: +// First (grid-exception-input-width) // |__________________|-|__________________|-|___| -// -// We calculate the width of that grid-exception-input as: +// We calculate the width of that grid-exception-input-width as: // // - 3.5 columns of base grid width // - + 3 inter-column gaps // - − half a gap (because it’s visually shared with the next block) - $grid-exception-input-width: calc(#{$sz-32} * 3.5 + 3 * var(--sp-xs) - (var(--sp-xs) / 2)); +// +// |___|-|___|-|___|-|___|-|___|-|___|-|___|-|___| +// +// Second (grid-exception-input-width-small) +// |__________________|-|____________|-|___|-|___| +// +// We calculate the width of that grid-exception-input-width-small as: +// +// - 2.5 columns of base grid width +// - + 2 inter-column gaps +// - − half a gap (because it’s visually shared with the next block) + +$grid-exception-input-width-small: calc(#{$sz-32} * 2.5 + 2 * var(--sp-xs) - (var(--sp-xs) / 2)); + // ============================================================ // CSS VARIABLES (exposed for runtime use) // ============================================================ @@ -95,7 +113,11 @@ $grid-exception-input-width: calc(#{$sz-32} * 3.5 + 3 * var(--sp-xs) - (var(--sp --left-sidebar-width-max: #{$left-sidebar-width-max}; --right-sidebar-width: #{$right-sidebar-width}; --right-sidebar-width-max: #{$right-sidebar-width-max}; - --7-columns-dropdown-width: #{$seven-column-width}; + --two-columns-width: #{$two-column-width}; + --three-columns-width: #{$three-column-width}; + --four-columns-width: #{$four-column-width}; + --seven-columns-width: #{$seven-column-width}; --options-width: #{$options-width}; --grid-exception-input-width: #{$grid-exception-input-width}; + --grid-exception-input-width-small: #{$grid-exception-input-width-small}; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug.scss b/frontend/src/app/main/ui/workspace/sidebar/debug.scss index 47f119009c..81a7921fe5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug.scss @@ -21,12 +21,14 @@ } .checkbox-wrapper { - @extend .input-checkbox; + @extend %input-checkbox; + height: deprecated.$s-32; padding: 0; } .checkbox-icon { - @extend .checkbox-icon; + @extend %checkbox-icon; + cursor: pointer; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss index a72bf3a833..1f67e505bc 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/debug_shape_info.scss @@ -26,7 +26,6 @@ .shape-title { font-size: deprecated.$fs-14; - padding-bottom: deprecated.$s-4; background: var(--color-background-quaternary); color: var(--color-foreground-primary); padding: deprecated.$s-8; @@ -34,6 +33,7 @@ display: flex; gap: deprecated.$s-4; } + .shape-name { flex: 1; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/history.scss b/frontend/src/app/main/ui/workspace/sidebar/history.scss index 907e6732bf..069d1d5d73 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/history.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/history.scss @@ -24,8 +24,7 @@ .history-entries { height: calc(100vh - deprecated.$s-100); padding: deprecated.$s-12; - overflow-x: hidden; - overflow-y: auto; + overflow: hidden auto; font-size: deprecated.$fs-12; } @@ -45,26 +44,33 @@ .history-entry-summary { display: flex; align-items: center; + .history-entry-summary-icon { svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--entry-foreground-color); } } + .history-entry-summary-text { margin: 0 deprecated.$s-8; color: var(--color-foreground-primary); } + .history-entry-summary-button { opacity: deprecated.$op-0; margin-left: auto; + &.button-opened { svg { transform: rotate(90deg); } } + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--entry-foreground-color); } } @@ -74,6 +80,7 @@ display: block; padding-top: deprecated.$s-16; color: var(--modal-text-foreground-color); + .history-entry-details-list { margin: 0; } @@ -88,14 +95,17 @@ &:hover { background-color: var(--entry-background-color-hover); color: var(--entry-foreground-color-hover); + .history-entry-summary { .history-entry-summary-icon { svg { stroke: var(--entry-foreground-color-hover); } } + .history-entry-summary-button { opacity: deprecated.$op-10; + &.button-opened { svg { transform: rotate(90deg); diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs index b17698d659..d31351adc8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.cljs @@ -275,11 +275,19 @@ toggle-collapse (mf/use-fn - (mf/deps is-expanded) + (mf/deps is-expanded id objects) (fn [event] (dom/stop-propagation event) - (if (and is-expanded (kbd/shift? event)) + (cond + ;; Shift+click while expanded collapses every layer in the sidebar + (and is-expanded (kbd/shift? event)) (st/emit! (dwc/collapse-all)) + + ;; Alt+click while collapsed expands the entire subtree rooted at this id + (and (not is-expanded) (kbd/alt? event)) + (st/emit! (dwc/expand-subtree id objects)) + + :else (st/emit! (dwc/toggle-collapse id))))) toggle-blocking diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss index 0fc369362d..f66d7b62fe 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_item.scss @@ -13,8 +13,8 @@ --layer-background-color: var(--color-background-primary); --layer-foreground-color: inherit; --shadow-color: transparent; - box-shadow: px2rem(16) px2rem(0) px2rem(0) px2rem(0) var(--shadow-color); + box-shadow: px2rem(16) px2rem(0) px2rem(0) px2rem(0) var(--shadow-color); display: flex; flex-direction: row; align-items: center; @@ -30,6 +30,7 @@ --context-hover-color: var(--layer-row-foreground-color-hover); --context-hover-opacity: 1; --layer-foreground-color: var(--layer-row-foreground-color-hover); + &.hidden { opacity: 1; } @@ -59,9 +60,11 @@ &.dnd-over-bot { border-block-end: $b-2 solid var(--color-accent-primary); } + &.dnd-over-top { border-block-start: $b-2 solid var(--color-accent-primary); } + &.dnd-over { border: $b-2 solid var(--color-accent-primary); } @@ -73,9 +76,11 @@ --layer-background-color: var(--color-background-quaternary); --shadow-color: var(--color-background-quaternary); } + .layer-row.type-comp & { --layer-foreground-color: var(--color-accent-secondary); } + .layer-row.selected & { --layer-background-color: transparent; --layer-foreground-color: var(--color-accent-primary); @@ -91,6 +96,7 @@ inline-size: calc(100% - (var(--depth) * var(--layer-indentation-size))); cursor: pointer; min-inline-size: px2rem(140); + &.filtered { inline-size: calc(100% - $sz-12); } @@ -106,6 +112,7 @@ &.selected { display: flex; } + .layer-row.highlight &, .layer-row:hover & { display: flex; @@ -130,21 +137,27 @@ inline-size: $sz-24; padding-inline-start: var(--sp-xs); color: var(--color-foreground-secondary); + .layer-row.selected & { color: var(--color-accent-primary); } + .layer-row.type-comp & { color: var(--color-accent-secondary); } + .inverse & { transform: rotate(-90deg); } + .layer-row.hidden & { opacity: 0.7; } + .layer-row.highlight &, .layer-row:hover & { opacity: 1; + svg { stroke: var(--color-accent-primary); } @@ -162,14 +175,17 @@ .layer-row.hidden & { opacity: 0.1; } + .layer-row.type-comp & { background-color: var(--color-accent-secondary); } + .layer-row.highlight &, .layer-row:hover & { opacity: 0.4; background-color: var(--color-accent-primary); } + .layer-row.selected & { background-color: var(--color-accent-primary); } @@ -200,12 +216,15 @@ .layer-row.hidden & { opacity: 0.7; } + .layer-row.selected & { stroke: var(--color-accent-primary); } + .layer-row.type-comp & { stroke: var(--color-accent-secondary); } + .layer-row.highlight &, .layer-row:hover & { opacity: 1; @@ -216,6 +235,7 @@ .layer-row.selected & { background-color: var(--color-background-quaternary); } + &.inverse svg { transform: rotate(90deg); } @@ -224,9 +244,9 @@ .toggle-element, .block-element { --layer-row-action-btn-background: none; + border: none; cursor: pointer; - display: flex; justify-content: center; align-items: center; block-size: 100%; @@ -235,6 +255,7 @@ display: none; background: var(--layer-row-action-btn-background); padding-inline-end: px2rem(6); + svg { display: flex; justify-content: center; @@ -249,6 +270,7 @@ .layer-row.hidden & { opacity: 0.7; } + .type-comp & { stroke: var(--color-accent-secondary); } @@ -257,6 +279,7 @@ .element-actions.selected & { display: flex; opacity: 0; + &.selected { opacity: 1; } @@ -269,15 +292,20 @@ .layer-row.highlight &, .layer-row:hover & { display: flex; + --layer-row-action-btn-background: var(--color-background-secondary); + svg { opacity: 1; stroke: var(--color-accent-primary); } } + .layer-row.selected & { display: flex; + --layer-row-action-btn-background: var(--color-background-quaternary); + svg { stroke: var(--color-accent-primary); } @@ -295,13 +323,16 @@ block-size: $sz-16; min-inline-size: calc(var(--depth) * var(--layer-indentation-size)); } + .filtered { min-inline-size: $sz-12; } + .lazy-load-sentinel { min-height: 1px; pointer-events: none; } + .lazy-load-sentinel { min-height: 1px; pointer-events: none; diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs index 5c0f181c1d..181ea70d47 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.cljs @@ -38,7 +38,7 @@ shape-name) default-value - (mf/with-memo [variant-id variant-error variant-properties] + (mf/with-memo [variant-id variant-error variant-properties shape-name] (if variant-id (or variant-error (ctv/properties-map->formula variant-properties)) shape-name)) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss index e2e4b1a723..659444daaf 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layer_name.scss @@ -11,11 +11,10 @@ --element-name-comp-color: var(--context-hover-color, var(--layer-row-component-foreground-color)); --element-name-opacity: var(--context-hover-opacity, deprecated.$op-7); - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; + @include deprecated.text-ellipsis; + @include deprecated.body-small-typography; color: var(--element-name-color); - flex-grow: 1; block-size: 100%; align-content: center; @@ -42,9 +41,9 @@ --element-name-input-border-color: var(--input-border-color-focus); --element-name-input-color: var(--layer-row-foreground-color); - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - @include deprecated.removeInputStyle; + @include deprecated.text-ellipsis; + @include deprecated.body-small-typography; + @include deprecated.remove-input-style; flex-grow: 1; height: deprecated.$s-28; @@ -62,5 +61,6 @@ .element-name-touched { --element-name-touched-color: var(--layer-row-component-foreground-color); + color: var(--element-name-touched-color); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs index 3440a4e43f..df02e1d0d9 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.cljs @@ -11,8 +11,10 @@ [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.types.shape :as cts] + [app.common.types.text :as txt] [app.common.uuid :as uuid] [app.main.data.workspace :as dw] + [app.main.data.workspace.texts :as dwt] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.components.search-bar :refer [search-bar*]] @@ -206,61 +208,70 @@ ;; --- Layers Toolbox +(def ^:private ref:layers-panel-search + (l/derived (l/key :layers-panel-search) refs/workspace-local)) + ;; FIXME: optimize (defn- match-filters? [state [id shape]] (let [search (:search-text state) + scope (:search-scope state) filters (:filters state) filters (cond-> filters (contains? filters :shape) - (conj :rect :circle :path :bool))] + (conj :rect :circle :path :bool)) + text-match? (case scope + :canvas (and (= :text (:type shape)) + (some? (:content shape)) + (txt/content-has-text? (:content shape) search)) + (or (str/includes? (str/lower (:name shape)) (str/lower search)) + (str/includes? (str/lower (:variant-name shape)) (str/lower search)) + ;; Dev-only: allow search by id + (and *assert* (str/includes? (dm/str (:id shape)) (str/lower search)))))] (or (= uuid/zero id) - (and (or (str/includes? (str/lower (:name shape)) (str/lower search)) - (str/includes? (str/lower (:variant-name shape)) (str/lower search)) - ;; Only for local development we allow search for ids. Otherwise will be hard - ;; search for numbers or single letter shape names (ie: "A") - (and *assert* - (str/includes? (dm/str (:id shape)) (str/lower search)))) + (and text-match? (or (empty? filters) - (and (contains? filters :component) - (contains? shape :component-id)) - (and (contains? filters :image) - (some? (cts/has-images? shape))) - + (and (contains? filters :component) (contains? shape :component-id)) + (and (contains? filters :image) (some? (cts/has-images? shape))) (let [direct-filters (into #{} (filter #{:frame :rect :circle :path :bool :text}) filters)] (contains? direct-filters (:type shape))) (and (contains? filters :group) - (and (cfh/group-shape? shape) - (not (contains? shape :component-id)) - (or (not (contains? shape :masked-group)) - (false? (:masked-group shape))))) - (and (contains? filters :mask) - (true? (:masked-group shape)))))))) + (cfh/group-shape? shape) + (not (contains? shape :component-id)) + (or (not (contains? shape :masked-group)) + (false? (:masked-group shape)))) + (and (contains? filters :mask) (true? (:masked-group shape)))))))) (defn use-search [page objects] - (let [state* (mf/use-state - #(do {:show-search false - :show-menu false - :search-text "" - :filters #{} - :num-items 100})) - - state (deref state*) - current-filters (:filters state) - current-items (:num-items state) - current-search (:search-text state) - show-menu? (:show-menu state) - show-search? (:show-search state) + (let [state* (mf/use-state + #(do {:show-search false + :find-replace-mode? false + :search-scope :layers + :show-menu false + :search-text "" + :replace-text "" + :filters #{} + :num-items 100 + :current-match-idx 0})) + layers-search-request (mf/deref ref:layers-panel-search) + state (deref state*) + current-filters (:filters state) + current-items (:num-items state) + current-search (:search-text state) + replace-text (:replace-text state) + show-menu? (:show-menu state) + show-search? (:show-search state) + find-replace-mode? (:find-replace-mode? state) + search-scope (:search-scope state) + current-match-idx (:current-match-idx state) clear-search-text (mf/use-fn - #(swap! state* assoc :search-text "" :num-items 100)) - + #(swap! state* assoc :search-text "" :num-items 100 :current-match-idx 0)) toggle-filters - (mf/use-fn - #(swap! state* update :show-menu not)) + (mf/use-fn #(swap! state* update :show-menu not)) on-toggle-filters-click (mf/use-fn @@ -269,18 +280,26 @@ (toggle-filters))) hide-menu - (mf/use-fn - #(swap! state* assoc :show-menu false)) + (mf/use-fn #(swap! state* assoc :show-menu false)) on-key-down - (mf/use-fn - (fn [event] - (when (kbd/esc? event) (hide-menu)))) + (mf/use-fn (fn [event] (when (kbd/esc? event) (hide-menu)))) update-search-text (mf/use-fn (fn [value _event] - (swap! state* assoc :search-text value :num-items 100))) + (swap! state* assoc :search-text value :num-items 100 :current-match-idx 0))) + + update-replace-text + (mf/use-fn (fn [value _event] (swap! state* assoc :replace-text value))) + + clear-replace-text + (mf/use-fn #(swap! state* assoc :replace-text "")) + + set-search-scope + (mf/use-fn + (fn [scope] + (swap! state* assoc :search-scope scope :num-items 100 :current-match-idx 0))) toggle-search (mf/use-fn @@ -289,30 +308,23 @@ (dom/blur! node) (swap! state* (fn [state] (-> state - (assoc :search-text "") - (assoc :filters #{}) - (assoc :show-menu false) - (assoc :num-items 100) + (assoc :search-text "" :replace-text "" :filters #{}) + (assoc :show-menu false :find-replace-mode? false) + (assoc :search-scope :layers :num-items 100 :current-match-idx 0) (update :show-search not))))))) remove-filter (mf/use-fn (fn [event] - (let [fkey (-> (dom/get-current-target event) - (dom/get-data "filter") - (keyword))] + (let [fkey (-> (dom/get-current-target event) (dom/get-data "filter") (keyword))] (swap! state* (fn [state] - (-> state - (update :filters disj fkey) - (assoc :num-items 100))))))) + (-> state (update :filters disj fkey) (assoc :num-items 100))))))) add-filter (mf/use-fn (fn [event] (dom/stop-propagation event) - (let [key (-> (dom/get-current-target event) - (dom/get-data "filter") - (keyword))] + (let [key (-> (dom/get-current-target event) (dom/get-data "filter") (keyword))] (swap! state* (fn [state] (-> state (update :filters conj key) @@ -332,6 +344,65 @@ filtered-objects-total (count filtered-objects-all) + canvas-match-ids + (mf/with-memo [objects current-search search-scope] + (when (and (= :canvas search-scope) (d/not-empty? current-search)) + (reduce-kv (fn [acc id shape] + (cond-> acc + (and (= :text (:type shape)) + (some? (:content shape)) + (txt/content-has-text? (:content shape) current-search)) + (conj id))) + [] objects))) + + layer-match-ids + (mf/with-memo [objects current-search search-scope] + (when (and (= :layers search-scope) (d/not-empty? current-search)) + (reduce-kv (fn [acc id shape] + (cond-> acc + (str/includes? (str/lower (:name shape)) (str/lower current-search)) + (conj id))) + [] objects))) + + text-match-ids (if (= :canvas search-scope) canvas-match-ids layer-match-ids) + text-match-count (count text-match-ids) + safe-match-idx (if (pos? text-match-count) (mod current-match-idx text-match-count) 0) + + navigate-next + (mf/use-fn + (mf/deps text-match-count) + (fn [_] + (when (pos? text-match-count) + (swap! state* update :current-match-idx + (fn [idx] (mod (inc idx) text-match-count)))))) + + navigate-prev + (mf/use-fn + (mf/deps text-match-count) + (fn [_] + (when (pos? text-match-count) + (swap! state* update :current-match-idx + (fn [idx] (mod (+ (dec idx) text-match-count) text-match-count)))))) + + handle-replace + (mf/use-fn + (mf/deps text-match-ids safe-match-idx replace-text current-search search-scope) + (fn [_] + (when (and (pos? text-match-count) (d/not-empty? current-search)) + (let [id (nth text-match-ids safe-match-idx)] + (if (= :canvas search-scope) + (st/emit! (dwt/replace-text-in-shapes [id] current-search replace-text)) + (st/emit! (dwt/replace-layer-names-in-shapes [id] current-search replace-text))))))) + + handle-replace-all + (mf/use-fn + (mf/deps text-match-ids replace-text current-search search-scope) + (fn [_] + (when (and (pos? text-match-count) (d/not-empty? current-search)) + (if (= :canvas search-scope) + (st/emit! (dwt/replace-text-in-shapes text-match-ids current-search replace-text)) + (st/emit! (dwt/replace-layer-names-in-shapes text-match-ids current-search replace-text)))))) + filtered-objects (mf/with-memo [active? filtered-objects-all current-items] (when active? @@ -353,6 +424,16 @@ (events/unlistenByKey key1) (events/unlistenByKey key2)))) + (mf/with-effect [layers-search-request] + (when (some? layers-search-request) + (let [replace-mode? (= layers-search-request :find-and-replace)] + (swap! state* (fn [s] + (-> s + (assoc :show-search true :find-replace-mode? replace-mode?) + (assoc :search-scope (if replace-mode? :canvas :layers)) + (assoc :search-text "" :replace-text "" :current-match-idx 0))))) + (st/emit! dw/clear-layers-search))) + [filtered-objects handle-show-more #(mf/html @@ -364,17 +445,62 @@ :on-clear clear-search-text :placeholder (tr "workspace.sidebar.layers.search")} [:button {:on-click on-toggle-filters-click - :class (stl/css-case - :filter-button true - :opened show-menu? - :active active?)} + :class (stl/css-case :filter-button true :opened show-menu? :active active?)} [:> icon* {:icon-id i/filter}]]] - [:> icon-button* {:variant "ghost" :aria-label (tr "labels.close") :on-click toggle-search :icon i/close}]] + [:div {:class (stl/css :search-scope-row)} + [:label {:class (stl/css-case :scope-option true :scope-selected (= :canvas search-scope))} + [:span {:class (stl/css-case :scope-radio true :scope-radio-checked (= :canvas search-scope))}] + [:input {:type "radio" :name "search-scope" :class (stl/css :scope-radio-input) + :checked (= :canvas search-scope) + :on-change (fn [_] (set-search-scope :canvas))}] + [:span {:class (stl/css :scope-label)} + (tr "workspace.sidebar.layers.search-scope-canvas")]] + [:label {:class (stl/css-case :scope-option true :scope-selected (= :layers search-scope))} + [:span {:class (stl/css-case :scope-radio true :scope-radio-checked (= :layers search-scope))}] + [:input {:type "radio" :name "search-scope" :class (stl/css :scope-radio-input) + :checked (= :layers search-scope) + :on-change (fn [_] (set-search-scope :layers))}] + [:span {:class (stl/css :scope-label)} + (tr "workspace.sidebar.layers.search-scope-layers")]]] + + (when ^boolean find-replace-mode? + [:* + [:div {:class (stl/css :tool-window-bar :replace-row)} + [:div {:class (stl/css :replace-input-wrapper)} + [:input {:class (stl/css :replace-input) + :value replace-text + :placeholder (tr "workspace.sidebar.layers.replace-placeholder") + :on-change (fn [event] + (update-replace-text (dom/get-target-val event) event))}] + (when (not= "" replace-text) + [:button {:class (stl/css :clear-icon) :on-click clear-replace-text} + [:> icon* {:icon-id i/delete-text :size "s"}]])] + (when (d/not-empty? current-search) + (if (pos? text-match-count) + [:div {:class (stl/css :match-navigation)} + [:span {:class (stl/css :match-count)} + (dm/str (inc safe-match-idx) " / " text-match-count)] + [:> icon-button* {:variant "ghost" :aria-label (tr "labels.previous") + :on-click navigate-prev :icon i/arrow-up}] + [:> icon-button* {:variant "ghost" :aria-label (tr "labels.next") + :on-click navigate-next :icon i/arrow-down}]] + [:span {:class (stl/css :no-matches)} + (tr "workspace.sidebar.layers.no-matches")]))] + [:div {:class (stl/css :replace-actions-row)} + [:button {:class (stl/css :replace-button) + :on-click handle-replace + :disabled (or (zero? text-match-count) (str/empty? current-search))} + (tr "workspace.sidebar.layers.replace")] + [:button {:class (stl/css :replace-button) + :on-click handle-replace-all + :disabled (or (zero? text-match-count) (str/empty? current-search))} + (tr "workspace.sidebar.layers.replace-all")]]]) + [:div {:class (stl/css :active-filters)} (for [fkey current-filters] (let [fname (d/name fkey) diff --git a/frontend/src/app/main/ui/workspace/sidebar/layers.scss b/frontend/src/app/main/ui/workspace/sidebar/layers.scss index e89f730323..048aa08549 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/layers.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/layers.scss @@ -18,65 +18,78 @@ &.search { padding: 0 deprecated.$s-12 0 deprecated.$s-8; gap: deprecated.$s-4; + .filter-button { - @include deprecated.flexCenter; - @include deprecated.buttonStyle; + @include deprecated.flex-center; + @include deprecated.button-style; + height: deprecated.$s-32; width: deprecated.$s-32; margin: 0; border: deprecated.$s-1 solid var(--color-background-tertiary); border-radius: deprecated.$br-8 deprecated.$br-2 deprecated.$br-2 deprecated.$br-8; background-color: var(--color-background-tertiary); + svg { height: deprecated.$s-16; width: deprecated.$s-16; stroke: var(--icon-foreground); } + &:focus { border: deprecated.$s-1 solid var(--input-border-color-focus); outline: 0; background-color: var(--input-background-color-active); color: var(--input-foreground-color-active); + svg { background-color: var(--input-background-color-active); } } + &:hover { border: deprecated.$s-1 solid var(--input-border-color-hover); background-color: var(--input-background-color-hover); + svg { background-color: var(--input-background-color-hover); stroke: var(--button-foreground-hover); } } + &.opened { - @extend .button-icon-selected; + @extend %button-icon-selected; } } } } .page-name { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + padding: 0 deprecated.$s-12; color: var(--title-foreground-color); } .icon-search { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; border-radius: deprecated.$br-8; margin-right: deprecated.$s-8; padding: 0; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } } .focus-title { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: grid; grid-template-columns: auto 1fr auto; align-items: center; @@ -85,38 +98,45 @@ } .back-button { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-32; width: deprecated.$s-24; padding: 0 deprecated.$s-4 0 deprecated.$s-8; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); transform: rotate(180deg); } } .focus-name { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; + @include deprecated.text-ellipsis; + @include deprecated.body-small-typography; + padding-left: deprecated.$s-4; color: var(--title-foreground-color); } .focus-mode-tag-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: 100%; margin-right: deprecated.$s-12; } .active-filters { - @include deprecated.flexRow; + @include deprecated.flex-row; + flex-wrap: wrap; margin: 0 deprecated.$s-12; } .layer-filter { - @extend .button-tag; + @extend %button-tag; + gap: deprecated.$s-6; height: deprecated.$s-24; margin: deprecated.$s-2 0; @@ -131,8 +151,9 @@ } .layer-filter-name { - @include deprecated.flexCenter; - @include deprecated.bodySmallTypography; + @include deprecated.flex-center; + @include deprecated.body-small-typography; + color: var(--pill-foreground-color); } @@ -141,12 +162,15 @@ } .filters-container { - @extend .menu-dropdown; + @extend %menu-dropdown; + position: absolute; left: deprecated.$s-20; width: deprecated.$s-192; + .filter-menu-item { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; justify-content: space-between; @@ -158,28 +182,34 @@ display: flex; align-items: center; gap: deprecated.$s-8; + .filter-menu-item-icon { color: var(--menu-foreground-color); } + .filter-menu-item-name { padding-top: deprecated.$s-2; color: var(--menu-foreground-color); } } + .filter-menu-item-tick { color: var(--menu-foreground-color); } &.selected { background-color: var(--menu-background-color-selected); + .filter-menu-item-name-wrapper { .filter-menu-item-icon { color: var(--menu-foreground-color); } + .filter-menu-item-name { color: var(--menu-foreground-color); } } + .filter-menu-item-tick { color: var(--menu-foreground-color); } @@ -187,14 +217,17 @@ &:hover { background-color: var(--menu-background-color-hover); + .filter-menu-item-name-wrapper { .filter-menu-item-icon { color: var(--menu-foreground-color-hover); } + .filter-menu-item-name { color: var(--menu-foreground-color-hover); } } + .filter-menu-item-tick { color: var(--menu-foreground-color-hover); } @@ -204,15 +237,169 @@ .tool-window-content { --calculated-height: calc(#{deprecated.$s-136} + var(--height, #{deprecated.$s-200})); + display: flex; flex-direction: column; height: calc(100vh - var(--calculated-height)); width: calc(var(--left-sidebar-width) + var(--depth) * var(--layer-indentation-size)); - overflow-x: auto; - overflow-y: overlay; + overflow: auto; scrollbar-gutter: stable; } +.replace-row { + padding: 0 deprecated.$s-12; + gap: deprecated.$s-4; +} + +.search-scope-row { + display: flex; + gap: deprecated.$s-16; + padding: deprecated.$s-4 deprecated.$s-12 deprecated.$s-8; + align-items: center; +} + +.scope-option { + display: flex; + align-items: center; + gap: deprecated.$s-6; + cursor: pointer; +} + +.scope-radio { + width: deprecated.$s-12; + height: deprecated.$s-12; + border: deprecated.$s-1 solid var(--color-foreground-secondary); + border-radius: 50%; + background-color: transparent; + flex-shrink: 0; +} + +.scope-radio-checked { + border-color: var(--color-accent-primary); + background-color: var(--color-accent-primary); + box-shadow: inset 0 0 0 deprecated.$s-2 var(--color-background-primary); +} + +.scope-radio-input { + display: none; +} + +.scope-label { + @include deprecated.body-small-typography; + + color: var(--color-foreground-secondary); + cursor: pointer; +} + +.scope-selected .scope-label { + color: var(--color-foreground-primary); +} + +.replace-actions-row { + display: flex; + gap: deprecated.$s-4; + padding: 0 deprecated.$s-12 deprecated.$s-8; +} + +.replace-input-wrapper { + @include deprecated.flex-center; + + flex: 1; + height: deprecated.$s-32; + border: deprecated.$s-1 solid var(--search-bar-input-border-color); + border-radius: deprecated.$br-8; + background-color: var(--search-bar-input-background-color); + + &:hover { + border: deprecated.$s-1 solid var(--input-border-color-hover); + background-color: var(--input-background-color-hover); + + .replace-input { + background-color: var(--input-background-color-hover); + } + } + + &:focus-within { + background-color: var(--input-background-color-active); + color: var(--input-foreground-color-active); + border: deprecated.$s-1 solid var(--input-border-color-focus); + + .replace-input { + background-color: var(--input-background-color-active); + } + } +} + +.replace-input { + width: 100%; + height: 100%; + margin: 0 deprecated.$s-8; + border: 0; + background-color: var(--input-background-color); + font-size: deprecated.$fs-12; + color: var(--input-foreground-color); + border-radius: deprecated.$br-8; + + &:focus { + outline: none; + } +} + +.replace-button { + @include deprecated.body-small-typography; + @include deprecated.button-style; + + flex: 1; + height: deprecated.$s-28; + padding: 0 deprecated.$s-8; + border: deprecated.$s-1 solid var(--color-background-tertiary); + border-radius: deprecated.$br-8; + background-color: var(--color-background-tertiary); + color: var(--color-foreground-primary); + white-space: nowrap; + text-transform: uppercase; + + &:hover:not(:disabled) { + border: deprecated.$s-1 solid var(--input-border-color-hover); + background-color: var(--input-background-color-hover); + } + + &:disabled { + opacity: 0.4; + cursor: default; + } +} + +.match-navigation { + display: flex; + align-items: center; + gap: deprecated.$s-2; + flex-shrink: 0; +} + +.match-count { + @include deprecated.body-small-typography; + + color: var(--color-foreground-secondary); + white-space: nowrap; +} + +.no-matches { + @include deprecated.body-small-typography; + + color: var(--color-foreground-secondary); + white-space: nowrap; + flex-shrink: 0; +} + +.clear-icon { + @extend %button-tag; + + flex: 0 0 deprecated.$s-32; + height: 100%; + color: var(--color-icon-default); +} + .element-list { display: grid; position: relative; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options.scss b/frontend/src/app/main/ui/workspace/sidebar/options.scss index 8a819471e8..b7428196ab 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options.scss @@ -18,8 +18,7 @@ } .content-class { - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; height: calc(100vh - #{$sz-96}); scrollbar-gutter: stable; } @@ -29,9 +28,11 @@ flex-direction: column; gap: var(--sp-s); width: 100%; + /* FIXME: This is hacky and prone to break, we should tackle the whole layout of the sidebar differently */ --sidebar-element-options-height: calc(100vh - #{$sz-88}); + height: var(--sidebar-element-options-height); padding-block-start: var(--sp-s); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs index 7ea8e42132..2f6d8a586c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/common.cljs @@ -7,6 +7,8 @@ (ns app.main.ui.workspace.sidebar.options.common (:require-macros [app.main.style :as stl]) (:require + [app.main.data.workspace.tokens.application :as dwta] + [app.main.store :as st] [app.util.dom :as dom] [rumext.v2 :as mf])) @@ -24,3 +26,13 @@ :ref ref} children]))) +(defn emit-value-or-token [value emit-value-fn ids attrs] + (if (or (string? value) + (number? value) + (nil? value)) + (emit-value-fn value) + (st/emit! + (dwta/toggle-token {:token (first value) + :attrs attrs + :shape-ids ids})))) + diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs index 275ad11e8d..fa40189182 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs @@ -13,8 +13,10 @@ [app.main.store :as st] [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.icons :as deprecated-icon] + [app.main.ui.workspace.sidebar.options.menus.measures :as measures] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -31,11 +33,35 @@ selected-preset-name (deref selected-preset-name*) - on-open - (mf/use-fn (fn [] (reset! show* true))) + search-term* + (mf/use-state "") + + search-term + (deref search-term*) + + container-ref + (mf/use-ref nil) + + on-toggle + (mf/use-fn + (fn [] + (swap! show* not) + (reset! search-term* ""))) on-close - (mf/use-fn (fn [] (reset! show* false))) + (mf/use-fn + (fn [] + (reset! show* false) + (reset! search-term* ""))) + + on-search-change + (mf/use-fn + (fn [value _event] + (reset! search-term* value))) + + filtered-presets + (mf/with-memo [search-term] + (measures/filter-size-presets search-term size-presets)) on-preset-selected (mf/use-fn @@ -48,7 +74,9 @@ (d/read-string))] (reset! selected-preset-name* name) - (st/emit! (dwd/set-default-size width height))))) + (st/emit! (dwd/set-default-size width height)) + (reset! show* false) + (reset! search-term* "")))) orientation (when (:width drawing-state) @@ -65,35 +93,49 @@ [:div {:class (stl/css :presets)} [:div {:class (stl/css-case :presets-wrapper true :opened show?) - :on-click on-open} + :ref container-ref + :on-click on-toggle} [:span {:class (stl/css :select-name)} (or selected-preset-name (tr "workspace.options.size-presets"))] [:span {:class (stl/css :collapsed-icon)} deprecated-icon/arrow] [:& dropdown {:show show? - :on-close on-close} - [:ul {:class (stl/css :custom-select-dropdown)} - (for [preset size-presets] - (if-not (:width preset) - [:li {:key (:name preset) - :class (stl/css-case :dropdown-element true - :disabled true)} - [:span {:class (stl/css :preset-name)} (:name preset)]] + :on-close on-close + :container container-ref} + [:div {:class (stl/css :custom-select-dropdown) + :on-click dom/stop-propagation} + [:div {:class (stl/css :preset-search)} + [:> search-bar* {:on-change on-search-change + :value search-term + :auto-focus true + :placeholder (tr "workspace.options.search-size-preset")}]] + [:ul {:class (stl/css :preset-list)} + (if (empty? filtered-presets) + [:li {:class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} + (tr "workspace.options.no-size-preset-results")]] + (for [preset filtered-presets] + (if-not (:width preset) + [:li {:key (:name preset) + :class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} (:name preset)]] - (let [preset-match (and (= (:width preset) (:width drawing-state)) - (= (:height preset) (:height drawing-state)))] - [:li {:key (:name preset) - :class (stl/css-case :dropdown-element true - :match preset-match) - :data-width (str (:width preset)) - :data-height (str (:height preset)) - :data-name (:name preset) - :on-click on-preset-selected} - [:div {:class (stl/css :name-wrapper)} - [:span {:class (stl/css :preset-name)} (:name preset)] - [:span {:class (stl/css :preset-size)} (:width preset) " x " (:height preset)]] - (when preset-match - [:span {:class (stl/css :check-icon)} deprecated-icon/tick])])))]]] + (let [preset-match (and (= (:width preset) (:width drawing-state)) + (= (:height preset) (:height drawing-state)))] + [:li {:key (:name preset) + :class (stl/css-case :dropdown-element true + :match preset-match) + :data-width (str (:width preset)) + :data-height (str (:height preset)) + :data-name (:name preset) + :on-click on-preset-selected} + [:div {:class (stl/css :name-wrapper)} + [:span {:class (stl/css :preset-name)} (:name preset)] + [:span {:class (stl/css :preset-size)} (:width preset) " x " (:height preset)]] + (when preset-match + [:span {:class (stl/css :check-icon)} deprecated-icon/tick])]))))]]]] [:& radio-buttons {:selected (or (d/name orientation) "") :on-change on-orientation-change diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss index d66e5de852..53221cd2a5 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/drawing/frame.scss @@ -10,11 +10,13 @@ .presets { @include sidebar.option-grid-structure; + grid-column: 1 / -1; } .presets-wrapper { - @extend .asset-element; + @extend %asset-element; + position: relative; grid-column: span 6; display: flex; @@ -23,10 +25,13 @@ border-radius: deprecated.$br-8; .collapsed-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + cursor: pointer; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); transform: rotate(90deg); } @@ -44,7 +49,8 @@ } .select-name { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; justify-content: flex-start; align-items: center; @@ -53,28 +59,52 @@ } .custom-select-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + margin-top: deprecated.$s-2; max-height: 70vh; width: deprecated.$s-252; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preset-search { + padding: deprecated.$s-4; + border-bottom: deprecated.$s-1 solid var(--menu-border-color-rest, transparent); +} + +.preset-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; + .dropdown-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + .name-wrapper { display: flex; gap: deprecated.$s-8; flex-grow: 1; + .preset-name { color: var(--menu-foreground-color-rest); } + .preset-size { color: var(--menu-foreground-color-rest); } } .check-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } @@ -82,6 +112,7 @@ &.disabled { pointer-events: none; cursor: default; + .preset-name { color: var(--menu-foreground-color); } @@ -91,6 +122,7 @@ .name-wrapper .preset-name { color: var(--menu-foreground-color-hover); } + .check-icon svg { stroke: var(--menu-foreground-color-hover); } @@ -98,9 +130,11 @@ &:hover { background-color: var(--menu-background-color-hover); + .name-wrapper .preset-name { color: var(--menu-foreground-color-hover); } + .check-icon svg { stroke: var(--menu-foreground-color-hover); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss index 6535f728b4..698698ec9d 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/align.scss @@ -9,14 +9,15 @@ .align-options { @include sidebar.option-grid-structure; + height: deprecated.$s-32; } + .align-group-horizontal, .align-group-vertical { display: grid; grid-template-columns: subgrid; - align-items: center; - justify-items: center; + place-items: center center; } .align-group-horizontal { @@ -28,22 +29,29 @@ } .align-button { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-32; padding: 0; border-radius: deprecated.$br-8; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } + &.disabled { cursor: default; + svg { stroke: var(--button-foreground-color-disabled); } + &:hover { background-color: var(--panel-background-color); + svg { stroke: var(--button-foreground-color-disabled); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss index c80e57e1ec..2d1c6263b7 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/blur.scss @@ -21,7 +21,8 @@ } .element-set-content { - @include deprecated.flexColumn; + @include deprecated.flex-column; + margin-bottom: deprecated.$s-8; } @@ -36,25 +37,32 @@ flex-grow: 1; border-radius: deprecated.$br-8; background-color: var(--input-details-color); + .show-more { - @extend .button-secondary; + @extend %button-secondary; + height: deprecated.$s-32; width: deprecated.$s-28; border-radius: deprecated.$br-8 0 0 deprecated.$br-8; box-sizing: border-box; border: deprecated.$s-1 solid var(--button-secondary-background-color-rest); + svg { - @extend .button-icon; + @extend %button-icon; } + &.selected { background-color: var(--button-radio-background-color-active); + svg { stroke: var(--button-radio-foreground-color-active); } } } + .label { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + flex-grow: 1; display: flex; align-items: center; @@ -66,24 +74,30 @@ box-sizing: border-box; border: deprecated.$s-1 solid var(--input-border-color); } + .blur-type-select { flex-grow: 1; border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; } } + .actions { - @include deprecated.flexRow; + @include deprecated.flex-row; } &.hidden { .blur-info { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + .show-more { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + border: deprecated.$s-1 solid var(--input-border-color-disabled); } + .label { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + border: deprecated.$s-1 solid var(--input-border-color-disabled); } } @@ -91,9 +105,11 @@ } .second-row { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + width: deprecated.$s-92; + .label { padding-left: deprecated.$s-8; width: deprecated.$s-60; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss index ef69c2dd4e..270b922093 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/bool.scss @@ -9,6 +9,7 @@ .boolean-options { @include sidebar.option-grid-structure; + height: var(--sp-xxxl); } @@ -19,26 +20,31 @@ } .flatten-button { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-32; border-radius: deprecated.$br-8; grid-column: 5 / span 1; + --flatten-icon-foreground-color: var(--icon-foreground); &.disabled { cursor: default; + --flatten-icon-foreground-color: var(--button-foreground-color-disabled); &:hover { background-color: var(--panel-background-color); + --flatten-icon-foreground-color: var(--button-foreground-color-disabled); } } } .flatten-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--flatten-icon-foreground-color); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs index d1d7a55fc5..16feaa68d0 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs @@ -11,6 +11,7 @@ [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.hooks :as hooks] + [app.main.ui.workspace.sidebar.options.common :as soc] [app.main.ui.workspace.sidebar.options.menus.input-wrapper-tokens :refer [numeric-input-wrapper*]] [app.util.i18n :as i18n :refer [tr]] [beicon.v2.core :as rx] @@ -130,26 +131,21 @@ (mf/use-fn (mf/deps change-radius ids) (fn [value] - (if (or (string? value) (number? value)) - (st/emit! - (change-radius (fn [shape] - (ctsr/set-radius-to-all-corners shape value)))) - (st/emit! - (dwta/toggle-token {:token (first value) - :attrs #{:r1 :r2 :r3 :r4} - :shape-ids ids}))))) - + (soc/emit-value-or-token + value + #(st/emit! (change-radius (fn [shape] (ctsr/set-radius-to-all-corners shape %)))) + ids + #{:r1 :r2 :r3 :r4}))) on-single-radius-change (mf/use-fn (mf/deps change-one-radius ids) (fn [value attr] - (if (or (string? value) (number? value)) - (st/emit! (change-one-radius #(ctsr/set-radius-to-single-corner % attr value) attr)) - (st/emit! (st/emit! - (dwta/toggle-token {:token (first value) - :attrs #{attr} - :shape-ids ids})))))) + (soc/emit-value-or-token + value + #(st/emit! (change-one-radius (fn [shape] (ctsr/set-radius-to-single-corner shape attr %)) attr)) + ids + #{attr}))) on-radius-r1-change #(on-single-radius-change % :r1) on-radius-r2-change #(on-single-radius-change % :r2) @@ -168,64 +164,50 @@ (mf/with-effect [ids] (reset! radius-expanded* false)) - [:section {:class (dm/str class " " (stl/css :radius)) - :aria-label "border-radius-section"} - (if (not radius-expanded) - (if token-numeric-inputs - [:> numeric-input-wrapper* - {:on-change on-all-radius-change - :on-detach on-detach-all - :icon i/corner-radius - :min 0 - :attr :border-radius - :nillable true - :property (tr "workspace.options.radius") - :applied-token (cond - (not (seq applied-tokens)) - nil + (if token-numeric-inputs + [:section {:class (dm/str class " " (stl/css :radius-token)) + :aria-label (tr "workspace.options.radius.radius-section")} + [:div {:class (stl/css :radius-first-row)} + [:> numeric-input-wrapper* + {:on-change on-all-radius-change + :on-detach on-detach-all + :icon i/corner-radius + :min 0 + :attr :border-radius + :nillable true + :property (tr "workspace.options.radius") + :applied-token (cond + (not (seq applied-tokens)) + nil - (or (not all-values-equal?) (not all-token-equal?)) - :multiple + (or (not all-values-equal?) (not all-token-equal?)) + :multiple - :else - (get applied-tokens :r1)) - :align :right - :placeholder (cond - (or (not all-values-equal?) - (not all-token-equal?)) - (tr "settings.multiple") - :else - "--") - :value (if all-values-equal? - (if (nil? (:r1 values)) - 0 - (:r1 values)) - nil)}] - - [:div {:class (stl/css :radius-1) - :title (tr "workspace.options.radius")} - [:> icon* {:icon-id i/corner-radius - :size "s" - :class (stl/css :icon)}] - [:> deprecated-input/numeric-input* - {:placeholder (cond - (not all-values-equal?) - (tr "settings.multiple") - (= :multiple (:r1 values)) - (tr "settings.multiple") :else - "--") - :min 0 - :nillable true - :on-change on-all-radius-change - :value (if all-values-equal? - (if (nil? (:r1 values)) - 0 - (:r1 values)) - nil)}]]) + (get applied-tokens :r1)) + :align :right + :placeholder (cond + (or (not all-values-equal?) + (not all-token-equal?)) + (tr "settings.multiple") + :else + "--") + :value (if all-values-equal? + (if (nil? (:r1 values)) + 0 + (:r1 values)) + nil)}] + [:> icon-button* {:class (stl/css-case :selected radius-expanded) + :variant "ghost" + :tooltip-placement "top-left" + :on-click toggle-radius-mode + :aria-label (if radius-expanded + (tr "workspace.options.radius.hide-all-corners") + (tr "workspace.options.radius.show-single-corners")) + :icon i/corner-radius}]] - (if token-numeric-inputs - [:div {:class (stl/css :radius-4)} + (when radius-expanded + [:div {:class (stl/css :radius-4-token)} [:> numeric-input-wrapper* {:on-change on-radius-r1-change :on-detach on-detach-r1 @@ -253,6 +235,7 @@ :property (tr "workspace.options.radius-top-right") :applied-token (get applied-tokens :r2) :align :right + :tooltip-placement "top-left" :inner-class (stl/css :no-icon-input) :placeholder (cond (or (= :multiple (get applied-tokens :r2)) @@ -297,9 +280,33 @@ "--") :align :right :class (stl/css :radius-wrapper) + :tooltip-placement "top-left" :inner-class (stl/css :no-icon-input) - :value (:r3 values)}]] - + :value (:r3 values)}]])] + [:section {:class (dm/str class " " (stl/css :radius)) + :aria-label (tr "workspace.options.radius.radius-section")} + (if (not radius-expanded) + [:div {:class (stl/css :radius-1) + :title (tr "workspace.options.radius")} + [:> icon* {:icon-id i/corner-radius + :size "s" + :class (stl/css :icon)}] + [:> deprecated-input/numeric-input* + {:placeholder (cond + (not all-values-equal?) + (tr "settings.multiple") + (= :multiple (:r1 values)) + (tr "settings.multiple") + :else + "--") + :min 0 + :nillable true + :on-change on-all-radius-change + :value (if all-values-equal? + (if (nil? (:r1 values)) + 0 + (:r1 values)) + nil)}]] [:div {:class (stl/css :radius-4)} [:div {:class (stl/css :small-input)} [:> deprecated-input/numeric-input* @@ -331,12 +338,11 @@ :title (tr "workspace.options.radius-bottom-right") :min 0 :on-change on-radius-r3-change - :value (:r3 values)}]]])) - - [:> icon-button* {:class (stl/css-case :selected radius-expanded) - :variant "ghost" - :on-click toggle-radius-mode - :aria-label (if radius-expanded - (tr "workspace.options.radius.hide-all-corners") - (tr "workspace.options.radius.show-single-corners")) - :icon i/corner-radius}]])) + :value (:r3 values)}]]]) + [:> icon-button* {:class (stl/css-case :selected radius-expanded) + :variant "ghost" + :on-click toggle-radius-mode + :aria-label (if radius-expanded + (tr "workspace.options.radius.hide-all-corners") + (tr "workspace.options.radius.show-single-corners")) + :icon i/corner-radius}]]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss index 5473314c67..e98b076c02 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/border_radius.scss @@ -14,8 +14,9 @@ gap: var(--sp-xs); } -.radius-1 { - @extend .input-element; +.radius-1, +.small-input { + @extend %input-element; @include t.use-typography("body-small"); } @@ -25,23 +26,12 @@ gap: var(--sp-xs); } -.small-input { - @extend .input-element; - @include t.use-typography("body-small"); -} - .selected { border-color: var(--button-icon-border-color-selected); background-color: var(--button-icon-background-color-selected); color: var(--color-accent-primary); } -.selected { - border-color: var(--button-icon-border-color-selected); - background-color: var(--button-icon-background-color-selected); - color: var(--button-icon-foreground-color-selected); -} - .icon { margin-inline: var(--sp-xs); } @@ -53,3 +43,20 @@ .dropdown-offset { --dropdown-offset: #{px2rem(-65)}; } + +.radius-token { + display: grid; + gap: var(--sp-xs); +} + +.radius-first-row { + display: grid; + grid-template-columns: 1fr auto; + gap: var(--sp-xs); +} + +.radius-4-token { + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--sp-xs); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs index 0c60429d98..06b3fac9d2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs @@ -188,9 +188,10 @@ [color-operations _] (retrieve-color-operations groups old-color prev-colors)] (mf/set-ref-val! prev-colors-ref (conj prev-colors color)) - (st/emit! (dwta/apply-token-on-selected color-operations token)))))] + (st/emit! (dwta/apply-token-on-color-selected color-operations token)))))] - [:div {:class (stl/css :element-set)} + [:section {:class (stl/css :element-set) + :aria-label (tr "workspace.options.selection-color.section")} [:div {:class (stl/css :element-title)} [:> title-bar* {:collapsable has-colors? :collapsed (not open?) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss index 7519bcb568..d138c82ac2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/color_selection.scss @@ -21,26 +21,31 @@ } .add-fill { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; } } .element-content { grid-column: span 8; - @include deprecated.flexColumn; + + @include deprecated.flex-column; + margin-bottom: deprecated.$s-8; } .selected-color-group { - @include deprecated.flexColumn; + @include deprecated.flex-column; } .more-colors-btn { - @extend .button-secondary; - @include deprecated.uppercaseTitleTipography; + @extend %button-secondary; + @include deprecated.uppercase-title-typography; + height: deprecated.$s-32; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss index 88ca5dd5b6..521c409223 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/component.scss @@ -14,6 +14,7 @@ .annotation { @include t.use-typography("body-small"); + grid-column: span 8; color: var(--color-foreground-secondary); border-radius: $br-8; @@ -123,7 +124,7 @@ // easy way to plop the elements on top of each other and have them both sized based on the tallest one's height display: grid; - &:after { + &::after { // The space is needed to preventy jumpy behavior content: attr(data-replicated-value) " "; white-space: pre-wrap; @@ -132,7 +133,6 @@ /* Identical styling required!! */ font: inherit; overflow-wrap: anywhere; - padding: var(--sp-m); /* Place on top of each other */ @@ -143,20 +143,15 @@ .annotation-textarea { background-color: var(--color-background-primary); color: var(--color-foreground-primary); - padding: var(--sp-m); - border: none; overflow: hidden; outline: none; - box-shadow: none; - resize: none; /* Identical styling required!! */ font: inherit; overflow-wrap: anywhere; - padding: var(--sp-m); /* Place on top of each other */ @@ -165,6 +160,7 @@ .annotation-counter { @include t.use-typography("body-small"); + text-align: right; color: var(--color-foreground-secondary); margin: 0 var(--sp-s) var(--sp-s) 0; @@ -181,6 +177,7 @@ --swap-item-thumbnail-background-color: var(--color-canvas); @include t.use-typography("body-small"); + display: flex; align-items: center; padding: px2rem(1) var(--sp-m) px2rem(1) px2rem(1); @@ -237,8 +234,6 @@ --swap-item-thumbnail-background-color-disabled: var(--color-foreground-secondary); display: flex; - justify-content: center; - align-items: center; place-items: center; aspect-ratio: 1 / 1; flex-wrap: wrap; @@ -257,6 +252,7 @@ .swap-item-name { @include t.use-typography("body-small"); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -295,10 +291,8 @@ &::before { content: " "; position: absolute; - inset-inline-start: 0; - inset-inline-end: 0; - inset-block-start: 0; - inset-block-end: 0; + inset-inline: 0; + inset-block: 0; border: calc($b-2 * 2) solid var(--swap-item-border-inner-color-selected); border-radius: $br-8; } @@ -334,6 +328,7 @@ .swap-group { @include t.use-typography("body-small"); + cursor: pointer; display: grid; grid-template-columns: 1fr var(--sp-m); @@ -365,6 +360,7 @@ .swap-title { @include t.use-typography("headline-small"); + display: flex; align-items: center; block-size: $sz-32; @@ -397,6 +393,7 @@ .swap-library-name { @include t.use-typography("body-small"); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -419,6 +416,7 @@ .swap-library-back-name { @include t.use-typography("body-small"); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -429,6 +427,7 @@ .swap-library-empty { @include t.use-typography("body-small"); + margin: 0 var(--sp-xs) 0 var(--sp-s); color: var(--color-foreground-secondary); } @@ -457,6 +456,7 @@ .component-title-swap { @include t.use-typography("headline-small"); + cursor: pointer; display: flex; align-items: center; @@ -482,6 +482,7 @@ .component-title-bar-type { @include t.use-typography("body-small"); + block-size: 100%; display: flex; align-items: center; @@ -498,8 +499,7 @@ display: flex; flex-direction: column; row-gap: var(--sp-m); - padding-block-start: var(--sp-xs); - padding-block-end: var(--sp-s); + padding-block: var(--sp-xs) var(--sp-s); } .component-pill { @@ -562,6 +562,7 @@ .pill-btn-text { @include t.use-typography("body-small"); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -572,6 +573,7 @@ .pill-btn-subtext { @include t.use-typography("body-small"); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -587,19 +589,21 @@ } .pill-actions-btn { - @extend .button-secondary; + @extend %button-secondary; + cursor: unset; block-size: 100%; inline-size: 100%; border-radius: 0 $br-8 $br-8 0; &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } .pill-actions-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + inline-size: $sz-252; inset-inline-end: 0; inset-inline-start: unset; @@ -610,12 +614,11 @@ } .pill-actions-dropdown-item { - @extend .dropdown-element-base; + @extend %dropdown-element-base; } .variant-property-list { grid-column: span 8; - display: grid; flex-direction: column; gap: var(--sp-xs); @@ -672,6 +675,7 @@ .variant-property-name { @include t.use-typography("body-small"); + margin-inline-start: var(--sp-s); color: var(--color-foreground-secondary); display: block; @@ -682,8 +686,8 @@ .variant-warning { @include t.use-typography("body-small"); - grid-column: span 8; + grid-column: span 8; border: $b-1 solid var(--color-background-quaternary); border-radius: $br-8; padding: var(--sp-m); @@ -702,6 +706,7 @@ .variant-warning-button { @include t.use-typography("body-small"); + cursor: pointer; background-color: transparent; border: none; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss index 5f7578afe1..e0fbb84843 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/constraints.scss @@ -18,12 +18,7 @@ .constraints-widget { background-color: var(--constraint-widget-background-color); display: grid; - grid-template-columns: deprecated.$s-24 deprecated.$s-60 deprecated.$s-24; - grid-template-rows: deprecated.$s-24 deprecated.$s-60 deprecated.$s-24; - grid-template-areas: - "top top top" - "left center right" - "bottom bottom bottom"; + grid-template: "top top top" deprecated.$s-24 "left center right" deprecated.$s-60 "bottom bottom bottom" deprecated.$s-24 / deprecated.$s-24 deprecated.$s-60 deprecated.$s-24; height: deprecated.$s-108; width: deprecated.$s-108; border-radius: deprecated.$br-8; @@ -34,22 +29,28 @@ .constraints-center, .constraints-right, .constraints-bottom { - @include deprecated.flexCenter; + @include deprecated.flex-center; + grid-area: top; } + .constraint-btn, .constraint-btn-special, .constraint-btn-rotated { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + width: 100%; height: 100%; + --resalted-area-background-color: var(--button-constraint-background-color-rest); --resalted-area-border-color: none; + &.active { --resalted-area-border-color: var(--button-constraint-border-color-hover); --resalted-area-background-color: var(--button-constraint-background-color-hover); } + &:hover, &:focus-visible { --resalted-area-border-color: var(--button-constraint-border-color-hover); @@ -69,9 +70,11 @@ .constraints-left { grid-area: left; + .constraint-btn-rotated { height: deprecated.$s-60; width: deprecated.$s-24; + .resalted-area { height: deprecated.$s-32; width: deprecated.$s-3; @@ -84,18 +87,22 @@ position: relative; background-color: var(--constraint-center-area-background-color); border-radius: deprecated.$br-8; + .constraint-btn { width: deprecated.$s-60; height: deprecated.$s-24; + .resalted-area { width: deprecated.$s-32; height: deprecated.$s-3; } } + .constraint-btn-special { position: absolute; height: deprecated.$s-60; width: deprecated.$s-24; + .resalted-area { height: deprecated.$s-32; width: deprecated.$s-3; @@ -105,9 +112,11 @@ .constraints-right { grid-area: right; + .constraint-btn-rotated { height: deprecated.$s-72; width: deprecated.$s-24; + .resalted-area { height: deprecated.$s-32; width: deprecated.$s-3; @@ -137,33 +146,42 @@ margin-bottom: deprecated.$s-8; margin-top: deprecated.$s-8; padding-left: 0; + input { margin: 0; } label { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; gap: deprecated.$s-2; cursor: pointer; color: var(--input-checkbox-text-foreground-color); + .check-mark { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-16; height: deprecated.$s-16; border-radius: deprecated.$br-6; background-color: var(--input-checkbox-inactive-background-color); + &.checked { background-color: var(--input-checkbox-background-color-active); + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--input-details-color); } } + &:hover { border-color: var(--input-checkbox-border-color-hover); } + &:focus { border-color: var(--input-checkbox-border-color-focus); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss index 487e4805bc..f224403269 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/exports.scss @@ -22,17 +22,21 @@ .element-set-content { @include sidebar.option-grid-structure; + gap: var(--sp-xs); } .multiple-exports { - @include deprecated.flexRow; + @include deprecated.flex-row; + grid-column: 1 / span 9; + .label { - @extend .mixed-bar; + @extend %mixed-bar; } + .actions { - @include deprecated.flexRow; + @include deprecated.flex-row; } } @@ -62,6 +66,7 @@ .size-select { grid-column: span 2; padding: 0; + .dropdown-upwards { bottom: deprecated.$s-36; top: unset; @@ -71,13 +76,15 @@ .suffix-input { grid-column: span 3; - @extend .input-element; - @include deprecated.bodySmallTypography; + + @extend %input-element; + @include deprecated.body-small-typography; } .export-btn { - @extend .button-secondary; - @include deprecated.uppercaseTitleTipography; + @extend %button-secondary; + @include deprecated.uppercase-title-typography; + grid-column: 1 / span 9; height: deprecated.$s-32; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs index 67d6d1370d..c420053c43 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.cljs @@ -52,16 +52,15 @@ [n-props o-props] (and (identical? (unchecked-get n-props "ids") (unchecked-get o-props "ids")) + (identical? (unchecked-get n-props "appliedTokens") + (unchecked-get o-props "appliedTokens")) (let [o-vals (unchecked-get o-props "values") n-vals (unchecked-get n-props "values") o-fills (get o-vals :fills) n-fills (get n-vals :fills) - o-applied-tokens (get o-vals :applied-tokens) - n-applied-tokens (get n-vals :applied-tokens) o-hide (get o-vals :hide-fill-on-export) n-hide (get n-vals :hide-fill-on-export)] (and (identical? o-hide n-hide) - (identical? o-applied-tokens n-applied-tokens) (identical? o-fills n-fills))))) (mf/defc fill-menu* @@ -174,10 +173,10 @@ (mf/deps ids) (fn [_ token] (st/emit! - (dwta/toggle-token {:token token - :attrs #{:fill} - :shape-ids ids - :expand-with-children true})))) + (dwta/apply-token-from-input {:token token + :attrs #{:fill} + :shape-ids ids + :expand-with-children true})))) on-detach-token (mf/use-fn @@ -196,7 +195,7 @@ (dom/remove-attribute! checkbox "indeterminate")))) [:section {:class (stl/css :fill-section) - :aria-label "Fill section"} + :aria-label (tr "workspace.options.fill.section")} [:div {:class (stl/css :fill-title)} [:> title-bar* {:collapsable has-fills? :collapsed (not open?) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss index 28a159b4de..a9d920386e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/fill.scss @@ -24,7 +24,6 @@ .fill-content { grid-column: span 8; - display: flex; flex-direction: column; gap: var(--sp-m); @@ -39,6 +38,7 @@ .fill-multiple-label { @include t.use-typography("body-small"); + display: flex; align-items: center; flex-grow: 1; @@ -51,12 +51,16 @@ .fill-checkbox { // TODO create a checkbox component in the DS - @extend .input-checkbox; + @extend %input-checkbox; + padding-inline-start: var(--sp-s); + span.checked { background-color: var(--color-accent-primary); + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--color-background-primary); } } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss index 51b82c5434..75b108f768 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/frame_grid.scss @@ -21,7 +21,8 @@ } .element-set-content { - @include deprecated.flexColumn; + @include deprecated.flex-column; + grid-column: span 8; margin: deprecated.$s-4 0 deprecated.$s-8 0; } @@ -37,70 +38,87 @@ gap: deprecated.$s-1; border-radius: deprecated.$br-8; background-color: var(--input-details-color); + .show-options { - @extend .button-secondary; + @extend %button-secondary; + height: deprecated.$s-32; width: deprecated.$s-28; border-radius: deprecated.$br-8 0 0 deprecated.$br-8; box-sizing: border-box; border: deprecated.$s-1 solid var(--input-border-color); + svg { - @extend .button-icon; + @extend %button-icon; } + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } + .type-select-wrapper { flex-grow: 1; width: deprecated.$s-96; padding: 0; border-radius: 0; height: deprecated.$s-32; + .grid-type-select { border-radius: 0; height: 100%; box-sizing: border-box; border: deprecated.$s-1 solid var(--input-border-color); + &:hover { border: deprecated.$s-1 solid var(--input-border-color-hover); } } } + .grid-size { - @extend .asset-element; + @extend %asset-element; + width: deprecated.$s-60; margin: 0; padding: 0; padding-left: deprecated.$s-8; border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; + .numeric-input { - @extend .input-base; - @include deprecated.bodySmallTypography; + @extend %input-base; + @include deprecated.body-small-typography; } } + .editable-select-wrapper { - @extend .asset-element; + @extend %asset-element; + width: deprecated.$s-60; margin: 0; padding: 0; position: relative; border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; + .column-select { height: deprecated.$s-32; border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; box-sizing: border-box; border: deprecated.$s-1 solid var(--input-border-color); + .numeric-input { - @extend .input-base; - @include deprecated.bodySmallTypography; + @extend %input-base; + @include deprecated.body-small-typography; + margin: 0; padding: 0; } + span { - @include deprecated.flexCenter; + @include deprecated.flex-center; + svg { - @extend .button-icon; + @extend %button-icon; } } } @@ -108,39 +126,52 @@ &.hidden { .show-options { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + border: deprecated.$s-1 solid var(--input-border-color-disabled); } + .type-select-wrapper, .editable-select-wrapper { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + .column-select, .grid-type-select { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + border: deprecated.$s-1 solid var(--input-border-color-disabled); } + .column-select { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; + .numeric-input { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; } } } + .grid-size { - @include deprecated.hiddenElement; + @include deprecated.hidden-element; + border: deprecated.$s-1 solid var(--input-border-color-disabled); + .icon { stroke: var(--input-foreground-color-disabled); } + .numeric-input { color: var(--input-foreground-color-disabled); } } + .actions { .hidden-btn, .lock-btn { background-color: transparent; + svg { stroke: var(--input-foreground-color-disabled); } @@ -150,18 +181,21 @@ } .actions { - @include deprecated.flexRow; + @include deprecated.flex-row; + grid-column: span 2; } .grid-advanced-options { - @include deprecated.flexColumn; + @include deprecated.flex-column; + margin-top: deprecated.$s-4; } .column-row, .square-row { - @include deprecated.flexColumn; + @include deprecated.flex-column; + position: relative; } @@ -169,35 +203,45 @@ position: relative; display: flex; gap: deprecated.$s-4; + .orientation-select-wrapper { width: deprecated.$s-92; padding: 0; } + .color-wrapper { width: deprecated.$s-156; } + .show-more-options { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-32; + svg { - @extend .button-icon; + @extend %button-icon; } + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } + .height { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + .icon-text { padding-top: deprecated.$s-1; } } + .gutter, .margin { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + .icon { &.rotated svg { transform: rotate(90deg); @@ -206,8 +250,9 @@ } .more-options { - @include deprecated.menuShadow; - @include deprecated.flexColumn; + @include deprecated.menu-shadow; + @include deprecated.flex-column; + position: absolute; top: calc(deprecated.$s-2 + deprecated.$s-28); right: 0; @@ -220,8 +265,10 @@ z-index: deprecated.$z-index-4; overflow-y: auto; background-color: var(--menu-background-color); + .option-btn { - @include deprecated.buttonStyle; + @include deprecated.button-style; + display: flex; align-items: center; height: deprecated.$s-32; @@ -238,13 +285,16 @@ } .second-row { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + left: unset; right: 0; width: deprecated.$s-108; + .btn-options { - @include deprecated.buttonStyle; - @extend .dropdown-element-base; + @include deprecated.button-style; + @extend %dropdown-element-base; + width: 100%; } } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss index 5b61b4dabf..9e4aa4ca3b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/grid_cell.scss @@ -8,7 +8,8 @@ @use "../../../sidebar/common/sidebar.scss" as sidebar; .grid-cell-menu-container { - @include deprecated.flexColumn; + @include deprecated.flex-column; + margin-top: deprecated.$s-8; gap: deprecated.$s-16; } @@ -27,7 +28,7 @@ } .row { - @include deprecated.flexRow; + @include deprecated.flex-row; } .cell-mode :global(label) { @@ -35,34 +36,39 @@ } .edit-grid-btn { - @extend .button-secondary; - @include deprecated.uppercaseTitleTipography; + @extend %button-secondary; + @include deprecated.uppercase-title-typography; + width: 100%; padding: deprecated.$s-8; } .area-input { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + width: 100%; padding: deprecated.$s-8; } .grid-coord-group { - @include deprecated.flexRow; + @include deprecated.flex-row; + border-radius: deprecated.$br-8; padding-left: deprecated.$s-4; background-color: var(--input-background-color); } .icon svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } .coord-input { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + border-radius: 0 deprecated.$br-8 deprecated.$br-8 0; border-left: deprecated.$s-1 solid var(--panel-background-color); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs index be7c58ebb2..5ca03e3d29 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs @@ -1,9 +1,9 @@ (ns app.main.ui.workspace.sidebar.options.menus.input-wrapper-tokens (:require-macros [app.main.style :as stl]) (:require - [app.common.types.token :as tk] [app.main.ui.context :as muc] [app.main.ui.ds.controls.numeric-input :refer [numeric-input*]] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.util.i18n :as i18n :refer [tr]] [rumext.v2 :as mf])) @@ -11,11 +11,8 @@ [{:keys [value attr applied-token align on-detach placeholder input-type class] :rest props}] (let [tokens (mf/use-ctx muc/active-tokens-by-type) - tokens (mf/with-memo [tokens input-type] - (delay - (-> (deref tokens) - (select-keys (get tk/tokens-by-input (or input-type attr))) - (not-empty)))) + tokens (mf/with-memo [tokens input-type attr] + (csu/filter-tokens-for-input tokens (or input-type attr))) on-detach-attr (mf/use-fn @@ -28,7 +25,7 @@ (tr "settings.multiple") "--")) :class [class (stl/css :numeric-input-wrapper)] - :applied-token applied-token + :applied-token-name applied-token :tokens (if (delay? tokens) @tokens tokens) :align align :on-detach on-detach-attr diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss index b5d6de75d1..3097f16e76 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.scss @@ -5,5 +5,5 @@ // Copyright (c) KALEIDOS INC .numeric-input-wrapper { - --dropdown-width: var(--7-columns-dropdown-width); + --dropdown-width: var(--seven-columns-width); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs index ac8fdc23cf..c1a54d9764 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs @@ -475,16 +475,6 @@ (when (ctsi/has-overlay-opts interaction) [:* - ;; Overlay position relative-to (select) - [:div {:class (stl/css :interaction-row)} - [:div {:class (stl/css :interaction-row-label)} - [:div {:class (stl/css :interaction-row-name)} - (tr "workspace.options.interaction-relative-to")]] - [:div {:class (stl/css :interaction-row-select)} - [:& select {:default-value (str (:position-relative-to interaction)) - :options relative-to-opts - :on-change change-position-relative-to}]]] - ;; Overlay position (select) [:div {:class (stl/css :interaction-row)} [:div {:class (stl/css :interaction-row-label)} @@ -495,6 +485,16 @@ :options overlay-position-opts :on-change change-overlay-pos-type}]]] + ;; Overlay position relative-to (select) + [:div {:class (stl/css :interaction-row)} + [:div {:class (stl/css :interaction-row-label)} + [:div {:class (stl/css :interaction-row-name)} + (tr "workspace.options.interaction-relative-to")]] + [:div {:class (stl/css :interaction-row-select)} + [:& select {:default-value (str (:position-relative-to interaction)) + :options relative-to-opts + :on-change change-position-relative-to}]]] + ;; Overlay position (buttons) [:div {:class (stl/css :interaction-row)} [:div {:class (stl/css :interaction-row-position)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss index 066145fa14..c4457a8aed 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/interactions.scss @@ -32,7 +32,6 @@ .content { grid-column: span 8; - display: flex; flex-direction: column; gap: var(--sp-xs); @@ -62,12 +61,12 @@ align-items: center; gap: px2rem(1); border-radius: $br-8; - padding: var(--sp-s) var(--sp-m); block-size: $sz-32; padding: 0; &.double { block-size: $sz-48; + .prototype-pill-button { block-size: $sz-48; } @@ -114,13 +113,12 @@ .prototype-pill-input { @include t.use-typography("body-small"); + border: none; background: none; outline: none; block-size: 100%; - inline-size: 100%; flex-grow: 1; - margin: var(--sp-xxs) 0; padding: 0 0 0 var(--sp-s); margin: 0; background-color: var(--color-background-tertiary); @@ -130,6 +128,7 @@ &:hover { background-color: var(--color-background-quaternary); + &:active { background-color: var(--color-background-quaternary); } @@ -142,13 +141,15 @@ .prototype-pill-name { @include t.use-typography("body-small"); - @include textEllipsis; + @include text-ellipsis; + color: var(--color-foreground-primary); } .prototype-pill-description { @include t.use-typography("body-small"); - @include textEllipsis; + @include text-ellipsis; + color: var(--color-foreground-secondary); } @@ -164,8 +165,9 @@ } .interaction-row-name { - @include twoLineTextEllipsis; + @include two-line-text-ellipsis; @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); } @@ -191,12 +193,10 @@ .interaction-row-position { grid-column: 4 / span 5; display: grid; - grid-template-areas: - "topleft top topright" - "left center right" - "bottomleft bottom bottomright"; - grid-template-columns: repeat(3, 1fr); - grid-template-rows: repeat(3, 1fr); + grid-template: + "topleft top topright" 1fr + "left center right" 1fr + "bottomleft bottom bottomright" 1fr / repeat(3, 1fr); inline-size: calc($sz-32 * 3); block-size: calc($sz-32 * 3); border-radius: $br-8; @@ -205,21 +205,27 @@ .center { grid-area: center; } + .top-left { grid-area: topleft; } + .top-center { grid-area: top; } + .top-right { grid-area: topright; } + .bottom-left { grid-area: bottomleft; } + .bottom-center { grid-area: bottom; } + .bottom-right { grid-area: bottomright; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs index 3e5cfe9921..41ec031902 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.cljs @@ -69,6 +69,8 @@ hidden? (get values :hidden) blocked? (get values :blocked) + opacity (get values :opacity) + on-detach-token (mf/use-fn (mf/deps ids) @@ -138,14 +140,13 @@ on-opacity-change (mf/use-fn - (mf/deps on-change handle-opacity-change) + (mf/deps handle-opacity-change) (fn [value] (if (or (string? value) (number? value)) (handle-opacity-change value) - (do - (st/emit! (dwta/toggle-token {:token (first value) - :attrs #{:opacity} - :shape-ids ids})))))) + (st/emit! (dwta/apply-token-from-input {:token (first value) + :attrs #{:opacity} + :shape-ids ids}))))) handle-set-hidden (mf/use-fn @@ -205,20 +206,25 @@ preview-complete?)) (swap! state* assoc :selected-blend-mode current-blend-mode))) - [:section {:class (stl/css-case :element-set-content true - :hidden hidden?) - :aria-label "layer-menu-section"} - [:div {:class (stl/css :select)} - [:& select - {:default-value selected-blend-mode - :options options - :on-change handle-change-blend-mode - :is-open? option-highlighted? - :class (stl/css-case :hidden-select hidden?) - :on-pointer-enter-option handle-blend-mode-enter - :on-pointer-leave-option handle-blend-mode-leave}]] + ;; NOTE: + ;; This code is temporarily duplicated because the UI is changing with a new feature. + ;; The new implementation is currently behind a feature/config flag and not yet released. + ;; Once the feature is released, the duplicated ClojureScript and SCSS code should be removed. + ;; https://tree.taiga.io/project/penpot/task/13704 + + (if token-numeric-inputs + ;; TODO: When duplicated code is remove rename this class removing the "token" reference from it + [:section {:class (stl/css :element-set-content-token) + :aria-label (tr "workspace.options.layer-options.layer-section")} + [:& select + {:default-value selected-blend-mode + :options options + :on-change handle-change-blend-mode + :is-open? option-highlighted? + :class (stl/css-case :hidden-select hidden?) + :on-pointer-enter-option handle-blend-mode-enter + :on-pointer-leave-option handle-blend-mode-leave}] - (if token-numeric-inputs [:> numeric-input-wrapper* {:on-change on-opacity-change :on-detach on-detach-token @@ -233,9 +239,54 @@ (tr "settings.multiple") "--") :align :right + :disabled (if (or (= :multiple hidden?) hidden?) true false) :class (stl/css :numeric-input-wrapper) - :value (* 100 - (or (get values :opacity) 1))}] + :value (if (= :multiple opacity) + opacity + (* 100 (d/nilv opacity 1)))}] + + (cond + (or (= :multiple hidden?) (not hidden?)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.layer-options.toggle-layer") + :on-click handle-set-hidden + :tooltip-placement "top-left" + :icon i/shown}] + + :else + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.layer-options.toggle-layer") + :on-click handle-set-visible + :tooltip-placement "top-left" + :icon i/hide}]) + + (cond + (or (= :multiple blocked?) (not blocked?)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.shape.menu.lock") + :on-click handle-set-blocked + :tooltip-placement "top-left" + :icon i/unlock}] + + :else + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.shape.menu.unlock") + :on-click handle-set-unblocked + :tooltip-placement "top-left" + :icon i/lock}])] + + [:section {:class (stl/css-case :element-set-content true + :hidden hidden?) + :aria-label (tr "workspace.options.layer-options.layer-section")} + [:div {:class (stl/css :select)} + [:& select + {:default-value selected-blend-mode + :options options + :on-change handle-change-blend-mode + :is-open? option-highlighted? + :class (stl/css-case :hidden-select hidden?) + :on-pointer-enter-option handle-blend-mode-enter + :on-pointer-leave-option handle-blend-mode-leave}]] [:div {:class (stl/css :input) :title (tr "workspace.options.opacity")} @@ -246,31 +297,31 @@ :on-change handle-opacity-change :min 0 :max 100 - :className (stl/css :numeric-input)}]]) + :className (stl/css :numeric-input)}]] - [:div {:class (stl/css :actions)} - (cond - (or (= :multiple hidden?) (not hidden?)) - [:> icon-button* {:variant "ghost" - :aria-label (tr "workspace.options.layer-options.toggle-layer") - :on-click handle-set-hidden - :icon i/shown}] + [:div {:class (stl/css :actions)} + (cond + (or (= :multiple hidden?) (not hidden?)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.layer-options.toggle-layer") + :on-click handle-set-hidden + :icon i/shown}] - :else - [:> icon-button* {:variant "ghost" - :aria-label (tr "workspace.options.layer-options.toggle-layer") - :on-click handle-set-visible - :icon i/hide}]) + :else + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.layer-options.toggle-layer") + :on-click handle-set-visible + :icon i/hide}]) - (cond - (or (= :multiple blocked?) (not blocked?)) - [:> icon-button* {:variant "ghost" - :aria-label (tr "workspace.shape.menu.lock") - :on-click handle-set-blocked - :icon i/unlock}] + (cond + (or (= :multiple blocked?) (not blocked?)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.shape.menu.lock") + :on-click handle-set-blocked + :icon i/unlock}] - :else - [:> icon-button* {:variant "ghost" - :aria-label (tr "workspace.shape.menu.unlock") - :on-click handle-set-unblocked - :icon i/lock}])]])) + :else + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.shape.menu.unlock") + :on-click handle-set-unblocked + :icon i/lock}])]]))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss index 637cf9a090..333cb66bbf 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layer.scss @@ -7,20 +7,30 @@ @use "refactor/common-refactor.scss" as deprecated; @use "../../../sidebar/common/sidebar.scss" as sidebar; @use "ds/_utils.scss" as *; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/typography.scss" as t; +// This code should be remove when numeric-input-tokens are activated +// https://tree.taiga.io/project/penpot/task/13704 .element-set-content { @include sidebar.option-grid-structure; - height: deprecated.$s-32; - margin-bottom: deprecated.$s-8; + + block-size: $sz-32; + margin-block-end: var(--sp-s); + .select { grid-column: span 4; padding: 0; } + .input { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include t.use-typography("body-small"); + grid-column: span 2; } + .actions { grid-column: span 2; display: grid; @@ -29,15 +39,28 @@ &.hidden { .hidden-select { - @include deprecated.hiddenElement; - border: deprecated.$s-1 solid var(--input-border-color-disabled); + cursor: default; + pointer-events: none; + box-sizing: border-box; + color: var(--input-foreground-color-disabled); + stroke: var(--input-foreground-color-disabled); + background-color: transparent; + border: $b-1 solid var(--input-border-color-disabled); } + .input { - @include deprecated.hiddenElement; - border: deprecated.$s-1 solid var(--input-border-color-disabled); + cursor: default; + pointer-events: none; + box-sizing: border-box; + color: var(--input-foreground-color-disabled); + stroke: var(--input-foreground-color-disabled); + background-color: transparent; + border: $b-1 solid var(--input-border-color-disabled); + .icon { stroke: var(--input-foreground-color-disabled); } + .numeric-input { color: var(--input-foreground-color-disabled); } @@ -45,7 +68,29 @@ } } +// This code should remain when numeric-input-tokens are activated +// https://tree.taiga.io/project/penpot/task/13704 + +// This rule should be rename when numeric-input-tokens are +// activated removing the token reference on the class +.element-set-content-token { + @include sidebar.option-grid-structure; + + block-size: $sz-32; + margin-block-end: var(--sp-s); + grid-template-columns: var(--grid-exception-input-width) var(--grid-exception-input-width-small) auto auto; +} + +.hidden-select { + cursor: default; + pointer-events: none; + box-sizing: border-box; + color: var(--input-foreground-color-disabled); + stroke: var(--input-foreground-color-disabled); + background-color: transparent; + border: $b-1 solid var(--input-border-color-disabled); +} + .numeric-input-wrapper { - grid-column: span 2; --dropdown-offset: #{px2rem(-35)}; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs index e07c3cd958..dd992ce08b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs @@ -30,6 +30,7 @@ [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] [app.main.ui.icons :as deprecated-icon] + [app.main.ui.workspace.sidebar.options.common :as soc] [app.main.ui.workspace.sidebar.options.menus.input-wrapper-tokens :refer [numeric-input-wrapper*]] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -335,15 +336,8 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr event] - (if (or (string? value) (number? value)) - (on-change :simple attr value event) - (do - (st/emit! - (dwta/toggle-token {:token (first value) - :attrs (if (= :p1 attr) - #{:p1 :p3} - #{:p2 :p4}) - :shape-ids ids})))))) + (let [on-change-fn #(on-change :simple attr % event)] + (soc/emit-value-or-token value on-change-fn ids attr)))) on-detach-token (mf/use-fn @@ -370,10 +364,10 @@ (mf/use-fn (mf/deps on-focus) #(on-focus :p2)) on-p1-change - (mf/use-fn (mf/deps on-change') #(on-change' % :p1)) + (mf/use-fn (mf/deps on-change') #(on-change' % #{:p1 :p3})) on-p2-change - (mf/use-fn (mf/deps on-change') #(on-change' % :p2))] + (mf/use-fn (mf/deps on-change') #(on-change' % #{:p2 :p4}))] [:div {:class (stl/css :paddings-simple)} (if token-numeric-inputs @@ -466,12 +460,8 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr event] - (if (or (string? value) (number? value)) - (on-change :multiple attr value event) - (do - (st/emit! (dwta/toggle-token {:token (first value) - :attrs #{attr} - :shape-ids ids})))))) + (let [on-change-fn #(on-change :multiple attr % event)] + (soc/emit-value-or-token value on-change-fn ids #{attr})))) on-focus (mf/use-fn @@ -648,7 +638,7 @@ :value p4}]])])) (mf/defc padding-section* - [{:keys [type on-type-change on-change] :as props}] + [{:keys [type on-type-change] :as props}] (let [on-type-change' (mf/use-fn (mf/deps on-type-change) @@ -656,9 +646,7 @@ (let [type (-> (dom/get-current-target event) (dom/get-data "type")) type (if (= type "multiple") :simple :multiple)] - (on-type-change type)))) - - props (mf/spread-object props {:on-change on-change})] + (on-type-change type))))] (mf/with-effect [] ;; on destroy component @@ -719,15 +707,8 @@ (mf/use-fn (mf/deps on-change wrap-type ids) (fn [value event attr] - (if (or (string? value) (number? value)) - (on-change (= "nowrap" wrap-type) attr value event) - (do - (st/emit! - (dwta/toggle-token {:token (first value) - :attrs (if (= "nowrap" wrap-type) - #{:row-gap :colum-gap} - #{attr}) - :shape-ids ids})))))) + (let [on-change-fn #(on-change (= "nowrap" wrap-type) attr % event)] + (soc/emit-value-or-token value on-change-fn ids #{attr})))) on-detach-token (mf/use-fn @@ -1195,10 +1176,10 @@ (fn [type prop val] (let [val (mth/finite val 0)] (cond - (and (= type :simple) (= prop :p1)) + (and (= type :simple) (or (= prop :p1) (= prop #{:p1 :p3}))) (st/emit! (dwsl/update-layout ids {:layout-padding {:p1 val :p3 val}})) - (and (= type :simple) (= prop :p2)) + (and (= type :simple) (or (= prop :p2) (= prop #{:p2 :p4}))) (st/emit! (dwsl/update-layout ids {:layout-padding {:p2 val :p4 val}})) (some? prop) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss index 10247a86b1..c4ec2b3f77 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_container.scss @@ -42,6 +42,7 @@ .flex-layout-menu { @include sidebar.option-grid-structure; + margin-block-end: var(--sp-s); } @@ -49,8 +50,7 @@ grid-column: 1 / -1; display: grid; grid-template-columns: subgrid; - margin-block-end: var(--sp-m); - margin-block-start: var(--sp-xs); + margin-block: var(--sp-xs) var(--sp-m); } .align-row { @@ -63,16 +63,20 @@ // TODO: Replace this buttons with DS buttons .wrap-button { - @extend .button-tertiary; + @extend %button-tertiary; + border-radius: $br-8; block-size: $sz-32; inline-size: $sz-32; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--color-foreground-secondary); } + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } @@ -95,6 +99,7 @@ var(--grid-exception-input-width) /* first input block */ var(--grid-exception-input-width) /* second input block */ var(--sp-xxxl); /* action button */ + gap: var(--sp-xs); grid-column: 1 / -1; } @@ -115,10 +120,11 @@ // TODO: Remove when activating token numeric inputs .column-gap, .row-gap { - @extend .input-element; + @extend %input-element; @include t.use-typography("body-small"); + &.disabled { - @extend .disabled-input; + @extend %disabled-input; } } @@ -126,7 +132,7 @@ .padding-simple, .padding-multiple { @include t.use-typography("body-small"); - @extend .input-element; + @extend %input-element; } .padding-group { @@ -151,16 +157,20 @@ // TODO: Replace this buttons with DS buttons .padding-toggle { - @extend .button-tertiary; + @extend %button-tertiary; + block-size: $sz-32; inline-size: $sz-32; border-radius: $br-8; + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--color-foreground-secondary); } + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } @@ -188,6 +198,7 @@ align-items: flex-start; position: relative; gap: var(--sp-xs); + margin-block-end: var(--sp-s); } .locate-button { @@ -197,6 +208,7 @@ .grid-layout-menu-title { @include t.use-typography("headline-small"); + flex: 1; color: var(--color-foreground-primary); grid-column: span 5; @@ -204,8 +216,9 @@ // TODO: Replace this buttons with DS buttons .edit-mode-btn { - @extend .button-secondary; + @extend %button-secondary; @include t.use-typography("headline-small"); + inline-size: 100%; padding: var(--sp-s); grid-column: span 7; @@ -213,8 +226,9 @@ // TODO: Replace this buttons with DS buttons .exit-btn { - @extend .button-secondary; + @extend %button-secondary; @include t.use-typography("headline-small"); + padding: var(--sp-s) var(--sp-xl); grid-column: span 2; } @@ -267,19 +281,23 @@ border-radius: $br-8 0 0 $br-8; background-color: var(--color-background-tertiary); padding: 0 var(--sp-s); + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--color-foreground-secondary); block-size: 100%; } + &:hover svg { stroke: var(--color-foreground-primary); } } .track-info-value { - @extend .input-element; + @extend %input-element; @include t.use-typography("body-small"); + border-radius: 0; border-inline-end: $b-1 solid var(--color-background-primary); } @@ -298,6 +316,7 @@ .grid-track-header { @include t.use-typography("body-small"); + display: flex; align-items: center; gap: var(--sp-xs); @@ -330,16 +349,19 @@ // TODO: Replace this buttons with DS buttons .expand-icon { - @extend .button-secondary; - block-size: px2rem(52); + @extend %button-secondary; + block-size: px2rem(52); border-radius: $br-8 0 0 $br-8; border-inline-end: $b-1 solid var(--color-background-primary); + svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--color-foreground-secondary); fill: var(--color-foreground-secondary); } + &:hover, &:active { svg { @@ -351,7 +373,8 @@ // TODO: Replace this buttons with DS buttons .add-column { - @extend .button-tertiary; + @extend %button-tertiary; + block-size: px2rem(52); svg { @@ -359,7 +382,6 @@ justify-content: center; align-items: center; color: transparent; - fill: none; stroke-width: px2rem(1); block-size: $sz-12; inline-size: $sz-12; @@ -369,7 +391,7 @@ } .layout-options { - box-shadow: 0px 0px $sz-12 0px var(--color-shadow-dark); + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); position: absolute; display: flex; flex-direction: column; @@ -382,8 +404,7 @@ margin-block-start: px2rem(1); border-radius: $br-8; z-index: var(--z-index-dropdown); - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; background-color: var(--color-background-tertiary); color: var(--color-foreground-primary); border: $b-2 solid var(--color-background-quaternary); @@ -423,7 +444,9 @@ var(--grid-exception-input-width) /* first input block */ var(--grid-exception-input-width) /* second input block */ var(--sp-xxxl); /* action button */ + gap: var(--sp-xs); + margin-block-end: var(--sp-xs); } .grid-first-row { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs index d239dbd1e3..c8e43e7f1c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs @@ -21,6 +21,7 @@ [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.icons :as deprecated-icon] + [app.main.ui.workspace.sidebar.options.common :as soc] [app.main.ui.workspace.sidebar.options.menus.input-wrapper-tokens :refer [numeric-input-wrapper*]] [app.main.ui.workspace.sidebar.options.menus.layout-container :refer [get-layout-flex-icon]] [app.util.dom :as dom] @@ -117,15 +118,10 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr] - (if (or (string? value) (number? value) (nil? value)) - (on-change :simple attr value) - (do - (st/emit! - (dwta/toggle-token {:token (first value) - :attrs (if (= :m1 attr) - #{:m1 :m3} - #{:m2 :m4}) - :shape-ids ids})))))) + (soc/emit-value-or-token value + #(on-change :simple attr %) + ids + (if (= :m1 attr) #{:m1 :m3} #{:m2 :m4})))) on-focus-m1 (mf/use-fn (mf/deps on-focus) #(on-focus :m1)) @@ -247,14 +243,10 @@ (mf/use-fn (mf/deps on-change ids) (fn [value attr] - (if (or (string? value) (number? value) (nil? value)) - (on-change :multiple attr value) - (do - (st/emit! - (dwta/toggle-token {:token (first value) - :attrs #{attr} - :shape-ids ids})))))) - + (soc/emit-value-or-token value + #(on-change :multiple attr %) + ids + #{attr}))) on-m1-change (mf/use-fn (mf/deps on-change') #(on-change' % :m1)) @@ -579,13 +571,10 @@ (mf/use-fn (mf/deps ids) (fn [value attr] - (if (or (string? value) (number? value) (nil? value)) - (st/emit! (dwsl/update-layout-child ids {attr value})) - (do - (st/emit! - (dwta/toggle-token {:token (first value) - :attrs #{attr} - :shape-ids ids})))))) + (soc/emit-value-or-token value + #(st/emit! (dwsl/update-layout-child ids {attr %})) + ids + #{attr}))) on-layout-item-min-w-change (mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-min-w)) @@ -599,7 +588,8 @@ on-layout-item-max-h-change (mf/use-fn (mf/deps on-size-change) #(on-size-change % :layout-item-max-h))] - [:div {:class (stl/css :advanced-options)} + [:section {:class (stl/css :advanced-options) + :aria-label "Layout item size constraints"} (when (= (:layout-item-h-sizing values) :fill) [:div {:class (stl/css :horizontal-fill)} (if token-numeric-inputs @@ -847,7 +837,7 @@ (st/emit! (dwsl/update-layout-child ids {:layout-item-z-index value}))))] [:section {:class (stl/css :element-set) - :aria-label "layout item menu"} + :aria-label "Layout item section"} [:div {:class (stl/css :element-title)} [:> title-bar* {:collapsable has-content? :collapsed (not open?) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss index d54b68e918..90a7f772f4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/layout_item.scss @@ -25,6 +25,7 @@ .flex-element-menu { @include sidebar.option-grid-structure; + gap: var(--sp-xs); } @@ -42,7 +43,8 @@ .z-index-wrapper { @include use-typography("body-small"); - @extend .input-element; + @extend %input-element; + grid-column: 6 / span 3; } @@ -71,18 +73,22 @@ var(--grid-exception-input-width) /* first input block */ var(--grid-exception-input-width) /* second input block */ var(--sp-xxxl); /* action button */ + gap: var(--sp-xs); } .margin-mode { - @extend .button-tertiary; + @extend %button-tertiary; + grid-column: 3; height: deprecated.$s-32; + svg { - @extend .button-icon; + @extend %button-icon; } + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } @@ -90,14 +96,17 @@ display: grid; gap: var(--sp-xs); grid-template-columns: subgrid; + .vertical-margin, .horizontal-margin { - @extend .input-element; + @extend %input-element; @include use-typography("body-small"); } + .vertical-margin { grid-column: 1; } + .horizontal-margin { grid-column: 2; } @@ -121,7 +130,7 @@ .bottom-margin, .left-margin, .right-margin { - @extend .input-element; + @extend %input-element; @include use-typography("body-small"); } @@ -155,6 +164,7 @@ var(--grid-exception-input-width) /* first input block */ var(--grid-exception-input-width) /* second input block */ var(--sp-xxxl); /* action button */ + gap: var(--sp-xs); } @@ -169,8 +179,9 @@ .layout-item-min-h, .layout-item-max-w, .layout-item-max-h { - @extend .input-element; + @extend %input-element; @include use-typography("body-small"); + .icon-text { justify-content: flex-start; inline-size: px2rem(80); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs index 9c5030e99c..b29d3fd47b 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.cljs @@ -26,6 +26,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.components.numeric-input :as deprecated-input] [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] + [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] @@ -34,6 +35,7 @@ [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] [clojure.set :as set] + [cuerdas.core :as str] [rumext.v2 :as mf])) (def measure-attrs @@ -105,6 +107,29 @@ (number? value) (parse-double (.toFixed value decimals))))) +(defn filter-size-presets + "Filter the `size-presets` list by `term`, preserving category headers only + when at least one of their following presets matches." + [term presets] + (if (str/blank? term) + presets + (let [lterm (str/lower term) + matches? (fn [p] (and (:width p) + (str/includes? (str/lower (:name p)) lterm)))] + (loop [remaining presets + acc []] + (if-let [head (first remaining)] + (if (:width head) + (recur (rest remaining) + (cond-> acc (matches? head) (conj head))) + (let [[items tail] (split-with :width (rest remaining)) + matching-items (filter matches? items)] + (recur tail + (if (seq matching-items) + (into (conj acc head) matching-items) + acc)))) + acc))))) + (mf/defc measures-menu* [{:keys [ids values applied-tokens type shapes]}] (let [token-numeric-inputs @@ -235,17 +260,36 @@ show-presets-dropdown? (deref preset-state*) - open-presets + preset-search-term* + (mf/use-state "") + + preset-search-term + (deref preset-search-term*) + + preset-container-ref + (mf/use-ref nil) + + toggle-presets (mf/use-fn - (mf/deps show-presets-dropdown?) (fn [] - (reset! preset-state* true))) + (swap! preset-state* not) + (reset! preset-search-term* ""))) close-presets (mf/use-fn (mf/deps show-presets-dropdown?) (fn [] - (reset! preset-state* false))) + (reset! preset-state* false) + (reset! preset-search-term* ""))) + + on-preset-search-change + (mf/use-fn + (fn [value _event] + (reset! preset-search-term* value))) + + filtered-size-presets + (mf/with-memo [preset-search-term] + (filter-size-presets preset-search-term size-presets)) on-preset-selected (mf/use-fn @@ -258,7 +302,9 @@ (dom/get-data "height") (d/read-string))] (st/emit! (udw/update-dimensions ids :width width) - (udw/update-dimensions ids :height height))))) + (udw/update-dimensions ids :height height)) + (reset! preset-state* false) + (reset! preset-search-term* "")))) ;; ORIENTATION @@ -283,9 +329,9 @@ (st/emit! (udw/trigger-bounding-box-cloaking ids) (udw/update-dimensions ids attr value)) (st/emit! (udw/trigger-bounding-box-cloaking ids) - (dwta/toggle-token {:token (first value) - :attrs #{attr} - :shape-ids ids}))))) + (dwta/apply-token-from-input {:token (first value) + :attrs #{attr} + :shape-ids ids}))))) on-proportion-lock-change (mf/use-fn @@ -304,9 +350,9 @@ (st/emit! (udw/trigger-bounding-box-cloaking ids)) (st/emit! (udw/update-positions ids {attr value}))) (st/emit! (udw/trigger-bounding-box-cloaking ids) - (dwta/toggle-token {:token (first value) - :attrs #{attr} - :shape-ids ids}))))) + (dwta/apply-token-from-input {:token (first value) + :attrs #{attr} + :shape-ids ids}))))) on-rotation-change (mf/use-fn @@ -317,9 +363,9 @@ (st/emit! (udw/trigger-bounding-box-cloaking ids)) (st/emit! (udw/increase-rotation ids value))) (st/emit! (udw/trigger-bounding-box-cloaking ids) - (dwta/toggle-token {:token (first value) - :attrs #{:rotation} - :shape-ids ids}))))) + (dwta/apply-token-from-input {:token (first value) + :attrs #{:rotation} + :shape-ids ids}))))) on-width-change (mf/use-fn (mf/deps on-size-change) #(on-size-change % :width)) @@ -379,33 +425,47 @@ [:div {:class (stl/css :presets)} [:div {:class (stl/css-case :presets-wrapper true :opened show-presets-dropdown?) - :on-click open-presets} + :ref preset-container-ref + :on-click toggle-presets} [:span {:class (stl/css :select-name)} (tr "workspace.options.size-presets")] [:span {:class (stl/css :collapsed-icon)} deprecated-icon/arrow] [:& dropdown {:show show-presets-dropdown? - :on-close close-presets} - [:ul {:class (stl/css :custom-select-dropdown)} - (for [size-preset size-presets] - (if-not (:width size-preset) - [:li {:key (:name size-preset) - :class (stl/css-case :dropdown-element true - :disabled true)} - [:span {:class (stl/css :preset-name)} (:name size-preset)]] + :on-close close-presets + :container preset-container-ref} + [:div {:class (stl/css :custom-select-dropdown) + :on-click dom/stop-propagation} + [:div {:class (stl/css :preset-search)} + [:> search-bar* {:on-change on-preset-search-change + :value preset-search-term + :auto-focus true + :placeholder (tr "workspace.options.search-size-preset")}]] + [:ul {:class (stl/css :preset-list)} + (if (empty? filtered-size-presets) + [:li {:class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} + (tr "workspace.options.no-size-preset-results")]] + (for [size-preset filtered-size-presets] + (if-not (:width size-preset) + [:li {:key (:name size-preset) + :class (stl/css-case :dropdown-element true + :disabled true)} + [:span {:class (stl/css :preset-name)} (:name size-preset)]] - (let [preset-match (and (= (:width size-preset) (d/parse-integer (:width values) 0)) - (= (:height size-preset) (d/parse-integer (:height values) 0)))] - [:li {:key (:name size-preset) - :class (stl/css-case :dropdown-element true - :match preset-match) - :data-width (str (:width size-preset)) - :data-height (str (:height size-preset)) - :on-click on-preset-selected} - [:div {:class (stl/css :name-wrapper)} - [:span {:class (stl/css :preset-name)} (:name size-preset)] - [:span {:class (stl/css :preset-size)} (:width size-preset) " x " (:height size-preset)]] - (when preset-match - [:span {:class (stl/css :check-icon)} deprecated-icon/tick])])))]]] + (let [preset-match (and (= (:width size-preset) (d/parse-integer (:width values) 0)) + (= (:height size-preset) (d/parse-integer (:height values) 0)))] + [:li {:key (:name size-preset) + :class (stl/css-case :dropdown-element true + :match preset-match) + :data-width (str (:width size-preset)) + :data-height (str (:height size-preset)) + :on-click on-preset-selected} + [:div {:class (stl/css :name-wrapper)} + [:span {:class (stl/css :preset-name)} (:name size-preset)] + [:span {:class (stl/css :preset-size)} (:width size-preset) " x " (:height size-preset)]] + (when preset-match + [:span {:class (stl/css :check-icon)} deprecated-icon/tick])]))))]]]] [:& radio-buttons {:selected (or (d/name orientation) "") :on-change on-orientation-change @@ -559,8 +619,7 @@ :value (get values :rotation)}] [:div {:class (stl/css :rotation) - :title (tr "workspace.options.rotation") - :data-testid "rotation"} + :title (tr "workspace.options.rotation")} [:span {:class (stl/css :icon)} deprecated-icon/rotation] [:> deprecated-input/numeric-input* {:no-validate true diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss index e214e0f636..357df42145 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/measures.scss @@ -14,17 +14,20 @@ var(--grid-exception-input-width) /* first input block */ var(--grid-exception-input-width) /* second input block */ var(--sp-xxxl); /* action button */ + gap: var(--sp-xs); margin-bottom: var(--sp-s); } .presets { @include sidebar.option-grid-structure; + grid-column: 1 / -1; } .presets-wrapper { - @extend .asset-element; + @extend %asset-element; + position: relative; grid-column: span 5; display: flex; @@ -33,10 +36,13 @@ border-radius: deprecated.$br-8; .collapsed-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + cursor: pointer; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); transform: rotate(90deg); } @@ -54,7 +60,8 @@ } .select-name { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; justify-content: flex-start; align-items: center; @@ -63,28 +70,52 @@ } .custom-select-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + margin-top: deprecated.$s-2; max-height: 70vh; width: deprecated.$s-252; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.preset-search { + padding: deprecated.$s-4; + border-bottom: deprecated.$s-1 solid var(--menu-border-color-rest, transparent); +} + +.preset-list { + flex: 1 1 auto; + min-height: 0; + overflow-y: auto; + margin: 0; + padding: 0; + list-style: none; + .dropdown-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + .name-wrapper { display: flex; gap: deprecated.$s-8; flex-grow: 1; + .preset-name { color: var(--menu-foreground-color-rest); } + .preset-size { color: var(--menu-foreground-color-rest); } } .check-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + svg { - @extend .button-icon-small; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } @@ -92,6 +123,7 @@ &.disabled { pointer-events: none; cursor: default; + .preset-name { color: var(--menu-foreground-color); } @@ -101,6 +133,7 @@ .name-wrapper .preset-name { color: var(--menu-foreground-color-hover); } + .check-icon svg { stroke: var(--menu-foreground-color-hover); } @@ -108,9 +141,11 @@ &:hover { background-color: var(--menu-background-color-hover); + .name-wrapper .preset-name { color: var(--menu-foreground-color-hover); } + .check-icon svg { stroke: var(--menu-foreground-color-hover); } @@ -131,13 +166,15 @@ .x-position, .y-position, .rotation { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + .icon-text { padding-top: deprecated.$s-1; } + &.disabled { - @extend .disabled-input; + @extend %disabled-input; } } @@ -146,17 +183,20 @@ } .lock-size-btn { - @extend .button-tertiary; + @extend %button-tertiary; + border-radius: deprecated.$br-8; height: deprecated.$s-32; width: deprecated.$s-28; + &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } .lock-ratio-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--icon-foreground); } @@ -175,21 +215,22 @@ } .clip-content-label { - @extend .button-tertiary; + @extend %button-tertiary; + height: var(--sp-xxxl); width: var(--sp-xxxl); border-radius: deprecated.$br-8; } .selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } .checkbox-button { - @extend .button-icon; + @extend %button-icon; } // TODO: Add a proper variable to this sizing .numeric-input-measures { - --dropdown-width: var(--7-columns-dropdown-width); + --dropdown-width: var(--seven-columns-width); } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss index 4e2453ff6d..310ac1eb8c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/shadow.scss @@ -23,7 +23,6 @@ .shadow-content { grid-column: span 8; - display: flex; flex-direction: column; gap: var(--sp-xs); @@ -38,6 +37,7 @@ .shadow-multiple-label { @include t.use-typography("body-small"); + display: flex; align-items: center; flex-grow: 1; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs index f1a708d8c0..306963bcd4 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs @@ -12,6 +12,7 @@ [app.common.types.stroke :as cts] [app.main.data.workspace :as udw] [app.main.data.workspace.colors :as dc] + [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.tokens.application :as dwta] [app.main.store :as st] [app.main.ui.components.title-bar :refer [title-bar*]] @@ -155,6 +156,13 @@ (st/emit! (udw/trigger-bounding-box-cloaking ids)) (st/emit! (dc/change-stroke-attrs ids {:stroke-cap-start stroke-cap-end :stroke-cap-end stroke-cap-start} index))))) + on-toggle-visibility + (mf/use-fn + (mf/deps ids) + (fn [index] + (st/emit! (udw/trigger-bounding-box-cloaking ids) + (dwsh/update-shapes ids #(update-in % [:strokes index :hidden] not))))) + on-add-stroke (fn [_] (st/emit! (udw/trigger-bounding-box-cloaking ids)) @@ -168,6 +176,7 @@ on-blur (fn [_] (reset! disable-drag false)) + on-detach-token (mf/use-fn (mf/deps ids) @@ -207,7 +216,7 @@ (seq strokes) [:> h/sortable-container* {} (for [[index value] (d/enumerate (:strokes values []))] - [:> stroke-row* {:key (dm/str "stroke-" index) + [:> stroke-row* {:key (dm/str "stroke-" index "-" (hash applied-tokens)) :stroke value :title (tr "workspace.options.stroke-color") :index index @@ -224,9 +233,10 @@ :on-stroke-cap-start-change on-stroke-cap-start-change :on-stroke-cap-end-change on-stroke-cap-end-change :on-stroke-cap-switch on-stroke-cap-switch - :applied-tokens applied-tokens + :applied-tokens (when (= 0 index) applied-tokens) :on-detach-token on-detach-token :on-remove on-remove + :on-toggle-visibility on-toggle-visibility :on-reorder handle-reorder :disable-drag disable-drag :on-focus on-focus diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss index 874beac840..ab0f622225 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/stroke.scss @@ -23,7 +23,6 @@ .stroke-content { grid-column: span 8; - display: flex; flex-direction: column; gap: var(--sp-m); @@ -42,6 +41,7 @@ .stroke-multiple-label { @include t.use-typography("body-small"); + display: flex; align-items: center; flex-grow: 1; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss index 50ba70a209..d8fabe2295 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.scss @@ -16,7 +16,8 @@ } .element-set-content { - @include deprecated.flexColumn; + @include deprecated.flex-column; + margin: deprecated.$s-4 0 0 0; } @@ -26,8 +27,9 @@ } .attr-name { - @include deprecated.bodySmallTypography; - @include deprecated.twoLineTextEllipsis; + @include deprecated.body-small-typography; + @include deprecated.two-line-text-ellipsis; + width: deprecated.$s-88; margin: auto deprecated.$s-4; margin-right: 0; @@ -36,8 +38,9 @@ } .attr-input { - @extend .input-element; - @include deprecated.bodySmallTypography; + @extend %input-element; + @include deprecated.body-small-typography; + width: deprecated.$s-124; } @@ -47,11 +50,13 @@ } .attr-action-btn { - @extend .button-tertiary; + @extend %button-tertiary; + width: deprecated.$s-28; height: deprecated.$s-32; + svg { - @extend .button-icon; + @extend %button-icon; } } @@ -61,7 +66,8 @@ } .attr-title { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + font-size: deprecated.$fs-10; text-transform: uppercase; margin-inline-start: deprecated.$s-4; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs index 38b624b2cc..504856d22e 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.cljs @@ -10,27 +10,31 @@ [app.common.data :as d] [app.common.types.text :as txt] [app.common.uuid :as uuid] + [app.config :as cf] [app.main.data.workspace.libraries :as dwl] [app.main.data.workspace.shapes :as dwsh] [app.main.data.workspace.shortcuts :as sc] [app.main.data.workspace.texts :as dwt] [app.main.data.workspace.texts-v3 :as dwt-v3] + [app.main.data.workspace.tokens.application :as dwta] [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] [app.main.store :as st] - [app.main.ui.components.radio-buttons :refer [radio-button radio-buttons]] [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.radio-buttons :refer [radio-buttons*]] + [app.main.ui.ds.controls.shared.searchable-options-dropdown :refer [searchable-options-dropdown*]] [app.main.ui.ds.foundations.assets.icon :as i] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] - [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* - typography-entry]] + [app.main.ui.workspace.sidebar.options.menus.token-typography-row :refer [token-typography-row*]] + [app.main.ui.workspace.sidebar.options.menus.typography :refer [text-options* typography-entry]] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] + [app.util.object :as obj] [app.util.text.content :as content] [app.util.text.ui :as txu] [app.util.timers :as ts] @@ -38,9 +42,40 @@ [potok.v2.core :as ptk] [rumext.v2 :as mf])) +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Constants +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(def ^:private token-typography-row-enabled? + "True when the token-typography-row feature flag is enabled. + Evaluated once at module load time; cf/flags is immutable after startup." + (contains? cf/flags :token-typography-row)) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Sub-components +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + (mf/defc text-align-options* - [{:keys [values on-change on-blur] :as props}] - (let [{:keys [text-align]} values + [{:keys [values on-change on-blur]}] + (let [options + (mf/with-memo [] + [{:value "left" + :id "text-align-left" + :label (tr "workspace.options.text-options.text-align-left") + :icon i/text-align-left} + {:value "center" + :id "text-align-center" + :label (tr "workspace.options.text-options.text-align-center") + :icon i/text-align-center} + {:value "right" + :id "text-align-right" + :label (tr "workspace.options.text-options.text-align-right") + :icon i/text-align-right} + {:value "justify" + :id "text-align-justify" + :label (tr "workspace.options.text-options.text-align-justify") + :icon i/text-justify}]) + handle-change (mf/use-fn (mf/deps on-change on-blur) @@ -48,60 +83,57 @@ (on-change {:text-align value}) (when (some? on-blur) (on-blur))))] - ;; --- Align [:div {:class (stl/css :align-options)} - [:& radio-buttons {:selected text-align - :on-change handle-change - :name "align-text-options"} - [:& radio-button {:value "left" - :id "text-align-left" - :title (tr "workspace.options.text-options.text-align-left") - :icon i/text-align-left}] - [:& radio-button {:value "center" - :id "text-align-center" - :title (tr "workspace.options.text-options.text-align-center") - :icon i/text-align-center}] - [:& radio-button {:value "right" - :id "text-align-right" - :title (tr "workspace.options.text-options.text-align-right") - :icon i/text-align-right}] - [:& radio-button {:value "justify" - :id "text-align-justify" - :title (tr "workspace.options.text-options.text-align-justify") - :icon i/text-justify}]]])) + [:> radio-buttons* {:selected (:text-align values) + :on-change handle-change + :name "align-text-options" + :options options}]])) (mf/defc text-direction-options* - [{:keys [values on-change on-blur] :as props}] - (let [direction (:text-direction values) + [{:keys [values on-change on-blur]}] + (let [direction (:text-direction values) + options + (mf/with-memo [] + [{:value "ltr" + :id "ltr-text-direction" + :label (tr "workspace.options.text-options.direction-ltr") + :icon i/text-ltr} + {:value "rtl" + :id "rtl-text-direction" + :label (tr "workspace.options.text-options.direction-rtl") + :icon i/text-rtl}]) + handle-change (mf/use-fn (mf/deps on-change on-blur direction) (fn [value] - (let [dir (if (= value direction) - "none" - value)] - (on-change {:text-direction dir}) - (when (some? on-blur) (on-blur)))))] + (on-change {:text-direction (if (= value direction) "none" value)}) + (when (some? on-blur) (on-blur))))] [:div {:class (stl/css :text-direction-options)} - [:& radio-buttons {:selected direction - :on-change handle-change - :name "text-direction-options"} - [:& radio-button {:value "ltr" - :type "checkbox" - :id "ltr-text-direction" - :title (tr "workspace.options.text-options.direction-ltr") - :icon i/text-ltr}] - [:& radio-button {:value "rtl" - :type "checkbox" - :id "rtl-text-direction" - :title (tr "workspace.options.text-options.direction-rtl") - :icon i/text-rtl}]]])) + [:> radio-buttons* {:selected direction + :on-change handle-change + :name "text-direction-options" + :options options}]])) (mf/defc vertical-align* - [{:keys [values on-change on-blur] :as props}] - (let [{:keys [vertical-align]} values - vertical-align (or vertical-align "top") + [{:keys [values on-change on-blur]}] + (let [vertical-align (or (:vertical-align values) "top") + options + (mf/with-memo [] + [{:value "top" + :id "vertical-text-align-top" + :label (tr "workspace.options.text-options.align-top") + :icon i/text-top} + {:value "center" + :id "vertical-text-align-center" + :label (tr "workspace.options.text-options.align-middle") + :icon i/text-middle} + {:value "bottom" + :id "vertical-text-align-bottom" + :label (tr "workspace.options.text-options.align-bottom") + :icon i/text-bottom}]) + handle-change (mf/use-fn (mf/deps on-change on-blur) @@ -110,29 +142,31 @@ (when (some? on-blur) (on-blur))))] [:div {:class (stl/css :vertical-align-options)} - [:& radio-buttons {:selected vertical-align - :on-change handle-change - :name "vertical-align-text-options"} - [:& radio-button {:value "top" - :id "vertical-text-align-top" - :title (tr "workspace.options.text-options.align-top") - :icon i/text-top}] - [:& radio-button {:value "center" - :id "vertical-text-align-center" - :title (tr "workspace.options.text-options.align-middle") - :icon i/text-middle}] - [:& radio-button {:value "bottom" - :id "vertical-text-align-bottom" - :title (tr "workspace.options.text-options.align-bottom") - :icon i/text-bottom}]]])) + [:> radio-buttons* {:selected vertical-align + :on-change handle-change + :name "vertical-align-text-options" + :options options}]])) (mf/defc grow-options* - [{:keys [ids values on-blur] :as props}] - (let [grow-type (:grow-type values) - + [{:keys [ids values on-blur]}] + (let [grow-type (:grow-type values) editor-instance (mf/deref refs/workspace-editor) + options + (mf/with-memo [] + [{:value "fixed" + :id "text-fixed-grow" + :label (tr "workspace.options.text-options.grow-fixed") + :icon i/text-fixed} + {:value "auto-width" + :id "text-auto-width-grow" + :label (tr "workspace.options.text-options.grow-auto-width") + :icon i/text-auto-width} + {:value "auto-height" + :id "text-auto-height-grow" + :label (tr "workspace.options.text-options.grow-auto-height") + :icon i/text-auto-height}]) - handle-change-grow + handle-change (mf/use-fn (mf/deps ids on-blur editor-instance) (fn [value] @@ -156,79 +190,182 @@ (when (some? on-blur) (on-blur))))] [:div {:class (stl/css :grow-options)} - [:& radio-buttons {:selected (d/name grow-type) - :on-change handle-change-grow - :name "grow-text-options"} - [:& radio-button {:value "fixed" - :id "text-fixed-grow" - :title (tr "workspace.options.text-options.grow-fixed") - :icon i/text-fixed}] - [:& radio-button {:value "auto-width" - :id "text-auto-width-grow" - :title (tr "workspace.options.text-options.grow-auto-width") - :icon i/text-auto-width}] - [:& radio-button {:value "auto-height" - :id "text-auto-height-grow" - :title (tr "workspace.options.text-options.grow-auto-height") - :icon i/text-auto-height}]]])) + [:> radio-buttons* {:selected (d/name grow-type) + :on-change handle-change + :name "grow-text-options" + :options options}]])) (mf/defc text-decoration-options* - [{:keys [values on-change on-blur] :as props}] - (let [text-decoration (or (:text-decoration values) "none") + [{:keys [values on-change on-blur token-applied]}] + (let [text-decoration (some-> (:text-decoration values) d/name) + options + (mf/with-memo [token-applied] + [{:value "underline" + :id "underline-text-decoration" + :disabled (and token-typography-row-enabled? (some? token-applied)) + :label (tr "workspace.options.text-options.underline" (sc/get-tooltip :underline)) + :icon i/text-underlined} + {:value "line-through" + :id "line-through-text-decoration" + :disabled (and token-typography-row-enabled? (some? token-applied)) + :label (tr "workspace.options.text-options.strikethrough" (sc/get-tooltip :line-through)) + :icon i/text-stroked}]) + handle-change (mf/use-fn - (mf/deps on-change on-blur text-decoration) + (mf/deps on-change on-blur) (fn [value] - (let [decoration (if (= value text-decoration) - "none" - value)] - (on-change {:text-decoration decoration}) - (when (some? on-blur) (on-blur)))))] + (on-change {:text-decoration value}) + (when (some? on-blur) + (on-blur))))] + [:div {:class (stl/css :text-decoration-options)} - [:& radio-buttons {:selected text-decoration - :on-change handle-change - :name "text-decoration-options"} - [:& radio-button {:value "underline" - :type "checkbox" - :id "underline-text-decoration" - :title (tr "workspace.options.text-options.underline" (sc/get-tooltip :underline)) - :icon i/text-underlined}] - [:& radio-button {:value "line-through" - :type "checkbox" - :id "line-through-text-decoration" - :title (tr "workspace.options.text-options.strikethrough" (sc/get-tooltip :line-through)) - :icon i/text-stroked}]]])) + [:> radio-buttons* {:selected (if (= text-decoration "none") + nil + text-decoration) + :on-change handle-change + :name "text-decoration-options" + :disabled (and token-typography-row-enabled? (some? token-applied)) + :allow-empty true + :options options}]])) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Helpers +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; + +(defn- get-option-by-name [options name] + (let [options (if (delay? options) (deref options) options)] + (d/seek #(= name (get % :name)) options))) + +(defn- resolve-delay [tokens] + (if (delay? tokens) @tokens tokens)) + +(defn- find-token-by-id [tokens id] + (->> (:typography tokens) + (d/seek #(= (:id %) (uuid/uuid id))))) + +(defn- check-props [n-props o-props] + (and (identical? (unchecked-get n-props "ids") + (unchecked-get o-props "ids")) + (identical? (unchecked-get n-props "appliedTokens") + (unchecked-get o-props "appliedTokens")) + (identical? (unchecked-get n-props "values") + (unchecked-get o-props "values")))) + +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; +;; Main component +;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;; (mf/defc text-menu* - {::mf/wrap [mf/memo]} - [{:keys [ids type values] :as props}] + {::mf/wrap [#(mf/memo' % check-props)]} + [{:keys [ids type values applied-tokens]}] - (let [file-id (mf/use-ctx ctx/current-file-id) - typographies (mf/deref refs/workspace-file-typography) - libraries (mf/deref refs/files) - label (case type - :multiple (tr "workspace.options.text-options.title-selection") - :group (tr "workspace.options.text-options.title-group") - (tr "workspace.options.text-options.title")) + (let [file-id (mf/use-ctx ctx/current-file-id) + typographies (mf/deref refs/workspace-file-typography) + libraries (mf/deref refs/files) + ;; --- UI state + menu-state* (mf/use-state {:main-menu true + :more-options false}) + menu-state (deref menu-state*) + main-menu-open? (:main-menu menu-state) + more-options-open? (:more-options menu-state) - state* (mf/use-state {:main-menu true - :more-options false}) - state (deref state*) - main-menu-open? (:main-menu state) - more-options-open? (:more-options state) + token-dropdown-open* (mf/use-state false) + token-dropdown-open? (deref token-dropdown-open*) + ;; --- Applied token + applied-token-name (:typography applied-tokens) + current-token-name* (mf/use-state applied-token-name) + current-token-name (deref current-token-name*) + + ;; --- Available tokens + active-tokens (mf/use-ctx ctx/active-tokens-by-type) + typography-tokens (mf/with-memo [active-tokens] (csu/filter-tokens-for-input active-tokens :typography)) + + ;; --- Dropdown + listbox-id (mf/use-id) + nodes-ref (mf/use-ref nil) + dropdown-ref (mf/use-ref nil) + + dropdown-options + (mf/with-memo [typography-tokens] + (csu/get-token-dropdown-options typography-tokens nil)) + + selected-token-id* + (mf/use-state #(when current-token-name + (:id (get-option-by-name dropdown-options current-token-name)))) + selected-token-id (deref selected-token-id*) + + ;; --- Typography + typography-id (:typography-ref-id values) + typography-file-id (:typography-ref-file values) + + typography + (mf/with-memo [typography-id typography-file-id file-id libraries] + (cond + (and typography-id + (not= typography-id :multiple) + (not= typography-file-id file-id)) + (-> (get-in libraries [typography-file-id :data :typographies typography-id]) + (assoc :file-id typography-file-id)) + + (and typography-id + (not= typography-id :multiple) + (= typography-file-id file-id)) + (get typographies typography-id))) + + ;; --- Helpers + multiple? (->> values vals (d/seek #(= % :multiple))) + + apply-token! + (mf/use-fn + (mf/deps ids typography-tokens) + (fn [id] + (let [token (find-token-by-id (resolve-delay typography-tokens) id)] + (reset! selected-token-id* id) + (reset! token-dropdown-open* false) + (st/emit! + (dwta/apply-token {:shape-ids ids + :attributes #{:typography} + :token token + :on-update-shape dwta/update-typography}))))) + label + (mf/with-memo [type] + (case type + :multiple (tr "workspace.options.text-options.title-selection") + :group (tr "workspace.options.text-options.title-group") + (tr "workspace.options.text-options.title"))) + set-option-ref + (mf/use-fn + (fn [node] + (let [state (d/nilv (mf/ref-val nodes-ref) #js {}) + id (dom/get-data node "id")] + (mf/set-ref-val! nodes-ref (obj/set! state id node)) + (fn [] + (let [state (d/nilv (mf/ref-val nodes-ref) #js {})] + (mf/set-ref-val! nodes-ref (obj/unset! state id))))))) + + ;; --- Toggles toggle-main-menu (mf/use-fn - (mf/deps main-menu-open?) - #(swap! state* assoc-in [:main-menu] (not main-menu-open?))) + #(swap! menu-state* update :main-menu not)) toggle-more-options (mf/use-fn - (mf/deps more-options-open?) - #(swap! state* assoc-in [:more-options] (not more-options-open?))) + #(swap! menu-state* update :more-options not)) - typography-id (:typography-ref-id values) - typography-file-id (:typography-ref-file values) + toggle-token-dropdown + (mf/use-fn + #(swap! token-dropdown-open* not)) + + ;; --- Event handlers + on-option-click + (mf/use-fn + (mf/deps apply-token!) + (fn [event] + (dom/stop-propagation event) + (let [id (dom/get-data (dom/get-current-target event) "id")] + (apply-token! id)))) emit-update! (mf/use-fn @@ -247,42 +384,24 @@ (fn [attrs] (emit-update! ids attrs))) - typography - (mf/with-memo [values file-id libraries] - (cond - (and typography-id - (not= typography-id :multiple) - (not= typography-file-id file-id)) - (-> libraries - (get-in [typography-file-id :data :typographies typography-id]) - (assoc :file-id typography-file-id)) - - (and typography-id - (not= typography-id :multiple) - (= typography-file-id file-id)) - (get typographies typography-id))) - on-convert-to-typography - (fn [_] - (let [set-values (-> (d/without-nils values) - (select-keys - (d/concat-vec txt/text-font-attrs - txt/text-spacing-attrs - txt/text-transform-attrs))) - typography (merge txt/default-typography set-values) - typography (dwt/generate-typography-name typography) - id (uuid/next)] - (st/emit! (dwl/add-typography (assoc typography :id id) false)) - (emit-update! ids - {:typography-ref-id id - :typography-ref-file file-id}))) + (mf/use-fn + (mf/deps values ids file-id emit-update!) + (fn [_] + (let [set-values (-> (d/without-nils values) + (select-keys (d/concat-vec txt/text-font-attrs + txt/text-spacing-attrs + txt/text-transform-attrs))) + typography (-> (merge txt/default-typography set-values) + (dwt/generate-typography-name)) + id (uuid/next)] + (st/emit! (dwl/add-typography (assoc typography :id id) false)) + (emit-update! ids {:typography-ref-id id :typography-ref-file file-id})))) handle-detach-typography (mf/use-fn (mf/deps on-change) - (fn [] - (on-change {:typography-ref-file nil - :typography-ref-id nil}))) + #(on-change {:typography-ref-file nil :typography-ref-id nil})) handle-change-typography (mf/use-fn @@ -290,77 +409,116 @@ (fn [changes] (st/emit! (dwl/update-typography (merge typography changes) file-id)))) + detach-token + (mf/use-fn + (fn [token-name] + (st/emit! (dwta/unapply-token {:token-name token-name + :attributes #{:typography} + :shape-ids ids})))) + expand-stream (mf/with-memo [] - (->> st/stream - (rx/filter (ptk/type? :expand-text-more-options)))) + (->> st/stream (rx/filter (ptk/type? :expand-text-more-options)))) - multiple? (->> values vals (d/seek #(= % :multiple))) + on-text-blur + (mf/use-fn + (fn [] + (ts/schedule + 100 + (fn [] + (when (not= "INPUT" (-> (dom/get-active) dom/get-tag-name)) + (dom/focus! (txu/get-text-editor-content))))))) - opts (mf/props - {:ids ids - :values values - :on-change on-change - :show-recent true - :on-blur - (fn [] - (ts/schedule - 100 - (fn [] - (when (not= "INPUT" (-> (dom/get-active) (dom/get-tag-name))) - (let [node (txu/get-text-editor-content)] - (dom/focus! node))))))})] + common-props (mf/props + {:ids ids + :values values + :on-change on-change + :show-recent true + :on-blur on-text-blur})] (hooks/use-stream expand-stream - #(swap! state* assoc-in [:more-options] true)) + #(swap! menu-state* assoc :more-options true)) - [:section {:class (stl/css :element-set) - :aria-label "Text section"} + (mf/with-effect [applied-token-name] + (reset! current-token-name* applied-token-name)) + + (mf/with-effect [applied-token-name dropdown-options] + (reset! selected-token-id* + (when applied-token-name + (:id (get-option-by-name dropdown-options applied-token-name))))) + + (mf/with-effect [token-dropdown-open?] + (when token-dropdown-open? + (ts/schedule 0 #(some-> (mf/ref-val dropdown-ref) dom/focus!)))) + + [:section {:class (stl/css :element-set) + :aria-label (tr "workspace.options.text-options.text-section")} [:div {:class (stl/css :element-title)} [:> title-bar* {:collapsable true :collapsed (not main-menu-open?) :on-collapsed toggle-main-menu :title label :class (stl/css :title-spacing-text)} - (when (and (not typography) (not multiple?)) - [:> icon-button* {:variant "ghost" - :aria-label (tr "labels.options") - :on-click on-convert-to-typography - :icon i/add}])]] + [:* + (when (and token-typography-row-enabled? (some? (resolve-delay typography-tokens)) (not typography)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "ds.inputs.numeric-input.open-token-list-dropdown") + :on-click toggle-token-dropdown + :tooltip-placement "top-left" + :icon i/tokens}]) + (when (and (not typography) (not multiple?) (not applied-token-name)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.convert-to-typography") + :on-click on-convert-to-typography + :tooltip-placement "top-left" + :icon i/add}])]]] (when main-menu-open? [:div {:class (stl/css :element-content)} (cond + (and token-typography-row-enabled? current-token-name) + [:> token-typography-row* {:token-name current-token-name + :detach-token detach-token + :active-tokens (resolve-delay typography-tokens)}] + typography - [:& typography-entry {:file-id typography-file-id + [:& typography-entry {:file-id typography-file-id :typography typography - :local? (= typography-file-id file-id) - :on-detach handle-detach-typography - :on-change handle-change-typography}] + :local? (= typography-file-id file-id) + :on-detach handle-detach-typography + :on-change handle-change-typography}] (= typography-id :multiple) [:div {:class (stl/css :multiple-typography)} [:span {:class (stl/css :multiple-text)} (tr "workspace.libraries.text.multiple-typography")] - [:div {:class (stl/css :multiple-typography-button) - :on-click handle-detach-typography - :title (tr "workspace.libraries.text.multiple-typography-tooltip")} - deprecated-icon/detach]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.libraries.text.multiple-typography-tooltip") + :on-click handle-detach-typography + :icon i/detach}]] :else - [:> text-options* opts]) + [:> text-options* common-props]) [:div {:class (stl/css :text-align-options)} - [:> text-align-options* opts] - [:> grow-options* opts] - [:> icon-button* {:variant "ghost" - :aria-label (tr "labels.options") + [:> text-align-options* common-props] + [:> grow-options* common-props] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.options") :data-testid "text-align-options-button" - :on-click toggle-more-options - :icon i/menu}]] + :on-click toggle-more-options + :icon i/menu}]] (when more-options-open? - [:div {:class (stl/css :text-decoration-options)} - [:> vertical-align* opts] - [:> text-decoration-options* opts] - [:> text-direction-options* opts]])])])) + [:div {:class (stl/css :text-decoration-options)} + [:> vertical-align* common-props] + [:> text-decoration-options* (mf/spread-props common-props {:token-applied current-token-name})] + [:> text-direction-options* common-props]])]) + + (when (and token-typography-row-enabled? token-dropdown-open?) + [:> searchable-options-dropdown* {:on-click on-option-click + :id listbox-id + :options (resolve-delay dropdown-options) + :selected selected-token-id + :align "right" + :ref set-option-ref}])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss index aa97d0ee32..e589c4dd79 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/text.scss @@ -9,6 +9,8 @@ .element-set { @include sidebar.option-grid-structure; + + position: relative; } .element-title { @@ -16,27 +18,31 @@ } .element-content { + @include deprecated.flex-column; + grid-column: span 8; - @include deprecated.flexColumn; margin-top: deprecated.$s-4; } .multiple-typography { - @extend .mixed-bar; + @extend %mixed-bar; } .multiple-text { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + flex-grow: 1; color: var(--input-foreground-color-active); } .multiple-typography-button { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-32; width: deprecated.$s-28; + svg { - @extend .button-icon; + @extend %button-icon; } } diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs new file mode 100644 index 0000000000..9d9944a1d2 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.cljs @@ -0,0 +1,87 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns app.main.ui.workspace.sidebar.options.menus.token-typography-row + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.data.macros :as dm] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] + [app.main.ui.ds.tooltip :refer [tooltip*]] + [app.util.i18n :as i18n :refer [tr]] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(mf/defc resolved-value-tooltip* + {::mf/private true} + [{:keys [token-name resolved-value]}] + [:* + [:span (dm/str (tr "workspace.tokens.token-name") ": ")] + [:span {:class (stl/css :token-name-tooltip)} token-name] + [:div + [:span (tr "inspect.tabs.styles.token-resolved-value")] + [:ul + (for [[k v] resolved-value] + [:li {:key (d/name k)} + [:span {:class (stl/css :resolved-key)} (str "- " (d/name k) ": ")] + [:span {:class (stl/css :resolved-value)} + (if (sequential? v) + (str/join ", " (map #(dm/str "\"" % "\"") v)) + (dm/str v))]])]]]) + +(mf/defc token-typography-row* + [{:keys [token-name active-tokens detach-token] :rest props}] + (let [element-ref (mf/use-ref nil) + id (mf/use-id) + + token (->> (:typography active-tokens) + (d/seek #(= (:name %) token-name))) + + has-errors (some? (:errors token)) + display-name (or (:name token) token-name) + + resolved-value (:resolved-value token) + not-active (or (nil? token) + (empty? (:typography active-tokens))) + on-detach + (mf/use-fn + (mf/deps display-name) + (fn [] + (detach-token display-name))) + + tooltip-content (cond + not-active + (tr "not-active-token.no-name") + has-errors + (tr "options.deleted-token") + :else + (mf/html [:> resolved-value-tooltip* {:token-name token-name + :resolved-value resolved-value}]))] + + [:div {:class (stl/css-case :token-typography-row true + :token-typography-row-with-errors has-errors + :token-typography-row-not-active not-active)} + (when (or has-errors not-active) + [:div {:class (stl/css :error-dot)}]) + [:> icon* {:icon-id i/text-typography + :class (stl/css :icon)}] + [:> tooltip* {:content tooltip-content + :trigger-ref element-ref + :class (stl/css :token-tooltip) + :id id} + + [:span {:aria-labelledby (dm/str id) + :class (stl/css :token-name) + :ref element-ref} + display-name]] + + [:> icon-button* {:variant "action" + :aria-label (tr "token-actions.detach-token") + :tooltip-class (stl/css :detach-button) + :tooltip-placement "top-left" + :on-click on-detach + :icon i/detach}]])) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss new file mode 100644 index 0000000000..ac89984a55 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/token_typography_row.scss @@ -0,0 +1,101 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/_utils.scss" as *; + +.token-typography-row { + --token-typography-row-background-color: var(--color-background-tertiary); + --token-typography-row-foreground-color: var(--color-token-foreground); + --token-typography-row-border-color: var(--color-token-border); + + display: flex; + align-items: center; + position: relative; + gap: var(--sp-xs); + block-size: $sz-32; + min-inline-size: 0; + inline-size: 100%; + padding: var(--sp-s); + margin-inline-end: 0; + background: var(--token-typography-row-background-color); + border: $b-1 solid var(--token-typography-row-border-color); + border-radius: $br-8; + + &:hover { + --token-typography-row-background-color: var(--color-token-background); + --token-typography-row-foreground-color: var(--color-foreground-primary); + --token-typography-row-border-color: var(--color-token-accent); + } +} + +.token-typography-row-with-errors, +.token-typography-row-not-active { + --token-typography-row-background-color: var(--color-background-primary); + --token-typography-row-foreground-color: var(--color-foreground-secondary); + --token-typography-row-border-color: var(--color-token-border); + + &:hover { + --token-typography-row-background-color: var(--color-background-primary); + --token-typography-row-foreground-color: var(--color-foreground-secondary); + --token-typography-row-border-color: var(--color-token-background); + } +} + +.icon { + display: block; + min-inline-size: $sz-16; + color: var(--token-typography-row-foreground-color); +} + +.token-name { + @include t.use-typography("body-small"); + @include text-ellipsis; + + color: var(--token-typography-row-foreground-color); + block-size: $sz-32; + flex: 1; + line-height: $sz-32; +} + +.token-tooltip { + min-inline-size: 0; + inline-size: inherit; +} + +.token-name-tooltip { + color: var(--color-foreground-primary); +} + +.detach-button { + flex-shrink: 0; + inline-size: 0; + max-inline-size: 0; + overflow: hidden; + opacity: 0; + pointer-events: none; +} + +.token-typography-row:hover .detach-button { + inline-size: auto; + opacity: 1; + pointer-events: auto; + max-inline-size: $sz-32; +} + +.error-dot { + inline-size: px2rem(4); + block-size: px2rem(4); + border-radius: 50%; + background-color: var(--color-foreground-error); + margin-inline-start: var(--sp-xs); + position: absolute; + inset-inline-end: px2rem(1); + inset-block-start: px2rem(5); +} 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 3a27f8aefa..4266716f7b 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 @@ -16,6 +16,8 @@ [app.main.data.common :as dcm] [app.main.data.fonts :as fts] [app.main.data.shortcuts :as dsc] + [app.main.data.workspace.libraries :as dwl] + [app.main.data.workspace.undo :as dwu] [app.main.features :as features] [app.main.fonts :as fonts] [app.main.refs :as refs] @@ -26,7 +28,9 @@ [app.main.ui.components.search-bar :refer [search-bar*]] [app.main.ui.components.select :refer [select]] [app.main.ui.context :as ctx] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.icons :as deprecated-icon] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -79,8 +83,10 @@ :on-click on-click} [:div {:class (stl/css-case :font-item true :selected is-current)} - [:span {:class (stl/css :label)} (:name font)] - [:span {:class (stl/css :icon)} (when is-current deprecated-icon/tick)]]])) + [:span {:class (stl/css :font-item-label)} (:name font)] + (when is-current + [:> icon* {:icon-id i/tick + :size "s"}])]])) (declare row-renderer) @@ -188,7 +194,7 @@ :placeholder (tr "workspace.options.search-font")}] (when (and recent-fonts show-recent) [:section {:class (stl/css :show-recent)} - [:p {:class (stl/css :title)} (tr "workspace.options.recent-fonts")] + [:p {:class (stl/css :header-title)} (tr "workspace.options.recent-fonts")] (for [[idx font] (d/enumerate recent-fonts)] [:> font-item* {:key (dm/str "font-" idx) :font font @@ -313,10 +319,11 @@ (some? font) [:* - [:span {:class (stl/css :name)} + [:span {:class (stl/css :font-option-name)} (:name font)] - [:span {:class (stl/css :icon)} - deprecated-icon/arrow]] + [:> icon* {:icon-id i/arrow-down + :class (stl/css :dropdown-icon) + :size "s"}]] :else (tr "dashboard.fonts.deleted-placeholder"))] @@ -463,9 +470,29 @@ (mf/defc typography-advanced-options {::mf/wrap [mf/memo]} - [{:keys [visible? typography editable? name-input-ref on-close on-change on-name-blur local? navigate-to-library on-key-down]}] - (let [ref (mf/use-ref nil) - font-data (fonts/get-font-data (:font-id typography))] + [{:keys [visible? typography editable? name-input-ref on-close on-change on-name-blur + local? navigate-to-library on-key-down file-id is-asset?]}] + (let [ref (mf/use-ref nil) + font-data (fonts/get-font-data (:font-id typography)) + typography-id (:id typography) + show-actions? (and is-asset? editable?) + + on-delete + (mf/use-fn + (mf/deps typography-id file-id on-close) + (fn [] + (on-close) + (let [undo-id (js/Symbol)] + (st/emit! (dwu/start-undo-transaction undo-id) + (dwl/delete-typography typography-id) + (dwl/sync-file file-id file-id :typographies typography-id) + (dwu/commit-undo-transaction undo-id))))) + + on-duplicate + (mf/use-fn + (mf/deps file-id typography-id) + (fn [] + (st/emit! (dwl/duplicate-typography file-id typography-id))))] (fonts/ensure-loaded! (:font-id typography)) (mf/use-effect @@ -497,9 +524,21 @@ :on-key-down on-key-down :on-blur on-name-blur}] - [:div {:class (stl/css :action-btn) - :on-click on-close} - deprecated-icon/tick]] + [:div {:class (stl/css :action-btns)} + (when show-actions? + [:* + [:> icon-button* {:variant "action" + :aria-label (tr "workspace.assets.duplicate") + :on-click on-duplicate + :icon i/clipboard}] + [:> icon-button* {:variant "action" + :aria-label (tr "workspace.assets.delete") + :on-click on-delete + :icon i/delete}]]) + [:> icon-button* {:variant "action" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/tick}]]] [:> text-options* {:values typography :on-change on-change @@ -519,9 +558,10 @@ (:name typography)] [:span {:class (stl/css :typography-font)} (:name font-data)] - [:div {:class (stl/css :action-btn) - :on-click on-close} - deprecated-icon/menu]] + [:> icon-button* {:variant "ghost" + :aria-label (tr "labels.close") + :on-click on-close + :icon i/menu}]] [:div {:class (stl/css :info-row)} [:span {:class (stl/css :info-label)} (tr "workspace.assets.typography.font-style")] @@ -544,13 +584,13 @@ [:span {:class (stl/css :info-content)} (:text-transform typography)]] (when-not local? - [:a {:class (stl/css :link-btn) - :on-click navigate-to-library} + [:> button* {:variant "secondary" + :on-click navigate-to-library} (tr "workspace.assets.typography.go-to-edit")])])]))) (mf/defc typography-entry {::mf/wrap-props false} - [{:keys [file-id typography local? selected? on-click on-change on-detach on-context-menu editing? renaming? focus-name? external-open*]}] + [{:keys [file-id typography local? selected? on-click on-change on-detach on-context-menu editing? renaming? focus-name? external-open* is-asset?]}] (let [name-input-ref (mf/use-ref) read-only? (mf/use-ctx ctx/workspace-read-only?) editable? (and local? (not read-only?)) @@ -650,12 +690,14 @@ (:name font-data)])]) [:div {:class (stl/css :element-set-actions)} (when ^boolean on-detach - [:button {:class (stl/css :element-set-actions-button) - :on-click on-detach} - deprecated-icon/detach]) - [:button {:class (stl/css :menu-btn) - :on-click on-open} - deprecated-icon/menu]]] + [:> icon-button* {:variant "action" + :aria-label (tr "settings.detach") + :on-click on-detach + :icon i/detach}]) + [:> icon-button* {:variant "action" + :aria-label (tr "labels.open") + :on-click on-open + :icon i/menu}]]] [:& typography-advanced-options {:visible? open? @@ -666,5 +708,7 @@ :on-change on-change :on-name-blur on-name-blur :on-key-down on-key-down + :file-id file-id + :is-asset? is-asset? :local? local? :navigate-to-library navigate-to-library}]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss index 506fb58b34..d01a04d186 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/typography.scss @@ -5,58 +5,59 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/typography.scss" as t; +@use "ds/_sizes.scss" as *; +@use "ds/_borders.scss" as *; +@use "ds/mixins.scss" as *; +@use "ds/_utils.scss" as *; .typography-entry { + --actions-visibility: hidden; + --typography-entry-background-color: var(--color-background-tertiary); + --typography-entry-foreground-color: var(--color-foreground-primary); + --typography-entry-border-color: transparent; + display: flex; flex-direction: row; align-items: center; - height: deprecated.$s-32; - width: 100%; - border-radius: deprecated.$br-8; - background-color: var(--assets-item-background-color); - color: var(--assets-item-name-foreground-color-hover); + block-size: $sz-32; + inline-size: 100%; + border-radius: $br-8; + background-color: var(--typography-entry-background-color); + color: var(--typography-entry-foreground-color); + border: $b-1 solid var(--typography-entry-border-color); + &:hover, &:focus-within { - background-color: var(--assets-item-background-color-hover); - color: var(--assets-item-name-foreground-color-hover); + --typography-entry-background-color: var(--color-background-quaternary); + --typography-entry-foreground-color: var(--color-foreground-primary); } &.selected { - border: deprecated.$s-1 solid var(--assets-item-border-color); - } - - .element-set-actions { - display: flex; - visibility: hidden; - .element-set-actions-button, - .menu-btn { - @extend .button-tertiary; - height: deprecated.$s-32; - width: deprecated.$s-28; - svg { - @extend .button-icon; - } - &:active { - background-color: transparent; - } - } + --typography-entry-border-color: var(--color-accent-primary); } &:hover { - background-color: var(--assets-item-background-color-hover); + --typography-entry-background-color: var(--color-background-quaternary); + .element-set-actions { - visibility: visible; + --actions-visibility: visible; } } } +.element-set-actions { + display: flex; + visibility: var(--actions-visibility); +} + .typography-selection-wrapper { display: grid; - grid-template-columns: deprecated.$s-24 auto 1fr; + grid-template-columns: $sz-24 auto 1fr; flex: 1; - height: 100%; - width: 100%; - padding: 0 deprecated.$s-12; + block-size: 100%; + inline-size: 100%; + padding: 0 var(--sp-m); &.is-selectable { cursor: pointer; @@ -64,372 +65,333 @@ } .typography-sample { - @include deprecated.flexCenter; - min-width: deprecated.$s-24; - height: deprecated.$s-32; - color: var(--assets-item-name-foreground-color); + display: flex; + justify-content: center; + align-items: center; + min-inline-size: $sz-24; + block-size: $sz-32; + color: var(--color-foreground-secondary); } .typography-name, .typography-font { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; + @include t.use-typography("body-small"); + @include text-ellipsis; + display: block; align-self: center; - margin-left: deprecated.$s-6; + margin-inline-start: px2rem(6); } .typography-name { - color: var(--assets-item-name-foreground-color); + color: var(--color-foreground-primary); } .typography-font { - color: var(--assets-item-name-foreground-color-rest); + color: var(--color-foreground-secondary); } .font-name-wrapper { - @include deprecated.bodySmallTypography; + --font-name-wrapper-foreground-color: var(--color-foreground-primary); + --font-name-wrapper-background-color: var(--color-background-tertiary); + --font-name-wrapper-border-color: transparent; + + @include t.use-typography("body-small"); + display: flex; align-items: center; - height: deprecated.$s-32; - width: 100%; - border-radius: deprecated.$br-8; - border: deprecated.$s-1 solid transparent; + block-size: $sz-32; + inline-size: 100%; + border-radius: $br-8; + border: $b-1 solid var(--font-name-wrapper-border-color); box-sizing: border-box; - background-color: var(--assets-item-background-color); - margin-bottom: deprecated.$s-4; - padding: deprecated.$s-8 deprecated.$s-0 deprecated.$s-8 deprecated.$s-12; + background-color: var(--font-name-wrapper-background-color); + margin-block-end: var(--sp-s); + padding: var(--sp-s) 0 var(--sp-s) var(--sp-m); - .typography-sample-input { - @include deprecated.flexCenter; - width: deprecated.$s-24; - height: 100%; - font-size: deprecated.$fs-16; - color: var(--assets-item-name-foreground-color-hover); - } - .adv-typography-name { - @include deprecated.removeInputStyle; - font-size: deprecated.$fs-12; - color: var(--input-foreground-color-active); - flex-grow: 1; - padding-left: deprecated.$s-6; - margin: 0; - } - .action-btn { - @extend .button-tertiary; - @include deprecated.flexCenter; - width: deprecated.$s-28; - height: deprecated.$s-28; - svg { - @extend .button-icon-small; - stroke: var(--icon-foreground); - } - &:active { - background-color: transparent; - } - } &:focus-within { - border: deprecated.$s-1 solid var(--input-border-color-active); - .adv-typography-name { - color: var(--input-foreground-color-active); - } + --font-name-wrapper-border-color: var(--color-accent-primary); } + &:hover { - background-color: var(--assets-item-background-color-hover); + --font-name-wrapper-background-color: var(--color-background-quaternary); } } +.typography-sample-input { + display: flex; + justify-content: center; + align-items: center; + inline-size: $sz-24; + block-size: 100%; + font-size: px2rem(16); + color: var(--color-foreground-primary); +} + +.adv-typography-name { + font-size: px2rem(12); + color: var(--font-name-wrapper-foreground-color); + flex-grow: 1; + padding-inline-start: px2rem(6); + margin: 0; + border: none; + background: none; + outline: none; +} + +.action-btns { + display: flex; + align-items: center; + gap: var(--sp-xxs); +} + .advanced-options-wrapper { - height: 100%; - width: 100%; - background-color: var(--assets-title-background-color); + block-size: 100%; + inline-size: 100%; + background-color: var(--color-background-primary); } .typography-info-wrapper { - @include deprecated.flexColumn; - margin-bottom: deprecated.$s-12; - .typography-name-wrapper { - @extend .asset-element; - display: grid; - grid-template-columns: deprecated.$s-24 auto 1fr deprecated.$s-28; - flex: 1; - height: deprecated.$s-32; - width: 100%; - padding: 0 0 0 deprecated.$s-12; - background-color: var(--assets-item-background-color-hover); - margin-bottom: deprecated.$s-4; - .typography-sample { - @include deprecated.flexCenter; - min-width: deprecated.$s-24; - font-size: deprecated.$fs-16; - height: deprecated.$s-32; - padding: 0; - color: var(--assets-item-name-foreground-color-hover); - } - .typography-name { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; - display: flex; - align-items: center; - justify-content: flex-start; - margin-left: deprecated.$s-6; - color: var(--assets-item-name-foreground-color-hover); - } - .typography-font { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; - margin-left: deprecated.$s-6; - display: flex; - align-items: center; - justify-content: flex-start; - min-width: 0; - color: var(--assets-item-name-foreground-color); - } - .action-btn { - @extend .button-tertiary; - width: deprecated.$s-28; - height: deprecated.$s-32; - svg { - @extend .button-icon; - } - &:active { - background-color: transparent; - } - } - } + display: flex; + flex-direction: column; + gap: var(--sp-xxs); + margin-block-end: var(--sp-m); +} - .info-row { - display: grid; - grid-template-columns: 50% 50%; - height: deprecated.$s-32; - --calculated-width: calc(var(--right-sidebar-width) - deprecated.$s-48); - padding-left: deprecated.$s-2; - .info-label { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; - width: calc(var(--calculated-width) / 2); - padding-top: deprecated.$s-8; - color: var(--assets-item-name-foreground-color); - } - .info-content { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; - padding-top: deprecated.$s-8; - width: calc(var(--calculated-width) / 2); - color: var(--assets-item-name-foreground-color-hover); - } - } +.typography-name-wrapper { + @extend %asset-element; - .link-btn { - @include deprecated.uppercaseTitleTipography; - @extend .button-secondary; - width: 100%; - height: deprecated.$s-32; - border-radius: deprecated.$br-8; - &:hover { - background-color: var(--button-secondary-background-color-hover); - color: var(--button-secondary-foreground-color-hover); - border: deprecated.$s-1 solid var(--button-secondary-border-color-hover); - text-decoration: none; - svg { - stroke: var(--button-secondary-foreground-color-hover); - } - } - &:focus { - background-color: var(--button-secondary-background-color-focus); - color: var(--button-secondary-foreground-color-focus); - border: deprecated.$s-1 solid var(--button-secondary-border-color-focus); - svg { - stroke: var(--button-secondary-foreground-color-focus); - } - } - } + display: grid; + grid-template-columns: $sz-24 auto 1fr $sz-28; + flex: 1; + block-size: $sz-32; + inline-size: 100%; + padding: 0 0 0 var(--sp-m); + background-color: var(--color-background-quaternary); + margin-block-end: var(--sp-xs); +} + +.typography-sample { + display: flex; + justify-content: center; + align-items: center; + min-inline-size: $sz-24; + font-size: px2rem(16); + block-size: $sz-32; + padding: 0; + color: var(--color-foreground-primary); +} + +.typography-name, +.typography-font { + @include t.use-typography("body-small"); + @include text-ellipsis; + + display: flex; + align-items: center; + justify-content: flex-start; + margin-inline-start: px2rem(6); + min-inline-size: 0; + color: var(--color-foreground-primary); +} + +.info-row { + --calculated-width: calc(var(--right-sidebar-width) - $sz-48); + + display: grid; + grid-template-columns: 50% 50%; + block-size: $sz-32; + padding-left: var(--sp-xs); +} + +.info-label, +.info-content { + @include t.use-typography("body-small"); + @include text-ellipsis; + + inline-size: calc(var(--calculated-width) / 2); + padding-block-start: var(--sp-s); + color: var(--color-foreground-primary); } .text-options { - @include deprecated.flexColumn; - max-width: var(--options-width); + display: flex; + flex-direction: column; + gap: var(--sp-xs); + max-inline-size: var(--options-width); &:not(.text-options-full-size) { position: relative; } - .font-option { - @include deprecated.bodySmallTypography; - @extend .asset-element; - padding: deprecated.$s-8 0 deprecated.$s-8 deprecated.$s-8; - cursor: pointer; +} - .name { - flex-grow: 1; - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - width: 100%; - } - .icon { - @include deprecated.flexCenter; - height: deprecated.$s-28; - width: deprecated.$s-28; - svg { - @extend .button-icon-small; - stroke: var(--icon-foreground); - transform: rotate(90deg); - } - } - } - .font-modifiers { +.font-option { + @include t.use-typography("body-small"); + @extend %asset-element; + + padding: var(--sp-s); + cursor: pointer; +} + +.font-option-name { + @include text-ellipsis; + + flex-grow: 1; + inline-size: 100%; +} + +.font-modifiers { + display: flex; + gap: var(--sp-xs); +} + +.font-size-options { + @extend %asset-element; + @include t.use-typography("body-small"); + + flex-grow: 1; + inline-size: px2rem(60); + margin: 0; + padding: 0; + border: $b-1 solid var(--color-background-tertiary); + position: relative; +} + +.font-variant-options { + padding: 0; + flex-grow: 2; +} + +.typography-variations { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.spacing-options { + display: flex; + align-items: center; + gap: var(--sp-xs); +} + +.line-height, +.letter-spacing { + @extend %input-element; + @include t.use-typography("body-small"); + + .icon { display: flex; - gap: deprecated.$s-4; - .font-size-options { - @extend .asset-element; - @include deprecated.bodySmallTypography; - flex-grow: 1; - width: deprecated.$s-60; - margin: 0; - padding: 0; - border: deprecated.$s-1 solid var(--input-border-color); - position: relative; + justify-content: center; + align-items: center; + inline-size: $sz-28; - .icon { - @include deprecated.flexCenter; - height: deprecated.$s-28; - min-width: deprecated.$s-28; - svg { - @extend .button-icon-small; - stroke: var(--icon-foreground); - transform: rotate(90deg); - } - } - } - .font-variant-options { - padding: 0; - flex-grow: 2; - } - } - .typography-variations { - @include deprecated.flexRow; - .spacing-options { - @include deprecated.flexRow; - .line-height, - .letter-spacing { - @extend .input-element; - @include deprecated.bodySmallTypography; - .icon { - @include deprecated.flexCenter; - width: deprecated.$s-28; - svg { - @extend .button-icon-small; - } - } - } - } - .text-transform { - @extend .asset-element; - width: fit-content; - padding: 0; - background-color: var(--radio-btns-background-color); - &:hover { - background-color: var(--radio-btns-background-color); - } + svg { + @extend %button-icon-small; } } } +.text-transform { + @extend %asset-element; + + inline-size: fit-content; + padding: 0; + background-color: var(--color-background-tertiary); +} + .font-size-select { - @include deprecated.removeInputStyle; - @include deprecated.bodySmallTypography; - height: deprecated.$s-32; - height: 100%; - width: 100%; + @include t.use-typography("body-small"); + + block-size: 100%; + inline-size: 100%; margin: 0; - padding: deprecated.$s-8; + padding: var(--sp-s); + border: none; + background: none; + outline: none; + .numeric-input { - @extend .input-base; - @include deprecated.bodySmallTypography; + @extend %input-base; + @include t.use-typography("body-small"); + padding: 0; } } .font-selector { - @include deprecated.flexCenter; + display: flex; + justify-content: center; + align-items: center; position: absolute; top: 0; left: 0; right: 0; - height: 100%; - width: 100%; - z-index: deprecated.$z-index-4; + block-size: 100%; + inline-size: 100%; + z-index: var(--z-index-dropdown); } .show-recent { - border-radius: deprecated.$br-8 deprecated.$br-8 0 0; - background: var(--dropdown-background-color); - border: deprecated.$s-1 solid var(--color-background-quaternary); + border-radius: $br-8 $br-8 0 0; + background: var(--color-background-tertiary); + border: $b-1 solid var(--color-background-quaternary); border-block-end: none; } .font-selector-dropdown { - width: 100%; + inline-size: 100%; + &:not(.font-selector-dropdown-full-size) { display: flex; flex-direction: column; flex-grow: 1; - height: 100%; - } - .header { - display: grid; - row-gap: deprecated.$s-2; - .title { - @include deprecated.uppercaseTitleTipography; - color: var(--title-foreground-color); - margin: 0; - padding: deprecated.$s-12; - } + block-size: 100%; } } +.header { + display: grid; + row-gap: var(--sp-xxs); +} + +.header-title { + @include t.use-typography("headline-small"); + + color: var(--color-foreground-secondary); + margin: 0; + padding: var(--sp-m); +} + .font-wrapper { - padding-bottom: deprecated.$s-4; + padding-bottom: var(--sp-xs); cursor: pointer; } .font-item { - @extend .asset-element; - margin-bottom: deprecated.$s-4; - border-radius: deprecated.$br-8; - display: flex; - .icon { - @include deprecated.flexCenter; - height: deprecated.$s-28; - width: deprecated.$s-28; - svg { - @extend .button-icon-small; - stroke: var(--icon-foreground); - } - } - &.selected { - color: var(--assets-item-name-foreground-color-hover); - .icon { - svg { - stroke: var(--assets-item-name-foreground-color-hover); - } - } - } + @extend %asset-element; - .label { - @include deprecated.bodySmallTypography; - @include deprecated.textEllipsis; - flex-grow: 1; - min-width: 0; + margin-bottom: var(--sp-xs); + border-radius: $br-8; + display: flex; + + &.selected { + color: var(--color-foreground-primary); } } +.font-item-label { + @include t.use-typography("body-small"); + @include text-ellipsis; + + flex-grow: 1; + min-inline-size: 0; +} + .font-selector-dropdown-full-size { - height: calc(100vh - 48px); // TODO: ugly hack :( Find a workaround for this. + block-size: calc(100vh - 48px); // TODO: ugly hack :( Find a workaround for this. display: grid; grid-template-rows: auto 1fr; - padding: deprecated.$s-2 deprecated.$s-12 deprecated.$s-12 deprecated.$s-12; + padding: var(--sp-xxs) var(--sp-m) var(--sp-m) var(--sp-m); } .fonts-list { @@ -437,22 +399,23 @@ display: flex; flex-direction: column; flex: 1 1 auto; - min-height: 100%; - width: 100%; - height: 100%; - padding: deprecated.$s-2; - border-radius: deprecated.$br-8; - background-color: var(--dropdown-background-color); + min-block-size: 100%; + inline-size: 100%; + block-size: 100%; + padding: var(--sp-xxs); + border-radius: $br-8; + background-color: var(--color-background-tertiary); overflow: hidden; + &:not(.fonts-list-full-size) { - margin-block-start: deprecated.$s-2; + margin-block-start: var(--sp-xxs); } } .fonts-list-full-size { border-start-start-radius: 0; border-start-end-radius: 0; - border: deprecated.$s-1 solid var(--color-background-quaternary); + border: $b-1 solid var(--color-background-quaternary); // TODO: this should belong to typography-entry , but atm we don't have a clear // way of accessing whether we are in fullsize mode or not @@ -460,3 +423,7 @@ padding-inline-end: 0; } } + +.dropdown-icon { + color: var(--color-foreground-secondary); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss index 5d77b269d2..98b71bd436 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.scss @@ -7,11 +7,11 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; max-width: deprecated.$s-888; width: 100%; @@ -31,7 +31,7 @@ } .modal-close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .rule-list { diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs index 740750b0cf..c5876940b8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/page.cljs @@ -24,17 +24,39 @@ (-> (l/key :background) (l/derived refs/workspace-page))) +(def ^:private ref:pixel-grid-color + (-> (l/key :pixel-grid-color) + (l/derived refs/workspace-page))) + +(def ^:private ref:pixel-grid-opacity + (-> (l/key :pixel-grid-opacity) + (l/derived refs/workspace-page))) + +;; Default pixel grid color shown in the picker when the user hasn't +;; set a custom one. Matches the legacy hardcoded CSS variable. +(def ^:private default-pixel-grid-color "#0070E4") + (mf/defc options* {::mf/wrap [mf/memo]} [] (let [background (mf/deref ref:background-color) + grid-color (mf/deref ref:pixel-grid-color) + grid-alpha (mf/deref ref:pixel-grid-opacity) + on-change (mf/use-fn #(st/emit! (dw/change-canvas-color %))) on-open (mf/use-fn #(st/emit! (dwu/start-undo-transaction :options))) on-close (mf/use-fn #(st/emit! (dwu/commit-undo-transaction :options))) + on-grid-change + (mf/use-fn #(st/emit! (dw/change-pixel-grid-color %))) + color (mf/with-memo [background] {:color (d/nilv background clr/canvas) - :opacity 1})] + :opacity 1}) + + grid (mf/with-memo [grid-color grid-alpha] + {:color (d/nilv grid-color default-pixel-grid-color) + :opacity (d/nilv grid-alpha 0.2)})] [:div {:class (stl/css :element-set)} [:div {:class (stl/css :element-title)} @@ -52,5 +74,15 @@ :on-change on-change :origin :canvas :on-open on-open + :on-close on-close}] + + [:> color-row* + {:disable-gradient true + :disable-image true + :title "Pixel grid color" + :color grid + :on-change on-grid-change + :origin :pixel-grid + :on-open on-open :on-close on-close}]]])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs index 1e0e3bb67b..229deb9460 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs @@ -11,7 +11,6 @@ [app.common.data.macros :as dm] [app.common.types.color :as clr] [app.common.types.shape.attrs :refer [default-color]] - [app.common.types.token :as tk] [app.config :as cfg] [app.main.data.modal :as modal] [app.main.data.workspace.colors :as dwc] @@ -27,6 +26,7 @@ [app.main.ui.ds.utilities.swatch :refer [swatch*]] [app.main.ui.formats :as fmt] [app.main.ui.hooks :as h] + [app.main.ui.workspace.tokens.management.forms.controls.utils :as csu] [app.util.color :as uc] [app.util.dom :as dom] [app.util.i18n :as i18n :refer [tr]] @@ -98,16 +98,16 @@ token-name-ref (mf/use-ref nil) swatch-tooltip-content (cond not-active - (tr "ds.inputs.token-field.no-active-color.token-option") + (tr "not-active-token.no-name") has-errors - (tr "color-row.token-color-row.deleted-token") + (tr "options.deleted-token") :else (tr "workspace.tokens.resolved-value" resolved)) name-tooltip-content (cond not-active - (tr "ds.inputs.token-field.no-active-color.token-option") + (tr "not-active-token.no-name") has-errors - (tr "color-row.token-color-row.deleted-token") + (tr "options.deleted-token") :else #(mf/html [:div @@ -138,7 +138,7 @@ [:div {:class (stl/css :token-actions)} [:> icon-button* {:variant "action" - :aria-label (tr "ds.inputs.token-field.detach-token") + :aria-label (tr "token-actions.detach-token") :on-click on-detach-token :icon i/detach}] [:> icon-button* @@ -177,12 +177,9 @@ active-tokens* (mf/use-ctx ctx/active-tokens-by-type) - tokens (mf/with-memo [active-tokens* origin] - (let [origin (if (= :color-selection origin) :fill origin)] - (delay - (-> (deref active-tokens*) - (select-keys (get tk/tokens-by-input origin)) - (not-empty))))) + tokens (mf/with-memo [active-tokens* origin] + (csu/filter-tokens-for-input active-tokens* origin)) + on-focus' (mf/use-fn (mf/deps on-focus) @@ -355,10 +352,6 @@ :dnd-over-top (= (:over dprops) :top) :dnd-over-bot (= (:over dprops) :bot))] - (when (= applied-token :multiple) - ;; (js/console.trace "color-row*") - (prn "color-row*" index color applied-token)) - (mf/with-effect [color prev-color disable-picker] (when (and (not disable-picker) (not= prev-color color)) (modal/update-props! :colorpicker {:data (parse-color color)}))) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss index 8e88e54ed6..39f56950d8 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/color_row.scss @@ -54,8 +54,10 @@ --color-name-wrapper-background-color: var(--color-background-tertiary); --color-name-wrapper-foreground-color: var(--color-foreground-primary); --color-name-wrapper-boder-color: var(--color-background-tertiary); + @include t.use-typography("body-small"); - @include textEllipsis; + @include text-ellipsis; + display: flex; align-items: center; flex-grow: 1; @@ -66,13 +68,13 @@ padding: 0; margin-inline-end: 0; border: $b-1 solid var(--color-name-wrapper-boder-color); - border-radius: $br-8; background-color: var(--color-name-wrapper-background-color); color: var(--color-name-wrapper-foreground-color); border-radius: $br-8 0 0 $br-8; &.no-opacity { border-radius: $br-8; + .color-input-wrapper { border-radius: $br-8; } @@ -86,6 +88,7 @@ .detach-btn { display: grid; } + &.editing { --color-name-wrapper-background-color: var(--color-background-primary); } @@ -101,6 +104,7 @@ &:focus-within { --color-name-wrapper-background-color: var(--color-background-tertiary); --color-name-wrapper-boder-color: var(--color-accent-primary); + &:hover { --color-name-wrapper-background-color: var(--color-background-quaternary); } @@ -108,6 +112,7 @@ &.editing { --color-name-wrapper-background-color: var(--color-background-primary); + &:hover { --color-name-wrapper-boder-color: var(--color-accent-primary); } @@ -121,6 +126,7 @@ .color-input-wrapper { @include t.use-typography("body-small"); + display: flex; align-items: center; flex-grow: 1; @@ -135,7 +141,8 @@ .color-name { @include t.use-typography("body-small"); - @include textEllipsis; + @include text-ellipsis; + flex-grow: 1; padding-inline: px2rem(6); border-radius: $br-8; @@ -150,13 +157,15 @@ padding: 0 var(--sp-xxs) 0 var(--sp-s); border-radius: $br-8 0 0 $br-8; background-color: transparent; + &:hover { background-color: transparent; } } .color-input { - @include textEllipsis; + @include text-ellipsis; + border: none; background: none; outline: none; @@ -167,6 +176,7 @@ padding: 0 0 0 px2rem(6); border-radius: $br-8; color: var(--input-foreground-color-active); + &[disabled] { opacity: 0.5; pointer-events: none; @@ -182,8 +192,14 @@ --opacity-input-boder-color: var(--color-background-tertiary); @include t.use-typography("body-small"); + display: flex; align-items: center; + + &:not(:focus-within) { + cursor: ew-resize; + } + block-size: $sz-32; inline-size: px2rem(60); padding-inline-start: var(--sp-xs); @@ -198,6 +214,7 @@ .detach-btn { display: grid; } + &.editing { --opacity-input-background-color: var(--color-background-primary); } @@ -213,6 +230,7 @@ &:focus-within { --opacity-input-background-color: var(--color-background-tertiary); --opacity-input-boder-color: var(--color-accent-primary); + &:hover { --opacity-input-background-color: var(--color-background-quaternary); } @@ -220,6 +238,7 @@ &.editing { --opacity-input-background-color: var(--color-background-primary); + &:hover { --opacity-input-boder-color: var(--color-accent-primary); } @@ -227,12 +246,12 @@ } .opacity-input { - @include textEllipsis; + @include text-ellipsis; + block-size: $sz-28; min-inline-size: $sz-28; flex-grow: 1; inline-size: 100%; - padding: 0; border-radius: 0 $br-8 $br-8 0; border: none; background: none; @@ -240,6 +259,12 @@ margin: var(--sp-xxs) 0; padding: 0 0 0 px2rem(6); color: var(--color-foreground-primary); + cursor: ew-resize; + + &:focus { + cursor: text; + } + &[disabled] { opacity: 0.5; pointer-events: none; @@ -263,6 +288,7 @@ --token-color-wrapper-foreground-color: var(--color-token-foreground); --token-color-wrapper-border-color: var(--color-token-border); --token-actions-display: none; + display: grid; grid-template-columns: auto 1fr auto; gap: var(--sp-xs); @@ -274,6 +300,7 @@ background: var(--token-color-wrapper-background-color); border: $b-1 solid var(--token-color-wrapper-border-color); border-radius: $br-8; + &:hover { --token-color-wrapper-background-color: var(--color-token-background); --token-color-wrapper-foreground-color: var(--color-foreground-primary); @@ -287,6 +314,7 @@ --token-color-wrapper-background-color: var(--color-background-primary); --token-color-wrapper-foreground-color: var(--color-foreground-secondary); --token-color-wrapper-border-color: var(--color-token-border); + &:hover { --token-color-wrapper-background-color: var(--color-background-primary); --token-color-wrapper-foreground-color: var(--color-foreground-secondary); @@ -297,7 +325,8 @@ .token-name { @include t.use-typography("body-small"); - @include textEllipsis; + @include text-ellipsis; + color: var(--token-color-wrapper-foreground-color); block-size: $sz-32; line-height: $sz-32; diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss index f13404d03e..84109fe162 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/shadow_row.scss @@ -80,8 +80,9 @@ .shadow-advanced-spread, .shadow-advanced-offset-y { // TODO remove this input by changing the input to DS component - @extend .input-element; + @extend %input-element; @include t.use-typography("body-small"); + .shadow-advanced-label { padding-inline-start: var(--sp-s); inline-size: px2rem(60); diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs index 64f34b7927..0e6a28612a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs @@ -16,8 +16,10 @@ [app.main.ui.components.reorder-handler :refer [reorder-handler*]] [app.main.ui.components.select :refer [select]] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.controls.select :refer [select*]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.hooks :as h] + [app.main.ui.workspace.sidebar.options.common :as soc] [app.main.ui.workspace.sidebar.options.menus.input-wrapper-tokens :refer [numeric-input-wrapper*]] [app.main.ui.workspace.sidebar.options.rows.color-row :refer [color-row*]] [app.util.i18n :as i18n :refer [tr]] @@ -38,6 +40,7 @@ on-stroke-cap-start-change on-stroke-cap-end-change on-stroke-cap-switch + on-toggle-visibility disable-drag on-focus on-blur @@ -47,7 +50,10 @@ select-on-focus ids]}] - (let [token-numeric-inputs + (let [hidden? (:hidden stroke) + hidden? (if (nil? hidden?) false hidden?) + + token-numeric-inputs (features/use-feature "tokens/numeric-input") on-drop @@ -92,14 +98,13 @@ on-width-change (mf/use-fn - (mf/deps index on-stroke-width-change) + (mf/deps index on-stroke-width-change ids) (fn [value] - (if (or (string? value) (number? value)) - (on-stroke-width-change index value) - - (st/emit! (dwta/toggle-token {:token (first value) - :attrs #{:stroke-width} - :shape-ids ids}))))) + (soc/emit-value-or-token + value + #(on-stroke-width-change index %) + ids + #{:stroke-width}))) stroke-alignment (or (:stroke-alignment stroke) :center) @@ -108,9 +113,9 @@ (d/concat-vec (when (= :multiple stroke-alignment) [{:value :multiple :label "--"}]) - [{:value :center :label (tr "workspace.options.stroke.center")} - {:value :inner :label (tr "workspace.options.stroke.inner")} - {:value :outer :label (tr "workspace.options.stroke.outer")}])) + [{:value :center :label (tr "workspace.options.stroke.center") :id "center" :icon "stroke-center"} + {:value :inner :label (tr "workspace.options.stroke.inner") :id "inner" :icon "stroke-inside"} + {:value :outer :label (tr "workspace.options.stroke.outer") :id "outer" :icon "stroke-outside"}])) on-alignment-change (mf/use-fn @@ -122,10 +127,10 @@ (mf/deps ids) (fn [_ token] (st/emit! - (dwta/toggle-token {:token token - :attrs #{:stroke-color} - :shape-ids ids - :expand-with-children true})))) + (dwta/apply-token-from-input {:token token + :attrs #{:stroke-color} + :shape-ids ids + :expand-with-children true})))) stroke-style (or (:stroke-style stroke) :solid) @@ -134,10 +139,10 @@ (d/concat-vec (when (= :multiple stroke-style) [{:value :multiple :label "--"}]) - [{:value :solid :label (tr "workspace.options.stroke.solid")} - {:value :dotted :label (tr "workspace.options.stroke.dotted")} - {:value :dashed :label (tr "workspace.options.stroke.dashed")} - {:value :mixed :label (tr "workspace.options.stroke.mixed")}])) + [{:value :solid :label (tr "workspace.options.stroke.solid") :id "solid" :icon "stroke-solid"} + {:value :dotted :label (tr "workspace.options.stroke.dotted") :id "dotted" :icon "stroke-dotted"} + {:value :dashed :label (tr "workspace.options.stroke.dashed") :id "dashed" :icon "stroke-dashed"} + {:value :mixed :label (tr "workspace.options.stroke.mixed") :id "mixed" :icon "stroke-mixed"}])) on-style-change (mf/use-fn @@ -164,7 +169,7 @@ (mf/use-fn (mf/deps on-detach-token) (fn [token] - (on-detach-token (first token) #{:stroke-width}))) + (on-detach-token token #{:stroke-width}))) stroke-caps-options [{:value nil :label (tr "workspace.options.stroke-cap.none")} @@ -181,10 +186,18 @@ on-cap-switch (mf/use-fn (mf/deps index on-stroke-cap-switch) - #(on-stroke-cap-switch index))] + #(on-stroke-cap-switch index)) + + on-toggle-visibility + (mf/use-fn + (mf/deps index on-toggle-visibility) + (fn [] + (when on-toggle-visibility + (on-toggle-visibility index))))] [:div {:class (stl/css-case :stroke-data true + :hidden hidden? :dnd-over-top (= (:over dprops) :top) :dnd-over-bot (= (:over dprops) :bot)) :aria-label (str "stroke-row-" index)} @@ -196,26 +209,37 @@ ;; Stroke Color ;; FIXME: memorize stroke color - [:> color-row* {:color (ctc/stroke->color stroke) - :index index - :title title - :on-change on-color-change-refactor - :on-detach on-color-detach - :on-remove on-remove - :disable-drag disable-drag - :applied-token (if (= index 0) - stroke-color-token - nil) - :on-detach-token on-detach-token-color - :on-token-change on-token-change - :on-focus on-focus - :origin :stroke-color - :select-on-focus select-on-focus - :on-blur on-blur}] + [:div {:class (stl/css :stroke-color-actions)} + [:> color-row* {:color (ctc/stroke->color stroke) + :index index + :title title + :on-change on-color-change-refactor + :on-detach on-color-detach + :disable-drag disable-drag + :applied-token (if (= index 0) + stroke-color-token + nil) + :on-detach-token on-detach-token-color + :on-token-change on-token-change + :on-focus on-focus + :origin :stroke-color + :select-on-focus select-on-focus + :on-blur on-blur}] + + (when (some? on-toggle-visibility) + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.stroke.toggle-stroke") + :on-click on-toggle-visibility + :icon (if hidden? "hide" "shown")}]) + + [:> icon-button* {:variant "ghost" + :aria-label (tr "workspace.options.stroke.remove-stroke") + :on-click on-remove + :icon i/remove}]] ;; Stroke Width, Alignment & Style - [:div {:class (stl/css :stroke-options)} - (if token-numeric-inputs + (if token-numeric-inputs + [:div {:class (stl/css :stroke-options-tokens)} [:> numeric-input-wrapper* {:on-change on-width-change :on-detach on-detach-token-width :icon i/stroke-size @@ -227,7 +251,25 @@ :property (tr "workspace.options.stroke-width") :applied-token (get applied-tokens :stroke-width) :value stroke-width}] + [:> select* {:default-selected (d/name stroke-alignment) + :options stroke-alignment-options + :variant "icon-only" + :data-testid "stroke.alignment" + :disabled (if (= :multiple hidden?) true hidden?) + :wrapper-class (stl/css :stroke-align-icon-select) + :on-change on-alignment-change}] + (when-not disable-stroke-style + [:> select* {:default-selected (d/name stroke-style) + :options stroke-style-options + :wrapper-class (stl/css :stroke-style-icon-select) + :data-testid "stroke.style" + :variant "icon-only" + :disabled (if (= :multiple hidden?) true hidden?) + :dropdown-alignment :right + :on-change on-style-change}])] + + [:div {:class (stl/css :stroke-options)} [:div {:class (stl/css :stroke-width-input) :title (tr "workspace.options.stroke-width")} [:> icon* {:icon-id i/stroke-size @@ -238,31 +280,35 @@ :on-change on-width-change :on-focus on-focus :select-on-focus select-on-focus - :on-blur on-blur}]]) + :on-blur on-blur}]] + [:div {:class (stl/css :stroke-alignment-select) + :data-testid "stroke.alignment"} + [:& select {:default-value stroke-alignment + :options stroke-alignment-options + :disabled hidden? + :on-change on-alignment-change}]] - [:div {:class (stl/css :stroke-alignment-select) - :data-testid "stroke.alignment"} - [:& select {:default-value stroke-alignment - :options stroke-alignment-options - :on-change on-alignment-change}]] - - (when-not disable-stroke-style - [:div {:class (stl/css :stroke-style-select) - :data-testid "stroke.style"} - [:& select {:default-value stroke-style - :options stroke-style-options - :on-change on-style-change}]])] + (when-not disable-stroke-style + [:div {:class (stl/css :stroke-style-select) + :data-testid "stroke.style"} + [:& select {:default-value stroke-style + :options stroke-style-options + :disabled hidden? + :on-change on-style-change}]])]) ;; Stroke Caps (when show-caps [:div {:class (stl/css :stroke-caps-options)} [:& select {:default-value (:stroke-cap-start stroke) :options stroke-caps-options + :disabled hidden? :on-change on-caps-start-change}] [:> icon-button* {:variant "secondary" :aria-label (tr "labels.switch") + :disabled hidden? :on-click on-cap-switch :icon i/switch}] [:& select {:default-value (:stroke-cap-end stroke) :options stroke-caps-options + :disabled hidden? :on-change on-caps-end-change}]])])) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss index 19f81ac9c2..c764e60f3f 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/options/rows/stroke_row.scss @@ -12,7 +12,6 @@ display: flex; flex-direction: column; gap: var(--sp-xs); - position: relative; --reorder-left-position: calc(-1 * var(--sp-l)); @@ -28,25 +27,40 @@ &.dnd-over-bot { --reorder-bottom-display: block; } + + &.hidden { + .stroke-options, + .stroke-options-tokens, + .stroke-caps-options { + opacity: 0.5; + pointer-events: none; + } + } +} + +.stroke-color-actions { + display: flex; + align-items: center; + + > :first-child { + flex: 1; + min-width: 0; + } } .stroke-options { @include sidebar.option-grid-structure; + align-items: center; } .stroke-width-input { grid-column: span 2; - // TODO replace with numeric-input* from DS - @extend .input-element; - + @extend %input-element; @include t.use-typography("body-small"); - padding-inline-start: var(--sp-xs); -} -.numeric-input-wrapper { - grid-column: span 2; + padding-inline-start: var(--sp-xs); } .stroke-alignment-select { @@ -62,3 +76,19 @@ grid-template-columns: 1fr auto 1fr; column-gap: var(--sp-xs); } + +.stroke-options-tokens { + @include sidebar.option-grid-structure; + + grid-template-columns: var(--three-columns-width) var(--grid-exception-input-width-small) var( + --grid-exception-input-width-small + ); +} + +.stroke-align-icon-select { + --dropdown-width: var(--four-columns-width); +} + +.stroke-style-icon-select { + --dropdown-width: var(--four-columns-width); +} diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs index 6d6815da66..4da35907b2 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/group.cljs @@ -102,7 +102,7 @@ [stroke-ids stroke-values stroke-tokens] (get-attrs shapes objects :stroke) - [text-ids text-values] + [text-ids text-values text-tokens] (get-attrs shapes objects :text) [layout-item-ids layout-item-values] @@ -171,7 +171,10 @@ [:> blur-menu* {:type type :ids blur-ids :values blur-values}]) (when-not (empty? text-ids) - [:> ot/text-menu* {:type type :ids text-ids :values text-values}]) + [:> ot/text-menu* {:type type + :ids text-ids + :values text-values + :applied-tokens text-tokens}]) (when-not (empty? svg-values) [:> svg-attrs-menu* {:ids ids :values svg-values}]) diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs index 4a810239f1..70827abf97 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/multiple.cljs @@ -255,6 +255,9 @@ (cond (= attr-group :measure) (select-measure-keys shape) :else (select-keys shape editable-attrs))) + shape-values (cond-> shape-values + (= attr-group :layer) + (update :hidden #(if (nil? %) false %))) new-token-acc (merge-token-values token-acc editable-attrs applied-tokens)] [(conj ids id) (merge-attrs values shape-values) @@ -385,7 +388,7 @@ [layer-ids layer-values layer-tokens] (get-attrs shapes objects :layer) - [text-ids text-values] + [text-ids text-values text-tokens] (get-attrs shapes objects :text) [constraint-ids constraint-values] @@ -478,7 +481,11 @@ [:> constraints-menu* {:ids constraint-ids :values constraint-values}]) (when-not (empty? text-ids) - [:> ot/text-menu* {:type type :ids text-ids :values text-values}]) + [:> ot/text-menu* + {:type type + :ids text-ids + :values text-values + :applied-tokens text-tokens}]) (when-not (empty? fill-ids) [:> fill/fill-menu* {:type type diff --git a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs index 96cc29b6ba..5825af579a 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/options/shapes/text.cljs @@ -179,6 +179,7 @@ [:> text-menu* {:ids ids :type type + :applied-tokens applied-tokens :values text-values}] [:> fill/fill-menu* diff --git a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss index 56376d9f02..8279a4970c 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/shortcuts.scss @@ -9,6 +9,7 @@ .shortcuts { display: grid; grid-template-rows: auto auto 1fr; + // TODO: Fix this once we start implementing the DS. // We should not be doign these hardcoded calc's. height: calc(100vh - #{deprecated.$s-60}); @@ -27,7 +28,8 @@ } .not-found { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--empty-message-foreground-color); margin: deprecated.$s-12; } @@ -43,7 +45,8 @@ .section-title, .subsection-title { - @include deprecated.uppercaseTitleTipography; + @include deprecated.uppercase-title-typography; + display: flex; align-items: center; margin: 0; @@ -64,9 +67,11 @@ text-transform: none; padding-left: deprecated.$s-12; } + .subsection-menu { margin-bottom: deprecated.$s-4; } + .sub-menu { margin-bottom: deprecated.$s-4; @@ -82,24 +87,29 @@ background-color: var(--pill-background-color); .command-name { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + margin-left: deprecated.$s-2; color: var(--pill-foreground-color); } + .keys { - @include deprecated.flexCenter; + @include deprecated.flex-center; + gap: deprecated.$s-2; color: var(--pill-foreground-color); .key { - @include deprecated.bodySmallTypography; - @include deprecated.flexCenter; + @include deprecated.body-small-typography; + @include deprecated.flex-center; + text-transform: capitalize; height: deprecated.$s-20; padding: deprecated.$s-2 deprecated.$s-6; border-radius: deprecated.$s-6; background-color: var(--menu-shortcut-background-color); } + .space { margin: 0 deprecated.$s-2; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs index 4e06a6aea6..06be0b5d94 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.cljs @@ -9,6 +9,7 @@ (:require [app.common.data :as d] [app.common.data.macros :as dm] + [app.common.types.page :as ctp] [app.main.data.common :as dcm] [app.main.data.helpers :as dsh] [app.main.data.modal :as modal] @@ -19,9 +20,8 @@ [app.main.ui.components.title-bar :refer [title-bar*]] [app.main.ui.context :as ctx] [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] - [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.assets.icon :as i :refer [icon*]] [app.main.ui.hooks :as hooks] - [app.main.ui.icons :as deprecated-icon] [app.main.ui.notifications.badge :refer [badge-notification]] [app.render-wasm.api :as wasm.api] [app.util.dom :as dom] @@ -51,8 +51,10 @@ each object change)" [page-id] (l/derived (fn [fdata] - (-> (dsh/get-page fdata page-id) - (dissoc :objects))) + (let [page (dsh/get-page fdata page-id)] + (-> page + (assoc :empty? (ctp/is-empty? page)) + (dissoc :objects)))) refs/workspace-data =)) @@ -63,32 +65,35 @@ (mf/defc page-item {::mf/wrap-props false} [{:keys [page index deletable? selected? editing? hovering? current-page-id]}] - (let [input-ref (mf/use-ref) - id (:id page) - delete-fn (mf/use-fn (mf/deps id) #(st/emit! (dw/delete-page id))) - navigate-fn (mf/use-fn (mf/deps id) #(st/emit! :interrupt (dcm/go-to-workspace :page-id id))) - read-only? (mf/use-ctx ctx/workspace-read-only?) + (let [input-ref (mf/use-ref) + id (:id page) + name (:name page "") + is-separator? (and (= "---" (str/trim name)) (:empty? page)) + delete-fn (mf/use-fn (mf/deps id) #(st/emit! (dw/delete-page id))) + navigate-fn (mf/use-fn (mf/deps id) #(st/emit! :interrupt (dcm/go-to-workspace :page-id id))) + read-only? (mf/use-ctx ctx/workspace-read-only?) on-click (mf/use-fn - (mf/deps id current-page-id) + (mf/deps id current-page-id is-separator?) (fn [] - ;; WASM page transitions: - ;; - Capture the current page (A) once - ;; - Show a blurred snapshot while the target page (B/C/...) renders - ;; - If the user clicks again during the transition, keep showing the original (A) snapshot - (if (and (features/active-feature? @st/state "render-wasm/v1") - (not= id current-page-id)) - (do - (-> (wasm.api/apply-canvas-blur) - (p/finally - (fn [] - ;; NOTE: it seems we need two RAF so the blur is actually applied and visible - ;; in the canvas :( - (timers/raf - (fn [] - (timers/raf navigate-fn))))))) - (navigate-fn)))) + (when-not is-separator? + ;; WASM page transitions: + ;; - Capture the current page (A) once + ;; - Show a blurred snapshot while the target page (B/C/...) renders + ;; - If the user clicks again during the transition, keep showing the original (A) snapshot + (if (and (features/active-feature? @st/state "render-wasm/v1") + (not= id current-page-id)) + (do + (-> (wasm.api/apply-canvas-blur) + (p/finally + (fn [] + ;; NOTE: it seems we need two RAF so the blur is actually applied and visible + ;; in the canvas :( + (timers/raf + (fn [] + (timers/raf navigate-fn))))))) + (navigate-fn))))) on-delete (mf/use-fn @@ -112,11 +117,14 @@ on-blur (mf/use-fn + (mf/deps id is-separator?) (fn [event] - (let [name (str/trim (dom/get-target-val event))] - (when-not (str/empty? name) - (st/emit! (dw/rename-page id name))) - (st/emit! (dw/stop-rename-page-item))))) + (let [new-name (str/trim (dom/get-target-val event))] + (if (str/empty? new-name) + (when is-separator? + (st/emit! (dw/delete-page id))) + (st/emit! (dw/rename-page id new-name)))) + (st/emit! (dw/stop-rename-page-item)))) on-key-down (mf/use-fn @@ -172,40 +180,49 @@ (dom/select-text! edit-input)) nil))) - [:li {:class (stl/css-case - :page-element true - :selected selected? - :dnd-over-top (= (:over dprops) :top) - :dnd-over-bot (= (:over dprops) :bot)) - :ref dref} - [:div {:class (stl/css-case - :element-list-body true - :hover hovering? - :selected selected?) - :data-testid (dm/str "page-" id) - :tab-index "0" - :on-click on-click - :on-double-click on-double-click - :on-context-menu on-context-menu} - [:div {:class (stl/css :page-icon)} - deprecated-icon/document] - - (if editing? - [:* - [:input {:class (stl/css :element-name) - :type "text" - :ref input-ref - :on-blur on-blur - :on-key-down on-key-down - :auto-focus true - :default-value (:name page "")}]] - [:* - [:span {:class (stl/css :page-name) :title (:name page) :data-testid "page-name"} - (:name page)] - [:div {:class (stl/css :page-actions)} - (when (and deletable? (not read-only?)) - [:button {:on-click on-delete} - deprecated-icon/delete])]])]])) + (let [selected? (and selected? (not is-separator?))] + [:li {:class (stl/css-case + :page-element true + :separator is-separator? + :selected selected? + :dnd-over-top (= (:over dprops) :top) + :dnd-over-bot (= (:over dprops) :bot)) + :ref dref} + [:div {:class (stl/css-case + :element-list-body true + :separator-body is-separator? + :hover (and hovering? (not is-separator?)) + :selected selected?) + :data-testid (dm/str "page-" id) + :tab-index "0" + :on-click on-click + :on-double-click on-double-click + :on-context-menu on-context-menu} + (if (and is-separator? (not editing?)) + [:div {:class (stl/css :page-separator) + :data-testid "page-separator"}] + [:* + (when-not is-separator? + [:div {:class (stl/css :page-icon)} + [:> icon* {:icon-id i/document :size "s"}]]) + (if editing? + [:input {:class (stl/css :element-name) + :type "text" + :ref input-ref + :on-blur on-blur + :on-key-down on-key-down + :auto-focus true + :default-value name}] + [:* + [:span {:class (stl/css :page-name) :title name :data-testid "page-name"} + name] + [:div {:class (stl/css :page-actions)} + (when (and deletable? (not read-only?)) + [:> icon-button* {:variant "ghost" + :aria-label (tr "modals.delete-page.title") + :on-click on-delete + :icon-size "s" + :icon i/delete}])]])])]]))) ;; --- Page Item Wrapper @@ -266,7 +283,6 @@ [:> title-bar* {:collapsable true :collapsed collapsed :on-collapsed on-toggle-collapsed - :all-clickable true :title (tr "workspace.sidebar.sitemap") :class (stl/css :title-spacing-sitemap)} diff --git a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss index 086af118e8..dd0370c5ed 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/sitemap.scss @@ -5,6 +5,7 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; +@use "ds/_borders.scss" as *; .sitemap { position: relative; @@ -29,6 +30,7 @@ border-top: deprecated.$s-2 solid var(--resize-area-border-color); background-color: var(--resize-area-background-color); cursor: ns-resize; + &:hover { border-color: var(--resize-area-border-color); } @@ -39,8 +41,7 @@ flex-direction: column; height: calc(-38px + var(--height, deprecated.$s-200)); width: var(--left-sidebar-width); - overflow-x: hidden; - overflow-y: overlay; + overflow: hidden auto; scrollbar-gutter: stable; .element-list { @@ -55,19 +56,24 @@ } .page-element { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + min-height: deprecated.$s-32; width: 100%; cursor: pointer; + &.dnd-over-top { border-top: deprecated.$s-1 solid var(--layer-row-foreground-color-drag); } + &.dnd-over-bot { border-bottom: deprecated.$s-1 solid var(--layer-row-foreground-color-drag); } + .dnd-over > .element-list-body { border: deprecated.$s-1 solid var(--layer-row-foreground-color-drag); } + .element-list-body { display: flex; align-items: center; @@ -76,51 +82,64 @@ padding: 0 deprecated.$s-12 0 0; transition: none; color: var(--layer-row-foreground-color); + .page-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + flex-grow: 1; padding-left: deprecated.$s-2; } + .page-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + height: deprecated.$s-32; width: deprecated.$s-24; padding: 0 deprecated.$s-4 0 deprecated.$s-8; + svg { - @extend .button-icon-small; - height: deprecated.$s-12; - width: deprecated.$s-12; + @extend %button-icon-small; + color: transparent; fill: none; stroke: var(--icon-foreground); } } + .page-actions { height: deprecated.$s-32; + display: flex; + align-items: center; + button { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + width: deprecated.$s-24; height: 100%; opacity: deprecated.$op-0; + svg { - @extend .button-icon-small; - height: deprecated.$s-12; - width: deprecated.$s-12; + @extend %button-icon-small; + color: transparent; fill: none; stroke: var(--icon-foreground); } } } + .element-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + color: var(--layer-row-foreground-color-focus); } + input.element-name { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - @include deprecated.removeInputStyle; + @include deprecated.text-ellipsis; + @include deprecated.body-small-typography; + @include deprecated.remove-input-style; + flex-grow: 1; height: deprecated.$s-28; max-width: calc(var(--parent-size) - (var(--depth) * var(--layer-indentation-size))); @@ -131,21 +150,25 @@ color: var(--layer-row-foreground-color); } } + &:active, &.on-drag { .element-list-body { color: var(--layer-row-foreground-color-drag); background-color: var(--layer-row-background-color-drag); + .page-actions button { svg { stroke: var(--layer-row-foreground-color-drag); } } + .page-icon svg { stroke: var(--layer-row-foreground-color-drag); } } } + &.selected, &.selected:hover { .element-list-body { @@ -153,16 +176,19 @@ background-color: var(--layer-row-background-color-selected); box-shadow: deprecated.$s-16 deprecated.$s-0 deprecated.$s-0 deprecated.$s-0 var(--layer-row-background-color-selected); + .page-actions button { svg { stroke: var(--layer-row-foreground-color-selected); } } + .page-icon svg { stroke: var(--layer-row-foreground-color-selected); } } } + &:hover, &.hover { .element-list-body { @@ -170,30 +196,37 @@ background-color: var(--layer-row-background-color-hover); box-shadow: deprecated.$s-16 deprecated.$s-0 deprecated.$s-0 deprecated.$s-0 var(--layer-row-background-color-hover); + .page-actions button { opacity: deprecated.$op-10; + svg { stroke: var(--layer-row-foreground-color-hover); } } + .page-icon svg { stroke: var(--layer-row-foreground-color-hover); } } } + &:focus { .element-list-body { color: var(--layer-row-foreground-color-focus); border: deprecated.$s-1 solid var(--layer-row-border-color-focus); outline: none; + .page-actions button { opacity: deprecated.$op-10; } } } + &:focus-within { .element-list-body { outline: none; + .page-actions button { opacity: deprecated.$op-10; } @@ -205,11 +238,13 @@ color: var(--layer-row-foreground-color-hidden); background-color: var(--layer-row-background-color-hidden); opacity: deprecated.$op-7; + .page-actions button { svg { stroke: var(--layer-row-foreground-color-hidden); } } + .page-icon svg { stroke: var(--layer-row-foreground-color-hidden); } @@ -217,8 +252,27 @@ } } +.element-list-body.separator-body { + height: auto; + min-height: var(--sp-xxxl); + padding: 0; +} + +.page-separator { + width: 100%; + height: $b-1; + margin: var(--sp-s); + background-color: var(--color-background-quaternary); +} + +.page-element.separator:hover .element-list-body, +.page-element.separator.hover .element-list-body { + color: var(--layer-row-foreground-color); + background-color: transparent; + box-shadow: none; +} + .title-spacing-sitemap { padding-inline-start: deprecated.$s-8; - margin-block-start: deprecated.$s-8; - margin-block-end: deprecated.$s-4; + margin-block: deprecated.$s-8 deprecated.$s-4; } diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs index 37edf428cd..0289f7c2f6 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.cljs +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.cljs @@ -11,7 +11,7 @@ [app.common.time :as ct] [app.common.uuid :as uuid] [app.config :as cfg] - [app.main.data.notifications :as ntf] + [app.main.data.event :as ev] [app.main.data.workspace.versions :as dwv] [app.main.refs :as refs] [app.main.store :as st] @@ -77,20 +77,49 @@ (assoc item :index index))) (reverse))) -(defn- open-restore-version-dialog - [origin id] - (st/emit! (ntf/dialog - :content (tr "workspace.versions.restore-warning") - :controls :inline-actions - :cancel {:label (tr "workspace.updates.dismiss") - :callback #(st/emit! (ntf/hide))} - :accept {:label (tr "labels.restore") - :callback #(st/emit! (dwv/restore-version id origin))} - :tag :restore-dialog))) +(defn- on-name-input-focus + [event] + (dom/select-text! (dom/get-target event))) + +(defn- extract-id-from-event + [event] + (-> event dom/get-current-target (dom/get-data "id") uuid/parse)) + +(defn- on-create-version + [] + (st/emit! (dwv/create-version))) + +(defn- on-edit-version + [id _event] + (st/emit! (dwv/update-versions-state {:editing id}))) + +(defn- on-cancel-version-edition + [_id _event] + (st/emit! (dwv/update-versions-state {:editing nil}))) + +(defn- on-rename-version + [id label] + (st/emit! (dwv/rename-version id label))) + +(defn- on-delete-version + [id] + (st/emit! (dwv/delete-version id))) + +(defn- on-pin-version + [id] + (st/emit! (dwv/pin-version id))) + +(defn- on-lock-version + [id] + (st/emit! (dwv/lock-version id))) + +(defn- on-unlock-version + [id] + (st/emit! (dwv/unlock-version id))) (mf/defc version-entry* {::mf/private true} - [{:keys [entry current-profile on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] + [{:keys [entry current-profile on-preview on-restore on-delete on-rename on-lock on-unlock on-edit on-cancel-edit is-editing]}] (let [show-menu? (mf/use-state false) profiles (mf/deref refs/profiles) @@ -108,6 +137,13 @@ (fn [event] (on-edit (:id entry) event))) + on-preview + (mf/use-fn + (mf/deps entry on-preview) + (fn [] + (when (fn? on-preview) + (on-preview (:id entry))))) + on-restore (mf/use-fn (mf/deps entry on-restore) @@ -136,11 +172,6 @@ (when on-unlock (on-unlock (:id entry))))) - on-name-input-focus - (mf/use-fn - (fn [event] - (dom/select-text! (dom/get-target event)))) - on-name-input-blur (mf/use-fn (mf/deps entry on-rename on-cancel-edit) @@ -191,6 +222,11 @@ :on-click on-edit} (tr "labels.rename")]) + [:li {:class (stl/css :menu-option) + :role "button" + :on-click on-preview} + (tr "workspace.versions.button.preview")] + [:li {:class (stl/css :menu-option) :role "button" :on-click on-restore} @@ -216,7 +252,7 @@ (tr "labels.delete")])])]])) (mf/defc snapshot-entry* - [{:keys [entry on-pin-snapshot on-restore-snapshot]}] + [{:keys [entry on-pin-snapshot on-restore-snapshot on-preview-snapshot]}] (let [open-menu* (mf/use-state nil) entry-ref (mf/use-ref nil) @@ -225,23 +261,22 @@ (mf/use-fn (mf/deps on-pin-snapshot) (fn [event] - (let [node (dom/get-current-target event) - id (-> node - (dom/get-data "id") - (uuid/parse))] - (when (fn? on-pin-snapshot) - (on-pin-snapshot id event))))) + (when (fn? on-pin-snapshot) + (on-pin-snapshot (extract-id-from-event event) event)))) on-restore-snapshot (mf/use-fn (mf/deps on-restore-snapshot) (fn [event] - (let [node (dom/get-current-target event) - id (-> node - (dom/get-data "id") - (uuid/parse))] - (when (fn? on-restore-snapshot) - (on-restore-snapshot id event))))) + (when (fn? on-restore-snapshot) + (on-restore-snapshot (extract-id-from-event event) event)))) + + on-preview-snapshot + (mf/use-fn + (mf/deps on-preview-snapshot) + (fn [event] + (when (fn? on-preview-snapshot) + (on-preview-snapshot (extract-id-from-event event) event)))) on-open-snapshot-menu (mf/use-fn @@ -266,6 +301,11 @@ :on-close #(reset! open-menu* nil)} [:ul {:class (stl/css :version-options-dropdown) :style {"--offset" (dm/str (:offset @open-menu*) "px")}} + [:li {:class (stl/css :menu-option) + :role "button" + :data-id (dm/str (:snapshot @open-menu*)) + :on-click on-preview-snapshot} + (tr "workspace.versions.button.preview")] [:li {:class (stl/css :menu-option) :role "button" :data-id (dm/str (:snapshot @open-menu*)) @@ -302,66 +342,50 @@ (= (:filter state) (:profile-id %))))) (group-snapshots))) - on-create-version + on-preview-version (mf/use-fn - (fn [] (st/emit! (dwv/create-version)))) + (fn [id] + (st/emit! (dwv/enter-preview id) + (ev/event {::ev/name "preview-version" + ::ev/origin "workspace:sidebar" + :type "pinned-version"})))) - on-edit-version + on-preview-snapshot (mf/use-fn (fn [id _event] - (st/emit! (dwv/update-versions-state {:editing id})))) - - on-cancel-version-edition - (mf/use-fn - (fn [_id _event] - (st/emit! (dwv/update-versions-state {:editing nil})))) - - on-rename-version - (mf/use-fn - (fn [id label] - (st/emit! (dwv/rename-version id label)))) + (st/emit! (dwv/enter-preview id) + (ev/event {::ev/name "preview-version" + ::ev/origin "workspace:sidebar" + :type "autosaved-version"})))) on-restore-version (mf/use-fn (fn [id _event] - (open-restore-version-dialog :version id))) + (st/emit! (dwv/enter-restore id) + (ev/event {::ev/name "restore-version" + ::ev/origin "workspace:sidebar" + :type "pinned-version"})))) on-restore-snapshot (mf/use-fn (fn [id _event] - (open-restore-version-dialog :snapshot id))) - - on-delete-version - (mf/use-fn - (fn [id] - (st/emit! (dwv/delete-version id)))) - - on-pin-version - (mf/use-fn - (fn [id] (st/emit! (dwv/pin-version id)))) - - on-lock-version - (mf/use-fn - (fn [id] - (st/emit! (dwv/lock-version id)))) - - on-unlock-version - (mf/use-fn - (fn [id] - (st/emit! (dwv/unlock-version id)))) + (st/emit! (dwv/enter-restore id) + (ev/event {::ev/name "restore-version" + ::ev/origin "workspace:sidebar" + :type "autosaved-version"})))) on-change-filter (mf/use-fn - (fn [filter] + (fn [filter-value] (cond - (= :all filter) + (= :all filter-value) (st/emit! (dwv/update-versions-state {:filter nil})) - (= :own filter) + (= :own filter-value) (st/emit! (dwv/update-versions-state {:filter (:id profile)})) :else - (st/emit! (dwv/update-versions-state {:filter filter}))))) + (st/emit! (dwv/update-versions-state {:filter filter-value}))))) options (mf/with-memo [users profile] @@ -415,6 +439,7 @@ :on-edit on-edit-version :on-cancel-edit on-cancel-version-edition :on-rename on-rename-version + :on-preview on-preview-version :on-restore on-restore-version :on-delete on-delete-version :on-lock on-lock-version @@ -423,6 +448,7 @@ :snapshot [:> snapshot-entry* {:key (:index entry) :entry entry + :on-preview-snapshot on-preview-snapshot :on-restore-snapshot on-restore-snapshot :on-pin-snapshot on-pin-version}] diff --git a/frontend/src/app/main/ui/workspace/sidebar/versions.scss b/frontend/src/app/main/ui/workspace/sidebar/versions.scss index eb4a736edb..41c04c3521 100644 --- a/frontend/src/app/main/ui/workspace/sidebar/versions.scss +++ b/frontend/src/app/main/ui/workspace/sidebar/versions.scss @@ -132,15 +132,17 @@ } .version-options-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; + position: absolute; width: fit-content; max-width: deprecated.$s-200; right: 0; left: unset; top: var(--offset); + .menu-option { - @extend .dropdown-element-base; + @extend %dropdown-element-base; } } @@ -164,6 +166,7 @@ &:hover { color: var(--color-accent-primary); + .icon-arrow { stroke: var(--color-accent-primary); } @@ -214,6 +217,7 @@ &:active { color: var(--color-accent-primary); + :global(.icon-pin) { visibility: initial; fill: var(--color-accent-primary); @@ -227,6 +231,7 @@ .cta { @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); a { diff --git a/frontend/src/app/main/ui/workspace/text_palette.scss b/frontend/src/app/main/ui/workspace/text_palette.scss index b8b438eb89..8090f88e7c 100644 --- a/frontend/src/app/main/ui/workspace/text_palette.scss +++ b/frontend/src/app/main/ui/workspace/text_palette.scss @@ -10,18 +10,22 @@ height: 100%; display: flex; } + .left-arrow, .right-arrow { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + position: relative; height: 100%; width: deprecated.$s-24; padding: 0; z-index: deprecated.$z-index-2; + svg { - @extend .button-icon; + @extend %button-icon; } + &::after { content: ""; position: absolute; @@ -37,20 +41,24 @@ ); pointer-events: none; } + &:hover { svg { stroke: var(--button-foreground-hover); } } + &:disabled { svg { stroke: var(--button-foreground-color-disabled); } + &::after { background-image: none; } } } + .left-arrow { &::after { left: deprecated.$s-24; @@ -60,6 +68,7 @@ var(--palette-button-shadow-final) 100% ); } + &.disabled ::after { background-image: none; } @@ -80,7 +89,8 @@ } .typography-item { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; flex-direction: column; justify-content: center; @@ -90,26 +100,30 @@ padding: deprecated.$s-8; border-radius: deprecated.$br-8; background-color: var(--palette-text-background-color); + &:first-child { margin-left: deprecated.$s-8; } .typography-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + height: deprecated.$s-16; width: deprecated.$s-120; color: var(--palette-text-color-selected); } .typography-font { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + height: deprecated.$s-16; width: deprecated.$s-120; color: var(--palette-text-color); } .typography-data { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + height: deprecated.$s-16; width: deprecated.$s-120; color: var(--palette-text-color); @@ -119,22 +133,26 @@ .typography-name { height: deprecated.$s-16; } + .typography-data { display: none; } } + &.small-item { .typography-data, .typography-font { display: none; } } + &:hover { background-color: var(--palette-text-background-color-hover); } } .text-palette-empty { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--palette-text-color); } diff --git a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss index fe450d0b1a..0a8a3985d7 100644 --- a/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss +++ b/frontend/src/app/main/ui/workspace/text_palette_ctx_menu.scss @@ -31,39 +31,52 @@ &:last-child { margin-bottom: 0; } + .library-name { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + color: var(--context-menu-foreground-color); display: grid; grid-template-columns: 1fr deprecated.$s-24; max-width: deprecated.$s-400; + .lib-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + max-width: deprecated.$s-380; } + .lib-num { margin-left: deprecated.$s-4; } } + .icon-wrapper { margin-left: deprecated.$s-4; - @include deprecated.flexCenter; + + @include deprecated.flex-center; + svg { - @include deprecated.flexCenter; - @extend .button-icon-small; + @include deprecated.flex-center; + @extend %button-icon-small; + stroke: var(--icon-foreground); } } + &.selected, &:hover { .icon-wrapper { - @include deprecated.flexCenter; + @include deprecated.flex-center; + svg { - @include deprecated.flexCenter; - @extend .button-icon-small; + @include deprecated.flex-center; + @extend %button-icon-small; + stroke: var(--context-menu-foreground-color-selected); } } + .library-name { color: var(--context-menu-foreground-color-selected); } diff --git a/frontend/src/app/main/ui/workspace/tokens/export.scss b/frontend/src/app/main/ui/workspace/tokens/export.scss index 53f9be2209..d7ecd3cb12 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export.scss +++ b/frontend/src/app/main/ui/workspace/tokens/export.scss @@ -10,14 +10,16 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-dialog { --modal-width: 32rem; --modal-padding: var(--sp-xxxl); --container-max-height: 16rem; - @extend .modal-container-base; + + @extend %modal-container-base; + user-select: none; width: var(--modal-width); max-width: 100%; diff --git a/frontend/src/app/main/ui/workspace/tokens/export/modal.scss b/frontend/src/app/main/ui/workspace/tokens/export/modal.scss index 81d4af36f8..a8d711103f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/export/modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/export/modal.scss @@ -62,13 +62,10 @@ .file-name { display: block; max-width: 99%; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + @include t.use-typography("body-medium"); + flex-grow: 1; - overflow: hidden; - text-overflow: ellipsis; padding: var(--sp-xs); overflow: hidden; text-overflow: ellipsis; @@ -90,14 +87,14 @@ border-radius: $br-8; margin: 0; max-height: var(--container-max-height); - overflow-y: auto; - overflow-x: auto; - word-wrap: normal; + overflow: auto; + overflow-wrap: normal; white-space: pre; } .disabled-message { @include t.use-typography("body-small"); + color: var(--color-foreground-secondary); display: flex; align-items: center; diff --git a/frontend/src/app/main/ui/workspace/tokens/import.scss b/frontend/src/app/main/ui/workspace/tokens/import.scss index 314edf94c8..26b77c7dc5 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import.scss @@ -7,11 +7,12 @@ @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; + user-select: none; } diff --git a/frontend/src/app/main/ui/workspace/tokens/import/modal.scss b/frontend/src/app/main/ui/workspace/tokens/import/modal.scss index 9d8671d48d..971c0abc88 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import/modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import/modal.scss @@ -30,6 +30,7 @@ .import-actions { @include t.use-typography("body-small"); + display: flex; justify-content: flex-end; gap: var(--sp-s); @@ -40,8 +41,7 @@ border-end-start-radius: 0; border-inline-start: $b-1 solid var(--color-accent-tertiary); width: var(--sp-xxxl); - padding-inline-start: 0; - padding-inline-end: 0; + padding-inline: 0; justify-content: center; } diff --git a/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss b/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss index d1394861db..b63eaf6648 100644 --- a/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss +++ b/frontend/src/app/main/ui/workspace/tokens/import_from_library.scss @@ -5,7 +5,6 @@ // Copyright (c) KALEIDOS INC @use "refactor/common-refactor.scss" as deprecated; - @use "ds/typography.scss" as t; @use "ds/_borders.scss" as *; @use "ds/_sizes.scss" as *; @@ -20,7 +19,8 @@ --modal-title-foreground-color: var(--color-foreground-primary); --modal-text-foreground-color: var(--color-foreground-secondary); - @extend .modal-overlay-base; + @extend %modal-overlay-base; + display: flex; justify-content: center; align-items: center; @@ -33,7 +33,8 @@ } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; + inline-size: 100%; max-inline-size: 32rem; max-block-size: unset; @@ -50,12 +51,14 @@ .modal-title { @include t.use-typography("headline-medium"); + color: var(--modal-title-foreground-color); - word-break: break-word; + overflow-wrap: break-word; } .modal-content { @include t.use-typography("body-large"); + color: var(--modal-text-foreground-color); } @@ -65,6 +68,7 @@ } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; + gap: var(--sp-s); } diff --git a/frontend/src/app/main/ui/workspace/tokens/management.cljs b/frontend/src/app/main/ui/workspace/tokens/management.cljs index 0ff4deafa4..e3a0dafed1 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management.cljs @@ -2,12 +2,17 @@ (:require-macros [app.main.style :as stl]) (:require [app.common.data :as d] + [app.common.path-names :as cpn] [app.common.types.shape.layout :as ctsl] [app.common.types.tokens-lib :as ctob] [app.config :as cf] + [app.main.data.helpers :as dh] + [app.main.data.modal :as modal] [app.main.data.style-dictionary :as sd] [app.main.data.workspace.tokens.application :as dwta] [app.main.data.workspace.tokens.library-edit :as dwtl] + [app.main.data.workspace.tokens.propagation :as dwtp] + [app.main.data.workspace.tokens.remapping :as remap] [app.main.refs :as refs] [app.main.store :as st] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] @@ -124,6 +129,16 @@ (mf/with-memo [tokens-by-type] (get-sorted-token-groups tokens-by-type)) + ;; Filter tokens by their path and return the tokens + filter-tokens-by-path + (mf/use-fn + (fn [tokens-filtered-by-type node] + (->> tokens-filtered-by-type + (filter (fn [token] + (let [token-path (cpn/split-path (:name token) :separator ".")] + (and (> (count token-path) 0) + (str/starts-with? (:name token) (str (:path node) "."))))))))) + ;; Filter tokens by their path and return their ids filter-tokens-by-path-ids (mf/use-fn @@ -132,7 +147,7 @@ (->> selected-token-set-tokens (filter (fn [token] (let [[_ token-value] token] - (and (= (:type token-value) type) (str/starts-with? (:name token-value) path))))) + (and (= (:type token-value) type) (str/starts-with? (:name token-value) (str path ".")))))) (mapv (fn [token] (let [[_ token-value] token] (:id token-value))))))) @@ -154,14 +169,11 @@ path (:name token) tokens-by-type (ctob/group-by-type selected-token-set-tokens) tokens-filtered-by-type (get tokens-by-type type) - tokens-in-path-ids (filter-tokens-by-path-ids type path) - remaining-tokens? (remaining-tokens-of-type-in-set? tokens-filtered-by-type tokens-in-path-ids)] - ;; Delete the token + remaining-tokens? (remaining-tokens-of-type-in-set? tokens-filtered-by-type [id])] (st/emit! (dwtl/delete-token selected-token-set-id id)) - ;; Remove from unfolded tree path (if remaining-tokens? (st/emit! (dwtl/toggle-token-path (str (name type) "." path))) - (st/emit! (dwtl/toggle-token-path (name type))))))) + (st/emit! (dwtl/close-token-type type)))))) delete-node (mf/with-memo [selected-token-set-tokens selected-token-set-id] @@ -176,7 +188,107 @@ ;; Remove from unfolded tree path (if remaining-tokens? (st/emit! (dwtl/toggle-token-path (str (name type) "." path))) - (st/emit! (dwtl/toggle-token-path (name type)))))))] + (st/emit! (dwtl/close-token-type type)))))) + + + + bulk-rename-tokens-in-path + ;; Rename tokens in bulk affected by a node rename. + (mf/use-fn + (mf/deps filter-tokens-by-path-ids selected-token-set-id) + (fn [node type new-node-name] + (let [old-path (:path node) + new-path (ctob/rename-path node new-node-name) + tokens-in-path-ids (filter-tokens-by-path-ids type old-path)] + (st/emit! + (modal/hide) + (dwtl/bulk-update-tokens selected-token-set-id tokens-in-path-ids type old-path new-path))))) + + bulk-remap-tokens-in-path + ;; Remap tokens in bulk affected by a node rename. + ;; It will update the token names and propagate the changes to the workspace. + (mf/use-fn + (mf/deps filter-tokens-by-path filter-tokens-by-path-ids selected-token-set-tokens selected-token-set-id) + (fn [node type new-node-name] + (let [old-path (:path node) + ;; Get tokens in path to remap their names after remapping the node + tokens-by-type (ctob/group-by-type selected-token-set-tokens) + tokens-filtered-by-type (get tokens-by-type type) + tokens-in-path (filter-tokens-by-path tokens-filtered-by-type node) + tokens-in-path-ids (filter-tokens-by-path-ids type old-path) + new-node-path (ctob/rename-path node new-node-name) + new-tokens (map (fn [token] + (let [new-token-path (ctob/rename-path node token new-node-name)] + (assoc token :name new-token-path))) + tokens-in-path)] + (st/emit! + (dwtl/bulk-update-tokens selected-token-set-id tokens-in-path-ids type old-path new-node-path) + (remap/bulk-remap-tokens tokens-in-path new-tokens) + (dwtp/propagate-workspace-tokens) + (modal/hide))))) + + on-remap-node-warning + ;; If there are tokens that will be affected by the node rename, we show the remap modal + (mf/use-fn + (mf/deps bulk-remap-tokens-in-path bulk-rename-tokens-in-path) + (fn [node type new-node-name] + (let [remap-data {:new-name new-node-name + :old-name (:name node) + :type "node"} + remap-handler #(bulk-remap-tokens-in-path node type new-node-name) + rename-handler #(bulk-rename-tokens-in-path node type new-node-name)] + (st/emit! + (modal/hide) + (modal/show :tokens/remapping-confirmation {:remap-data remap-data + :on-remap remap-handler + :on-rename rename-handler}))))) + + on-rename-node + ;; When user renames a node, we need to check if there are tokens that will be affected by this change. + ;; If there are, we display the remap modal, otherwise, we rename the tokens directly. + (mf/use-fn + (mf/deps selected-token-set-tokens filter-tokens-by-path on-remap-node-warning bulk-rename-tokens-in-path) + (fn [node type new-node-name] + (let [state @st/state + file-data (dh/lookup-file-data state) + tokens-by-type (ctob/group-by-type selected-token-set-tokens) + tokens-filtered-by-type (get tokens-by-type type) + tokens-in-current-path (filter-tokens-by-path tokens-filtered-by-type node) + token-references-count (reduce (fn [count token] + (+ count (remap/count-token-references file-data (:name token)))) + 0 + tokens-in-current-path)] + (if (> token-references-count 0) + (on-remap-node-warning node type new-node-name) + (bulk-rename-tokens-in-path node type new-node-name))))) + + on-duplicate-node + (fn [node type new-node-name] + (let [tokens-in-path-ids (filter-tokens-by-path-ids type (:path node))] + (st/emit! + (modal/hide) + (dwtl/bulk-create-tokens selected-token-set-id tokens-in-path-ids type node new-node-name)))) + + open-rename-node-modal + ;; When user renames a node, we display a form modal + (mf/use-fn + (mf/deps selected-token-set-tokens on-rename-node) + (fn [node type] + (let [on-rename-node-handler #(on-rename-node node type %)] + (st/emit! (modal/show :tokens/rename-node {:node node + :tokens-in-active-set selected-token-set-tokens + :on-rename on-rename-node-handler}))))) + + open-duplicate-node-modal + (mf/use-fn + (mf/deps selected-token-set-tokens on-duplicate-node) + (fn [node type] + (let [on-duplicate-node-handler #(on-duplicate-node node type %)] + (st/emit! (modal/show :tokens/rename-node {:new-node-name (str (:name node) "-copy") + :node node + :variant "duplicate" + :tokens-in-active-set selected-token-set-tokens + :on-rename on-duplicate-node-handler})))))] (mf/with-effect [tokens-lib selected-token-set-id] (when (and tokens-lib @@ -190,7 +302,9 @@ [:* [:& token-context-menu {:on-delete-token delete-token}] - [:> token-node-context-menu* {:on-delete-node delete-node}] + [:> token-node-context-menu* {:on-rename-node open-rename-node-modal + :on-duplicate-node open-duplicate-node-modal + :on-delete-node delete-node}] [:> selected-set-info* {:tokens-lib tokens-lib :selected-token-set-id selected-token-set-id}] diff --git a/frontend/src/app/main/ui/workspace/tokens/management.scss b/frontend/src/app/main/ui/workspace/tokens/management.scss index 135d3bcc76..39ba2ef087 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management.scss @@ -8,9 +8,10 @@ .sets-header-container { @include use-typography("headline-small"); + padding: var(--sp-s); color: var(--title-foreground-color); - word-break: break-word; + overflow-wrap: break-word; display: flex; align-items: flex-start; justify-content: space-between; @@ -24,6 +25,7 @@ .sets-header-status { @include use-typography("body-small"); + text-transform: none; color: var(--color-foreground-secondary); display: flex; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs index 8efa0ba66c..5ce0b1c16c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.cljs @@ -21,6 +21,7 @@ [app.main.ui.components.dropdown :refer [dropdown]] [app.main.ui.ds.foundations.assets.icon :refer [icon*] :as i] [app.main.ui.hooks :as hooks] + [app.util.clipboard :as clipboard] [app.util.dom :as dom] [app.util.i18n :refer [tr]] [app.util.timers :as timers] @@ -334,6 +335,7 @@ (defn default-actions [{:keys [token selected-token-set-id on-delete-token]}] (let [{:keys [modal]} (dwta/get-token-properties token) + on-copy-name #(clipboard/to-clipboard (:name token)) on-duplicate-token #(st/emit! (dwtl/duplicate-token (:id token)))] [{:title (tr "workspace.tokens.edit") :no-selectable true @@ -351,6 +353,9 @@ {:title (tr "workspace.tokens.duplicate") :no-selectable true :action on-duplicate-token} + {:title (tr "workspace.tokens.copy-name") + :no-selectable true + :action on-copy-name} {:title (tr "workspace.tokens.delete") :no-selectable true :action #(on-delete-token token)}])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss index 207166d747..284040b470 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/context_menu.scss @@ -22,7 +22,8 @@ .context-list, .token-context-submenu { - @include deprecated.menuShadow; + @include deprecated.menu-shadow; + display: grid; width: deprecated.$s-240; padding: deprecated.$s-4; @@ -56,7 +57,9 @@ --context-menu-item-bg-color: none; --context-menu-item-fg-color: var(--color-foreground-primary); --context-menu-item-border-color: none; + @include use-typography("body-small"); + display: flex; align-items: center; height: deprecated.$s-32; @@ -67,6 +70,7 @@ background-color: var(--context-menu-item-bg-color); border: deprecated.$s-1 solid var(--context-menu-item-border-color); cursor: pointer; + &:hover { --context-menu-item-bg-color: var(--color-background-quaternary); } @@ -124,6 +128,7 @@ .item-with-icon-space { padding-left: deprecated.$s-20; } + .icon-wrapper { margin-right: deprecated.$s-4; } diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs index 3e6dec43b1..137551c260 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.cljs @@ -85,11 +85,15 @@ filter-term* (mf/use-state "") filter-term (deref filter-term*) + selected-id* (mf/use-state nil) + selected-id (deref selected-id*) + options-ref (mf/use-ref nil) dropdown-ref (mf/use-ref nil) internal-ref (mf/use-ref nil) nodes-ref (mf/use-ref nil) wrapper-ref (mf/use-ref nil) + input-wrapper-ref (mf/use-ref nil) icon-button-ref (mf/use-ref nil) ref (or ref internal-ref) @@ -120,12 +124,28 @@ state (obj/set! state id node)] (mf/set-ref-val! nodes-ref state)))) + get-selected-id + (mf/use-fn + (mf/deps dropdown-options) + (fn [] + (let [input-node (mf/ref-val ref) + value (dom/get-input-value input-node) + cursor (dom/selection-start input-node) + token-name (tp/token-at-cursor value cursor) + options (if (delay? dropdown-options) @dropdown-options dropdown-options)] + (when token-name + (->> options + (filter #(= (:name %) token-name)) + first + :id))))) + toggle-dropdown (mf/use-fn (mf/deps is-open) (fn [event] (dom/prevent-default event) (swap! is-open* not) + (reset! selected-id* (get-selected-id)) (let [input-node (mf/ref-val ref)] (dom/focus! input-node)))) @@ -160,7 +180,8 @@ :options dropdown-options :toggle-dropdown toggle-dropdown :is-open* is-open* - :on-enter on-option-enter}) + :on-enter on-option-enter + :get-selected-id get-selected-id}) on-change (mf/use-fn @@ -219,11 +240,13 @@ :hint-message (:message hint) :on-key-down on-key-down :hint-type (:type hint) + :input-wrapper-ref input-wrapper-ref :ref ref :role "combobox" :aria-activedescendant focused-id :aria-controls listbox-id :aria-expanded is-open + :data-option-focused (boolean focused-id) :slot-end (when (some? @filtered-tokens-by-type) (mf/html @@ -244,7 +267,7 @@ props) - {:keys [style ready?]} (use-floating-dropdown is-open wrapper-ref dropdown-ref)] + {:keys [style ready?]} (use-floating-dropdown is-open input-wrapper-ref wrapper-ref dropdown-ref)] (mf/with-effect [resolve-stream tokens token name token-name] (let [subs (->> resolve-stream @@ -303,7 +326,7 @@ :id listbox-id :options options :focused focused-id - :selected nil + :selected selected-id :align :right :empty-to-end empty-to-end :wrapper-ref dropdown-ref diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss index b484cacfce..41cc87fd25 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox.scss @@ -12,5 +12,6 @@ position: fixed; max-block-size: $sz-400; overflow-y: auto; - @include custom-scrollbar(); + + @include custom-scrollbar; } diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs index b8be6dad81..3508833797 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/combobox_navigation.cljs @@ -26,14 +26,13 @@ [focusables focused-id direction] (let [ids (vec (map :id focusables)) idx (.indexOf (clj->js ids) focused-id) - idx (if (= idx -1) -1 idx) - next-idx (case direction - :down (min (dec (count ids)) (inc idx)) - :up (max 0 (dec (if (= idx -1) 0 idx))))] - (nth ids next-idx nil))) + count (count ids)] + (case direction + :down (nth ids (mod (inc idx) count) nil) + :up (nth ids (mod (if (= idx -1) 0 (dec idx)) count) nil)))) (defn use-navigation - [{:keys [is-open options nodes-ref is-open* toggle-dropdown on-enter]}] + [{:keys [is-open options nodes-ref is-open* toggle-dropdown on-enter get-selected-id]}] (let [focused-id* (mf/use-state nil) focused-id (deref focused-id*) @@ -46,6 +45,7 @@ down? (kbd/down-arrow? event) enter? (kbd/enter? event) esc? (kbd/esc? event) + tab? (kbd/tab? event) open-dropdown (kbd/is-key? event "{") close-dropdown (kbd/is-key? event "}") options (if (delay? options) @options options)] @@ -56,18 +56,21 @@ (dom/prevent-default event) (let [focusables (focusable-options options)] (cond + ;; Dropdown open: move focus to next option is-open (when (seq focusables) (let [next-id (next-focus-id focusables focused-id :down)] (reset! focused-id* next-id))) + ;; Dropdown closed with options: open and focus first (seq focusables) (do (toggle-dropdown event) + (when get-selected-id + (get-selected-id)) (reset! focused-id* (first-focusable-id focusables))) - :else - nil))) + :else nil))) up? (when is-open @@ -77,7 +80,9 @@ (reset! focused-id* next-id))) open-dropdown - (reset! is-open* true) + (do + (reset! is-open* true) + (reset! focused-id* nil)) close-dropdown (reset! is-open* false) @@ -89,21 +94,23 @@ (dom/prevent-default event) (when (some #(= (:id %) focused-id) focusables) (on-enter focused-id))))) + esc? - (do + (when is-open (dom/prevent-default event) + (dom/stop-propagation event) (reset! is-open* false)) + + tab? + (when is-open + (reset! is-open* false) + (reset! focused-id* nil)) + :else nil))))] - ;; Initial focus on first option - (mf/with-effect [is-open options] - (when is-open - (let [opts (if (delay? options) @options options) - focusables (focusable-options opts) - ids (set (map :id focusables))] - (when (and (seq focusables) - (not (contains? ids focused-id))) - (reset! focused-id* (:id (first focusables))))))) + (mf/with-effect [is-open] + (when (not is-open) + (reset! focused-id* nil))) ;; auto scroll when key down (mf/with-effect [focused-id nodes-ref] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs index 739ca0628c..d7cf90a3f3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/floating_dropdown.cljs @@ -9,7 +9,7 @@ [app.util.dom :as dom] [rumext.v2 :as mf])) -(defn use-floating-dropdown [is-open wrapper-ref dropdown-ref] +(defn use-floating-dropdown [is-open input-wrapper-ref outer-wrapper-ref dropdown-ref] (let [position* (mf/use-state nil) position (deref position*) ready* (mf/use-state false) @@ -32,7 +32,7 @@ (> dropdown-height space-below)) position (if open-up? - {:bottom (str (- windows-height (:top combobox-rect) 12) "px") + {:bottom (str (- windows-height (:top combobox-rect) -8) "px") :left (str (:left combobox-rect) "px") :width (str (:width combobox-rect) "px") :placement :top} @@ -44,27 +44,41 @@ (reset! ready* true) (reset! position* position)))] - (mf/with-effect [is-open dropdown-ref wrapper-ref] + (mf/with-effect [is-open dropdown-ref input-wrapper-ref outer-wrapper-ref] (when is-open - (let [handler (fn [event] - (let [dropdown-node (mf/ref-val dropdown-ref) - target (dom/get-target event)] - (when (or (nil? dropdown-node) - (not (instance? js/Node target)) - (not (.contains dropdown-node target))) - (js/requestAnimationFrame - (fn [] - (let [wrapper-node (mf/ref-val wrapper-ref)] - (reset! ready* true) - (calculate-position wrapper-node)))))))] + (let [recalculate + (fn [] + (js/requestAnimationFrame + (fn [] + (let [input-node (mf/ref-val input-wrapper-ref)] + (calculate-position input-node))))) + + handler + (fn [event] + (let [dropdown-node (mf/ref-val dropdown-ref) + target (dom/get-target event)] + (when (or (nil? dropdown-node) + (not (instance? js/Node target)) + (not (.contains dropdown-node target))) + (recalculate)))) + + resize-observer (js/ResizeObserver. (fn [_] (recalculate))) + outer-node (mf/ref-val outer-wrapper-ref) + dropdown-node (mf/ref-val dropdown-ref)] + (handler nil) (.addEventListener js/window "resize" handler) (.addEventListener js/window "scroll" handler true) + (when outer-node + (.observe resize-observer outer-node)) + (when dropdown-node + (.observe resize-observer dropdown-node)) (fn [] (.removeEventListener js/window "resize" handler) - (.removeEventListener js/window "scroll" handler true))))) + (.removeEventListener js/window "scroll" handler true) + (.disconnect resize-observer))))) {:style position :ready? ready diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs index 2308bef9c7..1c317ff3e3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/token_parsing.cljs @@ -22,6 +22,18 @@ :end (or (str/index-of value "}" last-open) cursor) :partial (subs text-before (inc last-open))}))) +(defn token-at-cursor + "Returns the full token name at the cursor position if cursor is + inside a complete {token-name} reference, nil otherwise." + [value cursor] + (let [last-open (str/last-index-of (subs value 0 cursor) "{") + last-close (str/index-of value "}" (or last-open 0))] + (when (and last-open last-close (> last-close last-open)) + (let [token-name (subs value (inc last-open) last-close)] + (when (and (seq token-name) + (not (str/includes? token-name " "))) + token-name))))) + (defn active-token [value input-node] (let [cursor (dom/selection-start input-node)] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs index f29c348e9d..39a4675dc0 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/controls/utils.cljs @@ -9,7 +9,8 @@ [token] {:id (str (get token :id)) :type :token - :resolved-value (get token :value) + :value (get token :value) + :resolved-value (get token :resolved-value) :name (get token :name)}) (defn- generate-dropdown-options @@ -53,7 +54,7 @@ tokens))) (defn- sort-groups-and-tokens - "Sorts both the groups and the tokens inside them alphabetically. + "Sorts the tokens inside the groups alphabetically. Input: A map where: @@ -65,18 +66,18 @@ :colors [{:name \"azul\"} {:name \"rojo\"}]} Output: - A sorted map where: - - groups are ordered alphabetically by key + A map which: - tokens inside each group are sorted alphabetically by :name Example output: - {:colors [{:name \"azul\"} {:name \"rojo\"}] - :dimensions [{:name \"quini\"} {:name \"tres\"}]}" + {:dimensions [{:name \"quini\"} {:name \"tres\"}] + :colors [{:name \"azul\"} {:name \"rojo\"}]}" [groups->tokens] - (into (sorted-map) ;; ensure groups are ordered alphabetically by their key - (for [[group tokens] groups->tokens] - [group (sort-by :name tokens)]))) + (reduce (fn [acc [group tokens]] + (assoc acc group (sort-by :name tokens))) + {} + groups->tokens)) (defn get-token-dropdown-options [tokens filter-term] @@ -94,9 +95,21 @@ (defn filter-tokens-for-input [raw-tokens input-type] (delay - (-> (deref raw-tokens) - (select-keys (get cto/tokens-by-input input-type)) - (not-empty)))) + (let [raw-tokens (deref raw-tokens) + key-order (case input-type + :color-selection + (concat + (get cto/tokens-by-input :fill) + (get cto/tokens-by-input :stroke-color)) + + (get cto/tokens-by-input input-type))] + (-> (reduce (fn [acc k] + (if (contains? raw-tokens k) + (assoc acc k (get raw-tokens k)) + acc)) + (array-map) + key-order) + (not-empty))))) (defn focusable-options [options] (filter #(= (:type %) :token) options)) \ No newline at end of file diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs index 8225a52887..a4cc813bbc 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -7,7 +7,6 @@ (ns app.main.ui.workspace.tokens.management.forms.generic-form (:require-macros [app.main.style :as stl]) (:require - [app.common.data :as d] [app.common.files.tokens :as cfo] [app.common.schema :as sm] [app.common.types.tokens-lib :as ctob] @@ -160,13 +159,13 @@ on-remap-token (mf/use-fn (mf/deps token) - (fn [valid-token name old-name description] + (fn [valid-token new-name old-name description] (st/emit! (dwtl/update-token (:id token) - {:name name + {:name new-name :value (:value valid-token) :description description}) - (remap/remap-tokens old-name name) + (remap/remap-tokens old-name new-name) (dwtp/propagate-workspace-tokens) (modal/hide!)))) @@ -186,7 +185,6 @@ (mf/deps validate-token token tokens token-type value-subfield value-type active-tab on-remap-token on-rename-token is-create) (fn [form _event] (let [name (get-in @form [:clean-data :name]) - path (str (d/name token-type) "." name) description (get-in @form [:clean-data :description]) value (get-in @form [:clean-data :value]) value-for-validation (get-value-for-validator active-tab value value-subfield value-type)] @@ -203,11 +201,12 @@ is-rename (and (= action "edit") (not= name old-name)) references-count (remap/count-token-references file-data old-name) on-remap #(on-remap-token valid-token name old-name description) - on-rename #(on-rename-token valid-token name description)] + on-rename #(on-rename-token valid-token name description) + remap-data {:new-name name + :old-name old-name + :type "token"}] (if (and is-rename (> references-count 0)) - (st/emit! (modal/show :tokens/remapping-confirmation {:old-token-name old-name - :new-token-name name - :references-count references-count + (st/emit! (modal/show :tokens/remapping-confirmation {:remap-data remap-data :on-remap on-remap :on-rename on-rename})) (st/emit! @@ -220,7 +219,7 @@ {:name name :value (:value valid-token) :description description})) - (dwtl/toggle-token-path path) + (dwtl/open-token-type (:type token)) (dwtp/propagate-workspace-tokens) (modal/hide!))))) ;; WORKAROUND: display validation errors in the form instead of crashing diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss index 00eb38a2f2..3ac3d7a753 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/generic_form.scss @@ -33,6 +33,7 @@ .form-modal-title { @include t.use-typography("headline-medium"); + color: var(--color-foreground-primary); display: flex; align-items: center; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss index c9dc66715a..2dd229e37a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/modals.scss @@ -13,18 +13,17 @@ border-radius: $br-4; background-color: var(--color-background-primary); border: $b-2 solid var(--color-background-quaternary); - min-width: $sz-364; min-height: $sz-192; max-width: $sz-512; max-height: $sz-512; - box-shadow: 0px 0px $sz-12 0px var(--color-shadow-dark); + box-shadow: 0 0 $sz-12 0 var(--color-shadow-dark); position: absolute; width: auto; min-width: auto; z-index: var(--z-index-set); - overflow-y: auto; - overflow-x: hidden; + overflow: hidden auto; padding: var(--sp-xxxl); + &.token-modal-large { max-block-size: 95vh; } diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs new file mode 100644 index 0000000000..194b731f03 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs @@ -0,0 +1,123 @@ +(ns app.main.ui.workspace.tokens.management.forms.rename-node-modal + (:require-macros [app.main.style :as stl]) + (:require + [app.common.data :as d] + [app.common.files.tokens :as cfo] + [app.common.types.tokens-lib :as ctob] + [app.main.data.modal :as modal] + [app.main.store :as st] + [app.main.ui.ds.buttons.button :refer [button*]] + [app.main.ui.ds.buttons.icon-button :refer [icon-button*]] + [app.main.ui.ds.foundations.assets.icon :as i] + [app.main.ui.ds.foundations.typography.heading :refer [heading*]] + [app.main.ui.forms :as fc] + [app.util.forms :as fm] + [app.util.i18n :refer [tr]] + [app.util.keyboard :as kbd] + [cuerdas.core :as str] + [rumext.v2 :as mf])) + +(mf/defc rename-node-form* + [{:keys [new-node-name node active-tokens tokens-tree variant on-close on-submit]}] + (let [make-schema #(cfo/make-node-token-schema active-tokens tokens-tree node) + + schema + (mf/with-memo [active-tokens] + (make-schema)) + + initial (mf/with-memo [node new-node-name] + {:name (d/nilv new-node-name (:name node))}) + + form (fm/use-form :schema schema + :initial initial) + + on-submit (mf/use-fn + (mf/deps form on-submit) + (fn [] + (let [name (get-in @form [:clean-data :name])] + (when (not= name (:name node)) + (on-submit name))))) + + is-disabled? (or (not (:valid @form)) + (= (get-in @form [:clean-data :name]) (:name node))) + + hint-path (mf/with-memo [@form node] + (let [new-name (get-in @form [:clean-data :name]) + path (str (:path node)) + new-path (str/replace path (:name node) new-name)] + (if (get-in @form [:touched :name]) + new-path + path)))] + + [:> fc/form* {:class (stl/css :form-wrapper) + :form form + :on-submit on-submit} + [:> heading* {:level 2 + :typography "headline-medium" + :class (stl/css :form-modal-title)} + (if (= variant "rename") + (tr "workspace.tokens.rename-group") + (tr "workspace.tokens.duplicate-group"))] + [:> fc/form-input* {:id "rename-node" + :name :name + :label (tr "workspace.tokens.token-name") + :placeholder (tr "workspace.tokens.token-name") + :max-length 255 + :variant "comfortable" + :hint-type "hint" + :hint-message (when (= variant "rename") (tr "workspace.tokens.rename-group-name-hint" hint-path)) + :auto-focus true}] + [:div {:class (stl/css :form-actions)} + [:> button* {:variant "secondary" + :name "cancel" + :on-click on-close} (tr "labels.cancel")] + [:> fc/form-submit* {:variant "primary" + :disabled is-disabled? + :name "rename"} (if (= variant "rename") (tr "labels.rename") (tr "labels.duplicate"))]]])) + +(mf/defc rename-node-modal + {::mf/register modal/components + ::mf/register-as :tokens/rename-node} + [{:keys [new-node-name node tokens-in-active-set on-rename variant]}] + + (let [variant (d/nilv variant "rename") ;; "rename" or "duplicate" + + tokens-tree-in-selected-set + (mf/with-memo [tokens-in-active-set node] + (-> (ctob/tokens-tree tokens-in-active-set) + (d/dissoc-in (:name node)))) + + close-modal + (mf/use-fn + (fn [] + (st/emit! (modal/hide)))) + + rename + (mf/use-fn + (mf/deps on-rename) + (fn [new-name] + (on-rename new-name))) + + on-key-down + (mf/use-fn + (mf/deps [close-modal]) + (fn [event] + (when (kbd/esc? event) + (close-modal))))] + + [:div {:class (stl/css :modal-overlay) + :on-key-down on-key-down + :data-testid "token-rename-node-modal"} + [:div {:class (stl/css :modal-dialog)} + [:> icon-button* {:class (stl/css :close-btn) + :on-click close-modal + :aria-label (tr "labels.close") + :variant "ghost" + :icon i/close}] + [:> rename-node-form* {:new-node-name new-node-name + :node node + :variant variant + :active-tokens tokens-in-active-set + :tokens-tree tokens-tree-in-selected-set + :on-close close-modal + :on-submit rename}]]])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss new file mode 100644 index 0000000000..16206e3ea2 --- /dev/null +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.scss @@ -0,0 +1,60 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at http://mozilla.org/MPL/2.0/. +// +// Copyright (c) KALEIDOS INC + +@use "ds/_sizes.scss" as *; +@use "ds/typography.scss" as t; +@use "refactor/common-refactor.scss" as deprecated; + +.modal-overlay { + --modal-title-foreground-color: var(--color-foreground-primary); + --modal-text-foreground-color: var(--color-foreground-secondary); + + @extend %modal-overlay-base; + + display: flex; + justify-content: center; + align-items: center; + position: fixed; + inset-inline-start: 0; + inset-block-start: 0; + block-size: 100%; + inline-size: 100%; + background-color: var(--overlay-color); +} + +.close-btn { + position: absolute; + inset-block-start: $sz-6; + inset-inline-end: $sz-6; +} + +.modal-dialog { + @extend %modal-container-base; + + inline-size: 100%; + max-inline-size: 32rem; + max-block-size: unset; + user-select: none; + position: relative; +} + +.form-wrapper { + display: flex; + flex-direction: column; + gap: var(--sp-l); +} + +.form-modal-title { + @include t.use-typography("headline-medium"); + + color: var(--color-foreground-primary); +} + +.form-actions { + display: flex; + justify-content: flex-end; + gap: var(--sp-m); +} diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs index 722683d54a..3878305e76 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.cljs @@ -296,7 +296,7 @@ [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] [:fn {:error/field [:value :reference] - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} (fn [{:keys [name value]}] (let [reference (get value :reference)] (if (and reference name) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss index f0a973f0b5..fa23fa90a4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/shadow.scss @@ -40,6 +40,7 @@ .title { @include t.use-typography("body-small"); + color: var(--color-foreground-primary); display: flex; align-items: center; @@ -51,6 +52,7 @@ .visible-label { @include t.use-typography("headline-small"); + color: var(--color-foreground-secondary); line-height: $sz-32; } diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs index cb926f72fd..63e62bfbdb 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.cljs @@ -239,7 +239,7 @@ [:string {:max 2048 :error/fn #(tr "errors.field-max-length" 2048)}]]] [:fn {:error/field [:value :reference] - :error/fn #(tr "workspace.tokens.self-reference")} + :error/fn #(tr "errors.tokens.self-reference")} (fn [{:keys [name value]}] (let [reference (get value :reference)] (if (and reference name) @@ -247,7 +247,7 @@ true)))] [:fn {:error/field [:value :line-height] - :error/fn #(tr "workspace.tokens.composite-line-height-needs-font-size")} + :error/fn #(tr "errors.tokens.composite-line-height-needs-font-size")} (fn [{:keys [value]}] (let [line-heigh (get value :line-height) font-size (get value :font-size)] diff --git a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss index 5bac11ad27..67b16bef4b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/forms/typography.scss @@ -30,6 +30,7 @@ .title { @include t.use-typography("body-small"); + color: var(--color-foreground-primary); display: flex; align-items: center; diff --git a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs index 19c3636e28..a9b6914b9f 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/group.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/group.cljs @@ -27,8 +27,11 @@ [okulary.core :as l] [rumext.v2 :as mf])) -(def ref:unfolded-token-paths - (l/derived (l/key :unfolded-token-paths) refs/workspace-tokens)) +(def ref:folded-token-paths + (l/derived (l/key :folded-token-paths) refs/workspace-tokens)) + +(def ref:unfolded-token-types + (l/derived (l/key :unfolded-token-types) refs/workspace-tokens)) (defn token-section-icon [type] @@ -72,8 +75,15 @@ (let [{:keys [modal title]} (get dwta/token-properties type) - unfolded-token-paths (mf/deref ref:unfolded-token-paths) - is-type-unfolded (contains? (set unfolded-token-paths) (name type)) + folded-token-paths (mf/deref ref:folded-token-paths) + unfolded-token-types-state (mf/deref ref:unfolded-token-types) + + current-file (mf/deref refs/file) + + is-same-file-set? (and (= (:file-id unfolded-token-types-state) (:id current-file)) + (= (:set-id unfolded-token-types-state) selected-token-set-id)) + is-type-unfolded (and is-same-file-set? + (contains? (set (:types unfolded-token-types-state)) type)) editing-ref (mf/deref refs/workspace-editor-state) edition (mf/deref refs/selected-edition) @@ -117,7 +127,7 @@ (mf/deps type expandable?) (fn [] (when expandable? - (st/emit! (dwtl/toggle-token-path (name type)))))) + (st/emit! (dwtl/toggle-token-type type))))) on-popover-open-click (mf/use-fn @@ -152,6 +162,10 @@ :level :warning :timeout 3000}))))))))] + (mf/use-effect + (fn [] + (st/emit! (dwtl/restore-unfolded-token-types)))) + [:div {:class (stl/css :token-section-wrapper) :data-testid (dm/str "section-" (name type))} [:> layer-button* {:label title @@ -172,13 +186,12 @@ (when is-type-unfolded [:> token-tree* {:tokens tokens :type type - :id (dm/str "token-tree-" (name type)) - :tokens-lib tokens-lib - :unfolded-token-paths unfolded-token-paths + :folded-token-paths folded-token-paths :selected-shapes selected-shapes + :is-selected-inside-layout is-selected-inside-layout :active-theme-tokens active-theme-tokens :selected-token-set-id selected-token-set-id - :is-selected-inside-layout is-selected-inside-layout + :tokens-lib tokens-lib :on-token-pill-click on-token-pill-click :on-pill-context-menu on-pill-context-menu :on-node-context-menu on-node-context-menu}])])) diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs index f150240cf1..6978243ec3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.cljs @@ -14,6 +14,7 @@ (def ^:private schema:token-node-context-menu [:map + [:on-rename-node fn?] [:on-delete-node fn?]]) (def ^:private tokens-node-menu-ref @@ -26,7 +27,7 @@ (mf/defc token-node-context-menu* {::mf/schema schema:token-node-context-menu} - [{:keys [on-delete-node]}] + [{:keys [on-rename-node on-duplicate-node on-delete-node]}] (let [mdata (mf/deref tokens-node-menu-ref) is-open? (boolean mdata) dropdown-ref (mf/use-ref) @@ -38,13 +39,29 @@ left (+ (get-in mdata [:position :x]) 5) container (hooks/use-portal-container :popup) - delete-node (mf/use-fn - (mf/deps mdata) - (fn [] - (let [node (get mdata :node) - type (get mdata :type)] - (when node - (on-delete-node node type)))))] + rename-node (mf/use-fn + (mf/deps mdata on-rename-node) + (fn [] + (let [node (get mdata :node) + type (get mdata :type)] + (when node + (on-rename-node node type))))) + + duplicate-node (mf/use-fn + (mf/deps mdata on-duplicate-node) + (fn [] + (let [node (get mdata :node) + type (get mdata :type)] + (when node + (on-duplicate-node node type))))) + + delete-node (mf/use-fn + (mf/deps mdata) + (fn [] + (let [node (get mdata :node) + type (get mdata :type)] + (when node + (on-delete-node node type)))))] (mf/with-effect [is-open?] (when (and (not= 0 (mf/ref-val dropdown-direction-change*)) (= false is-open?)) @@ -77,6 +94,16 @@ :on-context-menu prevent-default} (when mdata [:ul {:class (stl/css :token-node-context-menu-list)} + [:li {:class (stl/css :token-node-context-menu-listitem)} + [:button {:class (stl/css :token-node-context-menu-action) + :type "button" + :on-click rename-node} + (tr "labels.rename")]] + [:li {:class (stl/css :token-node-context-menu-listitem)} + [:button {:class (stl/css :token-node-context-menu-action) + :type "button" + :on-click duplicate-node} + (tr "labels.duplicate")]] [:li {:class (stl/css :token-node-context-menu-listitem)} [:button {:class (stl/css :token-node-context-menu-action) :type "button" diff --git a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss index 7e84dfa6d8..362699c88c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/node_context_menu.scss @@ -34,7 +34,7 @@ background-color: var(--color-background-tertiary); max-block-size: 100vh; overflow-y: auto; - box-shadow: 0px 0px $sz-12 0px var(--menu-shadow-color); + box-shadow: 0 0 $sz-12 0 var(--menu-shadow-color); } .token-node-context-menu-action { @@ -43,6 +43,7 @@ --context-menu-item-border-color: none; @include t.use-typography("body-small"); + appearance: none; background: var(--context-menu-item-bg-color); border: $b-1 solid var(--context-menu-item-border-color); diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss index e6df6d482e..ff9b9cc8c3 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_pill.scss @@ -10,8 +10,7 @@ .token-pill { @include use-typography("code-font"); - border: none; - background: none; + cursor: pointer; display: grid; grid-template-columns: auto 1fr; @@ -33,6 +32,7 @@ .name-wrapper { @include use-typography("code-font"); + display: block; overflow: hidden; text-overflow: ellipsis; @@ -49,6 +49,7 @@ .first-name-wrapper { @include use-typography("code-font"); + overflow: hidden; text-overflow: ellipsis; white-space: nowrap; @@ -57,6 +58,7 @@ .last-name-wrapper { @include use-typography("code-font"); + flex-shrink: 0; } @@ -70,6 +72,7 @@ --token-pill-border: var(--color-background-tertiary); --token-pill-outline: none; --token-pill-accent: var(--color-background-quaternary); + &:hover { --token-pill-background: var(--color-token-background); --token-pill-foreground: var(--color-foreground-primary); @@ -81,6 +84,7 @@ &:focus-visible { --token-pill-outline: var(--color-background-primary); --token-pill-border: var(--color-accent-primary); + outline-offset: -3px; } } @@ -124,11 +128,13 @@ --token-pill-background: var(--color-background-tertiary); --token-pill-accent: var(--color-foreground-error); } + &:hover { --token-pill-foreground: var(--color-foreground-primary); --token-pill-outline: none; --token-pill-border: var(--color-foreground-error); } + &:focus-visible { --token-pill-foreground: var(--color-foreground-error); --token-pill-border: var(--color-accent-primary); @@ -138,6 +144,7 @@ .token-pill-invalid-applied { --token-pill-border: var(--color-foreground-error); + &:hover, &:focus-visible { --token-pill-border: var(--color-foreground-error); @@ -177,6 +184,7 @@ --token-pill-border: var(--color-accent-error); --token-pill-foreground: var(--color-foreground-error); --token-pill-accent: var(--color-foreground-error); + &:hover, &:focus-visible { --token-pill-border: var(--color-accent-error); diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs index c08c1cd618..b27db0bc85 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.cljs @@ -7,6 +7,7 @@ (ns app.main.ui.workspace.tokens.management.token-tree (:require-macros [app.main.style :as stl]) (:require + [app.common.data :as d] [app.common.path-names :as cpn] [app.common.types.tokens-lib :as ctob] [app.main.data.workspace.tokens.library-edit :as dwtl] @@ -20,7 +21,7 @@ [:map [:node :any] [:type :keyword] - [:unfolded-token-paths {:optional true} [:vector :string]] + [:folded-token-paths {:optional true} [:maybe [:vector :string]]] [:selected-shapes :any] [:is-selected-inside-layout {:optional true} :boolean] [:active-theme-tokens {:optional true} :any] @@ -34,7 +35,7 @@ {::mf/schema schema:folder-node} [{:keys [node type - unfolded-token-paths + folded-token-paths selected-shapes is-selected-inside-layout active-theme-tokens @@ -44,12 +45,11 @@ on-pill-context-menu on-node-context-menu]}] (let [full-path (str (name type) "." (:path node)) - is-folder-expanded (contains? (set (or unfolded-token-paths [])) full-path) + is-folder-expanded (not (contains? (set (or folded-token-paths [])) full-path)) swap-folder-expanded (mf/use-fn - (mf/deps (:path node) type) + (mf/deps full-path) (fn [] - (let [path (str (name type) "." (:path node))] - (st/emit! (dwtl/toggle-token-path path))))) + (st/emit! (dwtl/toggle-token-path full-path)))) node-context-menu-prep (mf/use-fn (mf/deps on-node-context-menu node) @@ -65,18 +65,18 @@ :on-toggle-expand swap-folder-expanded :on-context-menu node-context-menu-prep}] (when is-folder-expanded - (let [children-fn (:children-fn node)] + (let [children (:children node)] [:div {:class (stl/css :folder-children-wrapper) :id (str "folder-children-" (:path node))} - (when children-fn - (let [children (children-fn)] - (for [child children] + (when (seq children) + (let [sorted-children (d/natural-sort-by :name children)] + (for [child sorted-children] (if (not (:leaf child)) [:ul {:class (stl/css :node-parent) :key (:path child)} [:> folder-node* {:type type :node child - :unfolded-token-paths unfolded-token-paths + :folded-token-paths folded-token-paths :selected-shapes selected-shapes :is-selected-inside-layout is-selected-inside-layout :active-theme-tokens active-theme-tokens @@ -100,12 +100,12 @@ [:map [:tokens :any] [:type :keyword] - [:unfolded-token-paths {:optional true} [:vector :string]] + [:folded-token-paths {:optional true} [:maybe [:vector :string]]] [:selected-shapes :any] [:is-selected-inside-layout {:optional true} :boolean] [:active-theme-tokens {:optional true} :any] - [:selected-token-set-id {:optional true} :any] [:tokens-lib {:optional true} :any] + [:selected-token-set-id {:optional true} :any] [:on-token-pill-click {:optional true} fn?] [:on-pill-context-menu {:optional true} fn?] [:on-node-context-menu {:optional true} fn?]]) @@ -114,7 +114,7 @@ {::mf/schema schema:token-tree} [{:keys [tokens type - unfolded-token-paths + folded-token-paths selected-shapes is-selected-inside-layout active-theme-tokens @@ -127,7 +127,8 @@ tree (mf/use-memo (mf/deps tokens) (fn [] - (cpn/build-tree-root tokens separator))) + (->> (cpn/build-tree-root tokens separator) + (d/natural-sort-by :name)))) can-edit? (:can-edit (deref refs/permissions)) on-node-context-menu (mf/use-fn (mf/deps can-edit? on-node-context-menu) @@ -151,7 +152,7 @@ :key (:path node)} [:> folder-node* {:node node :type type - :unfolded-token-paths unfolded-token-paths + :folded-token-paths folded-token-paths :selected-shapes selected-shapes :is-selected-inside-layout is-selected-inside-layout :active-theme-tokens active-theme-tokens diff --git a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss index 7b1ea0244e..9edd7caf40 100644 --- a/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss +++ b/frontend/src/app/main/ui/workspace/tokens/management/token_tree.scss @@ -12,7 +12,6 @@ padding-block-end: var(--sp-s); display: flex; flex-wrap: wrap; - gap: var(--sp-s); padding-inline-start: calc(var(--node-spacing)); & .node-parent { @@ -50,6 +49,7 @@ margin-block-end: var(--sp-s); } } + & .token-pill { flex: 0 0 auto; } diff --git a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs index 198972a193..1ac692c484 100644 --- a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.cljs @@ -20,27 +20,41 @@ [app.util.keyboard :as kbd] [rumext.v2 :as mf])) -(defn hide-remapping-modal +(defn- hide-remapping-modal "Hide the token remapping confirmation modal" [] (st/emit! (modal/hide))) +;; TODO: Uncomment when modal components support schema validation + +;; (def ^:private schema:remap-data +;; [:map +;; [:old-name :string] +;; [:new-name :string] +;; [:type [:enum "token" "node"]]]) + +;; (def ^:private schema:token-remapping-modal +;; [:map +;; [:remap-data [:maybe schema:remap-data]] +;; [:on-remap {:optional true} [:maybe fn?]] +;; [:on-rename {:optional true} [:maybe fn?]]]) + ;; Remapping Modal Component (mf/defc token-remapping-modal {::mf/register modal/components - ::mf/register-as :tokens/remapping-confirmation} - [{:keys [old-token-name new-token-name on-remap on-rename]}] - (let [remap-modal (get @st/state :remap-modal) + ::mf/register-as :tokens/remapping-confirmation + ;; TODO: Uncomment when modal components support schema validation + ;; ::mf/schema schema:token-remapping-modal + } + [{:keys [remap-data on-remap on-rename]}] + (let [old-name (:old-name remap-data) + new-name (:new-name remap-data) ;; Remap logic on confirm confirm-remap (mf/use-fn - (mf/deps on-remap remap-modal) + (mf/deps on-remap old-name new-name) (fn [] - ;; Call shared remapping logic - (let [old-token-name (:old-token-name remap-modal) - new-token-name (:new-token-name remap-modal)] - (st/emit! [:tokens/remap-tokens old-token-name new-token-name])) (when (fn? on-remap) (on-remap)))) @@ -83,9 +97,13 @@ :id "modal-title" :typography "headline-large" :class (stl/css :modal-title)} - (tr "workspace.tokens.remap-token-references-title" old-token-name new-token-name)]] + (if (= (:type remap-data) "token") + (tr "workspace.tokens.remap-token-references-title" old-name new-name) + (tr "workspace.tokens.remap-node-references-title" old-name new-name))]] [:div {:class (stl/css :modal-content)} - [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-effects")] + (if (= (:type remap-data) "token") + [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-token-warning-effects")] + [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-node-warning-effects")]) [:> text* {:as "p" :typography t/body-medium} (tr "workspace.tokens.remap-warning-time")]] [:div {:class (stl/css :modal-footer)} [:div {:class (stl/css :action-buttons)} diff --git a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss index 704df4e5d3..3dd5b7db84 100644 --- a/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/remapping_modal.scss @@ -6,14 +6,14 @@ @use "ds/_sizes.scss" as *; @use "ds/typography.scss" as t; - @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { --modal-title-foreground-color: var(--color-foreground-primary); --modal-text-foreground-color: var(--color-foreground-secondary); - @extend .modal-overlay-base; + @extend %modal-overlay-base; + display: flex; justify-content: center; align-items: center; @@ -32,7 +32,8 @@ } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; + inline-size: 100%; max-inline-size: 32rem; max-block-size: unset; @@ -49,12 +50,14 @@ .modal-title { @include t.use-typography("headline-medium"); + color: var(--modal-title-foreground-color); - word-break: break-word; + overflow-wrap: break-word; } .modal-content { @include t.use-typography("body-large"); + color: var(--modal-text-foreground-color); } @@ -64,12 +67,14 @@ } .action-buttons { - @extend .modal-action-btns; + @extend %modal-action-btns; + gap: var(--sp-s); } .modal-scd-msg, .modal-msg { @include t.use-typography("body-large"); + color: var(--modal-text-foreground-color); } diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.cljs b/frontend/src/app/main/ui/workspace/tokens/sets.cljs index fd35710a74..56621eceac 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets.cljs @@ -16,8 +16,8 @@ [rumext.v2 :as mf])) (defn- on-select-token-set-click [id] - (st/emit! (dwtl/clear-tokens-paths)) - (st/emit! (dwtl/set-selected-token-set-id id))) + (st/emit! (dwtl/clear-tokens-paths) + (dwtl/set-selected-token-set-id id))) (defn- on-toggle-token-set-click [name] (st/emit! (dwtl/toggle-token-set name))) diff --git a/frontend/src/app/main/ui/workspace/tokens/sets.scss b/frontend/src/app/main/ui/workspace/tokens/sets.scss index dee8bfe07b..f7141b973b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets.scss @@ -21,6 +21,7 @@ .create-set-button { @include use-typography("body-small"); + background-color: transparent; border: none; appearance: none; @@ -29,7 +30,8 @@ } .set-item-container { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; width: 100%; @@ -43,9 +45,11 @@ &.dnd-over-bot { border-bottom: deprecated.$s-2 solid var(--layer-row-foreground-color-hover); } + &.dnd-over-top { border-top: deprecated.$s-2 solid var(--layer-row-foreground-color-hover); } + &.dnd-over { border: deprecated.$s-2 solid var(--layer-row-foreground-color-hover); } @@ -64,7 +68,8 @@ } .set-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + flex-grow: 1; padding-left: deprecated.$s-2; } @@ -112,7 +117,7 @@ } .check-icon { - color: currentColor; + color: currentcolor; } .set-item-container:hover { @@ -129,6 +134,7 @@ padding: deprecated.$s-12; color: var(--color-foreground-secondary); } + .selected-set { background-color: var(--layer-row-background-color-selected); color: var(--layer-row-foreground-color-selected); @@ -136,19 +142,21 @@ } .collapsabled-icon { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + height: deprecated.$s-24; border-radius: deprecated.$br-8; + &:hover { color: var(--title-foreground-color-hover); } } .editing-node { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - @include deprecated.removeInputStyle; + @include deprecated.text-ellipsis; + @include deprecated.body-small-typography; + @include deprecated.remove-input-style; border: deprecated.$s-1 solid var(--input-border-color-focus); border-radius: deprecated.$br-8; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss index 1e36266233..53e6e72901 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets/context_menu.scss @@ -13,7 +13,8 @@ } .context-list { - @include deprecated.menuShadow; + @include deprecated.menu-shadow; + display: grid; width: deprecated.$s-240; padding: deprecated.$s-4; @@ -26,6 +27,7 @@ .context-menu-item { @include t.use-typography("body-small"); + color: var(--menu-foreground-color); display: flex; align-items: center; diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs index e7b9bf98c6..825e31571c 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs +++ b/frontend/src/app/main/ui/workspace/tokens/sets/helpers.cljs @@ -41,7 +41,9 @@ (dwtl/clear-token-set-creation)) (if (empty? errors) (let [token-set (ctob/make-token-set :name name)] - (st/emit! (dwtl/create-token-set token-set))) + (st/emit! (dwtl/create-token-set token-set) + (dwtl/clear-tokens-paths) + (dwtl/clear-tokens-types))) (st/emit! (ntf/show {:content (tr "errors.token-set-already-exists") :type :toast :level :error diff --git a/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss b/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss index dee8bfe07b..f7141b973b 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sets/lists.scss @@ -21,6 +21,7 @@ .create-set-button { @include use-typography("body-small"); + background-color: transparent; border: none; appearance: none; @@ -29,7 +30,8 @@ } .set-item-container { - @include deprecated.bodySmallTypography; + @include deprecated.body-small-typography; + display: flex; align-items: center; width: 100%; @@ -43,9 +45,11 @@ &.dnd-over-bot { border-bottom: deprecated.$s-2 solid var(--layer-row-foreground-color-hover); } + &.dnd-over-top { border-top: deprecated.$s-2 solid var(--layer-row-foreground-color-hover); } + &.dnd-over { border: deprecated.$s-2 solid var(--layer-row-foreground-color-hover); } @@ -64,7 +68,8 @@ } .set-name { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + flex-grow: 1; padding-left: deprecated.$s-2; } @@ -112,7 +117,7 @@ } .check-icon { - color: currentColor; + color: currentcolor; } .set-item-container:hover { @@ -129,6 +134,7 @@ padding: deprecated.$s-12; color: var(--color-foreground-secondary); } + .selected-set { background-color: var(--layer-row-background-color-selected); color: var(--layer-row-foreground-color-selected); @@ -136,19 +142,21 @@ } .collapsabled-icon { - @include deprecated.buttonStyle; - @include deprecated.flexCenter; + @include deprecated.button-style; + @include deprecated.flex-center; + height: deprecated.$s-24; border-radius: deprecated.$br-8; + &:hover { color: var(--title-foreground-color-hover); } } .editing-node { - @include deprecated.textEllipsis; - @include deprecated.bodySmallTypography; - @include deprecated.removeInputStyle; + @include deprecated.text-ellipsis; + @include deprecated.body-small-typography; + @include deprecated.remove-input-style; border: deprecated.$s-1 solid var(--input-border-color-focus); border-radius: deprecated.$br-8; diff --git a/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss b/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss index ae5339a979..fc4164d92a 100644 --- a/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss +++ b/frontend/src/app/main/ui/workspace/tokens/settings/menu.scss @@ -5,19 +5,18 @@ // Copyright (c) KALEIDOS INC @use "ds/spacing.scss" as *; - @use "refactor/common-refactor.scss" as deprecated; .setting-modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .setting-modal { - @extend .modal-container-base; + @extend %modal-container-base; } .close-btn { - @extend .modal-close-btn-base; + @extend %modal-close-btn-base; } .settings-modal-layout { diff --git a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss index a48adee402..3eefaa09b0 100644 --- a/frontend/src/app/main/ui/workspace/tokens/sidebar.scss +++ b/frontend/src/app/main/ui/workspace/tokens/sidebar.scss @@ -11,6 +11,7 @@ .sidebar-wrapper { display: grid; grid-template-rows: auto 1fr auto; + // Overflow on the bottom section can't be done without hardcoded values for the height // This has to be changed from the wrapping sidebar styles height: calc(100vh - #{deprecated.$s-92}); @@ -18,7 +19,6 @@ } .token-management-section-wrapper { - position: relative; display: flex; flex: 1; height: var(--resize-height); @@ -55,8 +55,9 @@ .section-icon { margin-right: var(--sp-xs); + // Align better with the label - translate: 0px -1px; + translate: 0 -1px; } .import-export-button-wrapper { @@ -72,7 +73,8 @@ } .import-export-button { - @extend .button-secondary; + @extend %button-secondary; + display: flex; align-items: center; justify-content: end; @@ -80,12 +82,12 @@ text-transform: uppercase; gap: var(--sp-s); background-color: var(--color-background-primary); - box-shadow: var(--el-shadow-dark); } .import-export-menu { - @extend .menu-dropdown; + @extend %menu-dropdown; + top: -#{deprecated.$s-6}; right: 0; translate: 0 -100%; @@ -94,8 +96,10 @@ } .import-export-menu-item { - @extend .menu-item-base; + @extend %menu-item-base; + cursor: pointer; + &:hover { color: var(--menu-foreground-color-hover); } diff --git a/frontend/src/app/main/ui/workspace/tokens/themes.scss b/frontend/src/app/main/ui/workspace/tokens/themes.scss index a96f9f341a..4dc34ad389 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes.scss @@ -15,7 +15,7 @@ .themes-header { padding: var(--sp-s); color: var(--title-foreground-color); - word-break: break-word; + overflow-wrap: break-word; } .empty-theme-wrapper { @@ -29,6 +29,7 @@ .create-theme-button { @include use-typography("body-small"); + background-color: transparent; border: none; appearance: none; diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss index 6be2add41c..4cc6c1ef06 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes/create_modal.scss @@ -76,6 +76,7 @@ gap: var(--sp-xs); align-items: center; padding: 0; + &:hover { color: var(--color-accent-primary); } @@ -142,7 +143,7 @@ } .group-title-name { - @include textEllipsis; + @include text-ellipsis; flex-grow: 1; } @@ -171,7 +172,7 @@ } .theme-name-row { - @include textEllipsis; + @include text-ellipsis; flex-grow: 1; } diff --git a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss index 867d65eafe..cf9a2077a4 100644 --- a/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss +++ b/frontend/src/app/main/ui/workspace/tokens/themes/theme_selector.scss @@ -11,6 +11,7 @@ --custom-select-bg-color: var(--menu-background-color); --custom-select-icon-color: var(--color-foreground-secondary); --custom-select-text-color: var(--menu-foreground-color); + position: relative; display: grid; grid-template-columns: 1fr auto; @@ -24,6 +25,7 @@ border: deprecated.$s-1 solid var(--custom-select-border-color); color: var(--custom-select-text-color); cursor: pointer; + &:hover { --custom-select-bg-color: var(--menu-background-color-hover); --custom-select-border-color: var(--menu-background-color); @@ -41,7 +43,8 @@ } .group { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; + display: block; padding: deprecated.$s-8; color: var(--color-foreground-secondary); @@ -52,23 +55,26 @@ --custom-select-border-color: var(--menu-border-color-disabled); --custom-select-icon-color: var(--menu-foreground-color-disabled); --custom-select-text-color: var(--menu-foreground-color-disabled); + pointer-events: none; cursor: default; } .dropdown-button { - @include deprecated.flexCenter; + @include deprecated.flex-center; + color: var(--color-foreground-secondary); } .current-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + width: deprecated.$s-24; padding-right: deprecated.$s-4; } .custom-select-dropdown { - @extend .dropdown-wrapper; + @extend %dropdown-wrapper; } .separator { @@ -88,7 +94,8 @@ } .checked-element-button { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + position: relative; display: flex; justify-content: space-between; @@ -96,17 +103,20 @@ } .checked-element { - @extend .dropdown-element-base; + @extend %dropdown-element-base; + &.is-selected { color: var(--menu-foreground-color); } + &.disabled { display: none; } } .check-icon { - @include deprecated.flexCenter; + @include deprecated.flex-center; + color: var(--icon-foreground-primary); visibility: hidden; } @@ -121,12 +131,13 @@ } .current-label { - @include deprecated.textEllipsis; + @include deprecated.text-ellipsis; } .dropdown-portal { --menu-max-height: #{deprecated.$s-400}; - @extend .new-scrollbar; + + @extend %new-scrollbar; position: absolute; } diff --git a/frontend/src/app/main/ui/workspace/top_toolbar.scss b/frontend/src/app/main/ui/workspace/top_toolbar.scss index d005682878..f513b7b048 100644 --- a/frontend/src/app/main/ui/workspace/top_toolbar.scss +++ b/frontend/src/app/main/ui/workspace/top_toolbar.scss @@ -27,6 +27,7 @@ --toolbar-position-y: #{deprecated.$s-28}; --toolbar-offset-y: 0px; + top: calc(var(--toolbar-position-y) + var(--toolbar-offset-y)); } @@ -37,6 +38,7 @@ .main-toolbar-hidden { --toolbar-offset-y: -#{deprecated.$s-4}; + height: deprecated.$s-16; z-index: deprecated.$z-index-1; border-radius: 0 0 deprecated.$s-8 deprecated.$s-8; @@ -62,7 +64,8 @@ } .main-toolbar-options-button { - @extend .button-tertiary; + @extend %button-tertiary; + height: deprecated.$s-36; width: deprecated.$s-36; flex-shrink: 0; @@ -70,18 +73,20 @@ margin: 0 deprecated.$s-2; svg { - @extend .button-icon; + @extend %button-icon; + stroke: var(--color-foreground-secondary); } &.selected { - @extend .button-icon-selected; + @extend %button-icon-selected; } } .toolbar-handler { - @include deprecated.flexCenter; - @include deprecated.buttonStyle; + @include deprecated.flex-center; + @include deprecated.button-style; + position: absolute; left: 0; bottom: 0; diff --git a/frontend/src/app/main/ui/workspace/viewport.cljs b/frontend/src/app/main/ui/workspace/viewport.cljs index b27b543901..26553b35d8 100644 --- a/frontend/src/app/main/ui/workspace/viewport.cljs +++ b/frontend/src/app/main/ui/workspace/viewport.cljs @@ -97,6 +97,7 @@ {:keys [options-mode tooltip + preview-id show-distances? picking-color?]} wglobal @@ -260,7 +261,8 @@ show-rulers? (and (contains? layout :rulers) (not hide-ui?)) - disabled-guides? (or drawing-tool transform path-drawing? path-editing? @space? @mod?) + disabled-guides? (or drawing-tool transform path-drawing? path-editing? @space? @mod? + (contains? layout :lock-guides)) single-select? (= (count selected-shapes) 1) @@ -308,28 +310,33 @@ (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures?) + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) (hooks/setup-viewport-modifiers modifiers base-objects) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) - [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} - (when (:can-edit permissions) - (if read-only? - [:> view-only-bar* {}] - [:* - (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) + [:div {:class (stl/css :viewport) :style {"--zoom" zoom} :data-testid "viewport"} + (cond + (some? preview-id) + nil - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) + (and read-only? (:can-edit permissions)) + [:> view-only-bar* {}] - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])])) + :else + [:* + (when-not hide-ui? + [:> top-toolbar* {:layout layout}]) + + (when (and ^boolean path-editing? + ^boolean single-select?) + [:> path-edition-bar* {:shape editing-shape + :edit-path-state edit-path-state + :layout layout}]) + + (when (and ^boolean grid-editing? + ^boolean single-select?) + [:> grid-edition-bar* {:shape editing-shape}])]) [:div {:class (stl/css :viewport-overlays)} ;; The behaviour inside a foreign object is a bit different that in plain HTML so we wrap diff --git a/frontend/src/app/main/ui/workspace/viewport.scss b/frontend/src/app/main/ui/workspace/viewport.scss index 89ebd06403..cfd2209f7b 100644 --- a/frontend/src/app/main/ui/workspace/viewport.scss +++ b/frontend/src/app/main/ui/workspace/viewport.scss @@ -31,9 +31,6 @@ overflow: hidden; pointer-events: none; position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; + inset: 0; z-index: deprecated.$z-index-1; } diff --git a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss index d0f1549cb1..9009fe8d76 100644 --- a/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss +++ b/frontend/src/app/main/ui/workspace/viewport/grid_layout_editor.scss @@ -10,10 +10,11 @@ .marker-shape { fill: var(--grid-editor-marker-color); } + .marker-text { fill: var(--app-white); font-size: calc(deprecated.$s-12 / var(--zoom)); - font-family: worksans; + font-family: "worksans", "vazirmatn", sans-serif; } } @@ -36,7 +37,7 @@ background: none; border: 0; color: var(--grid-editor-marker-text); - font-family: worksans; + font-family: "worksans", "vazirmatn", sans-serif; font-size: calc(deprecated.$fs-12 / var(--zoom)); font-weight: 400; margin: 0; @@ -118,10 +119,11 @@ } .grid-actions-container { - @include deprecated.flexRow; + @include deprecated.flex-row; + background: var(--panel-background-color); border-radius: deprecated.$br-12; - box-shadow: 0px 0px deprecated.$s-12 0px var(--menu-shadow-color); + box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color); gap: deprecated.$s-8; height: deprecated.$s-48; margin-left: -50%; @@ -139,22 +141,25 @@ } .locate-btn { - @extend .button-secondary; + @extend %button-secondary; + text-transform: uppercase; padding: deprecated.$s-8 deprecated.$s-20; font-size: deprecated.$fs-11; } .done-btn { - @extend .button-primary; + @extend %button-primary; + text-transform: uppercase; padding: deprecated.$s-8 deprecated.$s-20; font-size: deprecated.$fs-11; } .close-btn { - @extend .button-tertiary; + @extend %button-tertiary; + svg { - @extend .button-icon; + @extend %button-icon; } } diff --git a/frontend/src/app/main/ui/workspace/viewport/guides.cljs b/frontend/src/app/main/ui/workspace/viewport/guides.cljs index d542d983c9..c79cafe702 100644 --- a/frontend/src/app/main/ui/workspace/viewport/guides.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/guides.cljs @@ -6,6 +6,7 @@ (ns app.main.ui.workspace.viewport.guides (:require + [app.common.data :as d] [app.common.data.macros :as dm] [app.common.files.helpers :as cfh] [app.common.geom.point :as gpt] @@ -23,12 +24,15 @@ [app.main.ui.formats :as fmt] [app.main.ui.workspace.viewport.rulers :as rulers] [app.util.dom :as dom] + [app.util.keyboard :as kbd] + [cuerdas.core :as str] [rumext.v2 :as mf])) (def ^:const guide-width 1) (def ^:const guide-opacity 0.7) (def ^:const guide-opacity-hover 1) -(def ^:const guide-color colors/new-danger) +(def ^:const default-guide-color colors/new-danger) + (def ^:const guide-pill-width 34) (def ^:const guide-pill-height 20) (def ^:const guide-pill-corner-radius 4) @@ -282,13 +286,29 @@ (mf/defc guide* {::mf/wrap [mf/memo]} [{:keys [guide is-hover on-guide-change get-hover-frame vbox zoom - hover-frame disabled-guides frame-modifier frame-transform]}] + hover-frame disabled-guides frame-modifier frame-transform + on-guide-context-menu]}] (let [axis (get guide :axis) + guide-color + (or (:color guide) default-guide-color) + + read-only? + (mf/use-ctx ctx/workspace-read-only?) + + is-editing* + (mf/use-state false) + + is-editing + (deref is-editing*) + + input-ref + (mf/use-ref nil) + handle-change-position (mf/use-fn - (mf/deps on-guide-change) + (mf/deps on-guide-change guide) (fn [changes] (when on-guide-change (on-guide-change (merge guide changes))))) @@ -329,14 +349,68 @@ frame-guide-outside? (and (some? frame) - (not (is-guide-inside-frame? (assoc guide :position pos) frame)))] + (not (is-guide-inside-frame? (assoc guide :position pos) frame))) + + frame-offset + (if (some? frame) + (if (= axis :x) (:x frame) (:y frame)) + 0) + + accept-editing + (mf/use-fn + (mf/deps frame-offset on-guide-change guide) + (fn [] + ;; Enter both fires this and triggers a blur that calls it again; + ;; bail out on the second invocation when the input is already gone. + (when-let [input (mf/ref-val input-ref)] + (let [parsed (-> input dom/get-value str/trim d/parse-double)] + (reset! is-editing* false) + (when (and (some? parsed) (some? on-guide-change)) + (on-guide-change (assoc guide :position (+ parsed frame-offset)))))))) + + cancel-editing + (mf/use-fn + #(reset! is-editing* false)) + + on-input-key-down + (mf/use-fn + (mf/deps accept-editing cancel-editing) + (fn [event] + (cond + (kbd/enter? event) + (do (dom/prevent-default event) + (dom/stop-propagation event) + (accept-editing)) + + (kbd/esc? event) + (do (dom/prevent-default event) + (dom/stop-propagation event) + (cancel-editing))))) + + on-double-click + (mf/use-fn + (mf/deps read-only?) + (fn [event] + (when-not read-only? + (dom/stop-propagation event) + (reset! is-editing* true))))] + + (mf/with-effect [is-editing] + (when is-editing + (some-> (mf/ref-val input-ref) dom/select-text!))) (when (or (nil? frame) (and (cfh/root-frame? frame) (not (ctst/rotated-frame? frame)))) [:g.guide-area {:opacity (when frame-guide-outside? 0)} (when-not disabled-guides - (let [{:keys [x y width height]} (guide-area-axis pos vbox zoom frame axis)] + (let [{:keys [x y width height]} (guide-area-axis pos vbox zoom frame axis) + on-context-menu + (fn [event] + (dom/prevent-default event) + (dom/stop-propagation event) + (when on-guide-context-menu + (on-guide-context-menu event guide)))] [:rect {:x x :y y :width width @@ -349,7 +423,9 @@ :on-pointer-down on-pointer-down :on-pointer-up on-pointer-up :on-lost-pointer-capture on-lost-pointer-capture - :on-pointer-move on-pointer-move}])) + :on-pointer-move on-pointer-move + :on-context-menu on-context-menu + :on-double-click on-double-click}])) (if (some? frame) (let [{:keys [l1-x1 l1-y1 l1-x2 l1-y2 @@ -398,9 +474,12 @@ guide-opacity-hover guide-opacity)}}])) - (when (or is-hover (:hover @state)) + (when (or is-hover (:hover @state) is-editing) (let [{:keys [rect-x rect-y rect-width rect-height text-x text-y]} - (guide-pill-axis pos vbox zoom axis)] + (guide-pill-axis pos vbox zoom axis) + display-value (fmt/format-number (- pos frame-offset)) + input-w (/ guide-pill-width zoom) + input-h (/ guide-pill-height zoom)] [:g.guide-pill [:rect {:x rect-x :y rect-y @@ -408,18 +487,46 @@ :height rect-height :rx guide-pill-corner-radius :ry guide-pill-corner-radius - :style {:fill guide-color}}] + :style {:fill guide-color} + :on-double-click on-double-click}] - [:text {:x text-x - :y text-y - :text-anchor "middle" - :dominant-baseline "middle" - :transform (when (= axis :y) (str "rotate(-90 " text-x "," text-y ")")) - :style {:font-size (/ rulers/font-size zoom) - :font-family rulers/font-family - :fill colors/white}} - ;; If the guide is associated to a frame we show the position relative to the frame - (fmt/format-number (- pos (if (= axis :x) (:x frame) (:y frame))))]]))]))) + (if is-editing + [:foreignObject {:x (- text-x (/ input-w 2)) + :y (- text-y (/ input-h 2)) + :width input-w + :height input-h + :transform (when (= axis :y) + (str "rotate(-90 " text-x "," text-y ")"))} + [:input {:ref input-ref + :type "number" + :step "any" + :default-value display-value + :auto-focus true + :on-key-down on-input-key-down + :on-blur accept-editing + :on-pointer-down dom/stop-propagation + :style {:width "100%" + :height "100%" + :border "none" + :outline "none" + :padding 0 + :margin 0 + :background "transparent" + :color colors/white + :font-family rulers/font-family + :font-size (str (/ rulers/font-size zoom) "px") + :text-align "center" + :-moz-appearance "textfield"}}]] + [:text {:x text-x + :y text-y + :text-anchor "middle" + :dominant-baseline "middle" + :transform (when (= axis :y) (str "rotate(-90 " text-x "," text-y ")")) + :style {:font-size (/ rulers/font-size zoom) + :font-family rulers/font-family + :fill colors/white}} + ;; If the guide is associated to a frame we show the position relative to the frame + display-value])]))]))) (mf/defc new-guide-area* [{:keys [vbox zoom axis get-hover-frame disabled-guides]}] @@ -502,6 +609,13 @@ (st/emit! (dw/update-guides guide)) (st/emit! (dw/remove-guide guide))))) + on-guide-context-menu + (mf/use-fn + (fn [event guide] + (let [position (dom/get-client-position event)] + (st/emit! (dw/show-guide-context-menu {:position position + :guide guide}))))) + frame-modifiers (-> (group-by :id modifiers) (update-vals (comp :transform first)))] @@ -533,4 +647,5 @@ :frame-transform (get frame-modifiers frame-id) :get-hover-frame get-hover-frame :on-guide-change on-guide-change + :on-guide-context-menu on-guide-context-menu :disabled-guides disabled-guides}]))])) diff --git a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs index 86280c6a43..140b5d5dd1 100644 --- a/frontend/src/app/main/ui/workspace/viewport/hooks.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/hooks.cljs @@ -177,7 +177,7 @@ (dw/increase-zoom))))))) (defn setup-hover-shapes - [page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures?] + [page-id move-stream objects selected mod? hover measure-hover hover-ids hover-top-frame-id hover-disabled? focus zoom show-measures? read-only?] (let [;; We use ref so we don't recreate the stream on a change zoom-ref (mf/use-ref zoom) mod-ref (mf/use-ref @mod?) @@ -261,7 +261,7 @@ (let [sorted-ids-cache (mf/use-ref {})] (hooks/use-stream over-shapes-stream - (mf/deps page-id objects show-measures?) + (mf/deps page-id objects show-measures? read-only?) (fn [ids] (let [selected (mf/ref-val selected-ref) focus (mf/ref-val focus-ref) @@ -273,7 +273,7 @@ (let [sorted-ids (into (d/ordered-set) (comp (remove (partial cfh/hidden-parent? objects)) - (remove #(dm/get-in objects [% :blocked])) + (remove #(and (not read-only?) (dm/get-in objects [% :blocked]))) (remove (partial cfh/svg-raw-shape? objects))) (ctt/sort-z-index objects ids {:bottom-frames? mod?}))] (mf/set-ref-val! sorted-ids-cache (assoc cached-ids [mod? ids] sorted-ids)) diff --git a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs index 8617405003..8b1ae15552 100644 --- a/frontend/src/app/main/ui/workspace/viewport/interactions.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/interactions.cljs @@ -33,6 +33,10 @@ :move-overlay-index]) refs/workspace-local =)) +(def ^:private outgoing-link-color "var(--color-accent-tertiary)") +(def ^:private incoming-link-color "var(--color-accent-quaternary)") +(def ^:private neutral-link-color "var(--df-secondary)") + (defn- on-pointer-down [event index {:keys [id] :as shape}] (dom/stop-propagation event) @@ -139,7 +143,7 @@ (mf/defc interaction-path* - [{:keys [index level orig-shape dest-shape dest-point is-selected action-type zoom]}] + [{:keys [index level orig-shape dest-shape dest-point selected is-selected action-type zoom]}] (let [[orig-pos orig-x orig-y dest-pos dest-x dest-y] (cond dest-shape @@ -160,11 +164,17 @@ path ["M" orig-x orig-y "C" (+ orig-x orig-dx) orig-y (+ dest-x dest-dx) dest-y dest-x dest-y] pdata (str/join " " path) - arrow-dir (if (= dest-pos :left) :right :left)] + arrow-dir (if (= dest-pos :left) :right :left) + incoming? (and (some? dest-shape) + (contains? selected (:id dest-shape))) + stroke-color (cond + is-selected outgoing-link-color + incoming? incoming-link-color + :else neutral-link-color)] (if-not is-selected [:g {:on-pointer-down #(on-pointer-down % index orig-shape)} - [:path {:stroke "var(--df-secondary)" + [:path {:stroke stroke-color :fill "none" :pointer-events "visible" :stroke-width (/ 2 zoom) @@ -173,13 +183,13 @@ [:> interaction-marker* {:index index :x dest-x :y dest-y - :stroke "var(--df-secondary)" + :stroke stroke-color :action-type action-type :arrow-dir arrow-dir :zoom zoom}])] [:g {:on-pointer-down #(on-pointer-down % index orig-shape)} - [:path {:stroke "var(--color-accent-tertiary)" + [:path {:stroke stroke-color :fill "none" :pointer-events "visible" :stroke-width (/ 2 zoom) @@ -188,17 +198,17 @@ (when dest-shape [:& outline {:zoom zoom :shape dest-shape - :color "var(--color-accent-tertiary)"}]) + :color stroke-color}]) [:> interaction-marker* {:index index :x orig-x :y orig-y - :stroke "var(--color-accent-tertiary)" + :stroke stroke-color :zoom zoom}] [:> interaction-marker* {:index index :x dest-x :y dest-y - :stroke "var(--color-accent-tertiary)" + :stroke stroke-color :action-type action-type :arrow-dir arrow-dir :zoom zoom}]]))) diff --git a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss index 94c86e6a8d..e079d01f1a 100644 --- a/frontend/src/app/main/ui/workspace/viewport/path_actions.scss +++ b/frontend/src/app/main/ui/workspace/viewport/path_actions.scss @@ -39,7 +39,9 @@ .topbar-btn { --pathbar-icon-color: var(--color-foreground-secondary); - @extend .button-tertiary; + + @extend %button-tertiary; + height: deprecated.$s-36; width: deprecated.$s-36; flex-shrink: 0; @@ -50,11 +52,13 @@ &.is-toggled { --pathbar-icon-color: var(--button-radio-foreground-color-active); + background-color: var(--button-radio-background-color-active); } .pathbar-icon { - @extend .button-icon; + @extend %button-icon; + stroke: var(--pathbar-icon-color); } } diff --git a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss index fbbe82c72e..87d3abe4bc 100644 --- a/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss +++ b/frontend/src/app/main/ui/workspace/viewport/pixel_overlay.scss @@ -5,11 +5,8 @@ // Copyright (c) KALEIDOS INC .pixel-overlay { - left: 0; + inset: 0; pointer-events: initial; position: absolute; - top: 0; - right: 0; - bottom: 0; z-index: 1; } diff --git a/frontend/src/app/main/ui/workspace/viewport/presence.scss b/frontend/src/app/main/ui/workspace/viewport/presence.scss index d71cd38e21..32486a0244 100644 --- a/frontend/src/app/main/ui/workspace/viewport/presence.scss +++ b/frontend/src/app/main/ui/workspace/viewport/presence.scss @@ -8,7 +8,7 @@ .profile-name { width: fit-content; - font-family: worksans; + font-family: "worksans", "vazirmatn", sans-serif; padding: 2px 12px; border-radius: deprecated.$br-4; display: flex; diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs index b9f4f69cb4..2fca82347f 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.cljs @@ -20,14 +20,12 @@ ;; branch. (mf/defc view-only-bar* - {::mf/private true} [] - (let [handle-close-view-mode + (let [on-close (mf/use-fn - (fn [] - (st/emit! :interrupt - (dw/set-options-mode :design) - (dwc/set-workspace-read-only false))))] + #(st/emit! :interrupt + (dw/set-options-mode :design) + (dwc/set-workspace-read-only false)))] [:div {:class (stl/css :viewport-actions)} [:div {:class (stl/css :viewport-actions-container)} [:div {:class (stl/css :viewport-actions-title)} @@ -35,7 +33,7 @@ {:tag-name "span" :content (tr "workspace.top-bar.view-only")}]] [:button {:class (stl/css :done-btn) - :on-click handle-close-view-mode} + :on-click on-close} (tr "workspace.top-bar.read-only.done")]]])) (mf/defc path-edition-bar* diff --git a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss index 802dc32ab7..5ee297d756 100644 --- a/frontend/src/app/main/ui/workspace/viewport/top_bar.scss +++ b/frontend/src/app/main/ui/workspace/viewport/top_bar.scss @@ -10,6 +10,7 @@ .viewport-actions-path { pointer-events: none; position: absolute; + --actions-toolbar-position-y: #{deprecated.$s-28}; --actions-toolbar-offset-y: #{deprecated.$s-6}; @@ -23,7 +24,8 @@ } .viewport-actions-container { - @include deprecated.flexRow; + @include deprecated.flex-row; + background: var(--panel-background-color); border-radius: deprecated.$br-12; box-shadow: 0 0 deprecated.$s-12 0 var(--menu-shadow-color); @@ -45,7 +47,8 @@ } .done-btn { - @extend .button-primary; + @extend %button-primary; + text-transform: uppercase; padding: deprecated.$s-8 deprecated.$s-20; font-size: deprecated.$fs-11; diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs index a47897d2d6..dac8657ad2 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.cljs +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.cljs @@ -33,24 +33,36 @@ (mf/defc pixel-grid* [{:keys [vbox zoom]}] - [:g.pixel-grid - [:defs - [:pattern {:id "pixel-grid" - :viewBox "0 0 1 1" - :width 1 - :height 1 - :pattern-units "userSpaceOnUse"} - [:path {:d "M 1 0 L 0 0 0 1" - :style {:fill "none" - :stroke (if (dbg/enabled? :pixel-grid) "red" "var(--status-color-info-500)") - :stroke-opacity (if (dbg/enabled? :pixel-grid) 1 "0.2") - :stroke-width (str (/ 1 zoom))}}]]] - [:rect {:x (:x vbox) - :y (:y vbox) - :width (:width vbox) - :height (:height vbox) - :fill (str "url(#pixel-grid)") - :style {:pointer-events "none"}}]]) + (let [page (mf/deref refs/workspace-page) + custom-color (:pixel-grid-color page) + custom-alpha (:pixel-grid-opacity page) + debug? (dbg/enabled? :pixel-grid) + stroke (cond + debug? "red" + custom-color custom-color + :else "var(--status-color-info-500)") + opacity (cond + debug? 1 + (some? custom-alpha) custom-alpha + :else 0.2)] + [:g.pixel-grid + [:defs + [:pattern {:id "pixel-grid" + :viewBox "0 0 1 1" + :width 1 + :height 1 + :pattern-units "userSpaceOnUse"} + [:path {:d "M 1 0 L 0 0 0 1" + :style {:fill "none" + :stroke stroke + :stroke-opacity opacity + :stroke-width (str (/ 1 zoom))}}]]] + [:rect {:x (:x vbox) + :y (:y vbox) + :width (:width vbox) + :height (:height vbox) + :fill (str "url(#pixel-grid)") + :style {:pointer-events "none"}}]])) (mf/defc cursor-tooltip* [{:keys [zoom tooltip]}] diff --git a/frontend/src/app/main/ui/workspace/viewport/widgets.scss b/frontend/src/app/main/ui/workspace/viewport/widgets.scss index 319888776c..4a1b949374 100644 --- a/frontend/src/app/main/ui/workspace/viewport/widgets.scss +++ b/frontend/src/app/main/ui/workspace/viewport/widgets.scss @@ -21,13 +21,13 @@ .frame-flow-badge-content { @include t.use-typography("body-small"); + display: flex; align-items: center; justify-content: center; gap: var(--sp-xs); border-radius: $br-6; - padding-inline-start: var(--sp-xs); - padding-inline-end: var(--sp-s); + padding-inline: var(--sp-xs) var(--sp-s); height: var(--sp-xxl); background-color: var(--frame-flow-badge-background-color); color: var(--frame-flow-badge-foreground-color); @@ -47,6 +47,7 @@ .frame-title-label { @include t.use-typography("body-small"); + text-overflow: ellipsis; overflow: hidden; white-space: nowrap; @@ -56,6 +57,7 @@ .frame-title-input { @include t.use-typography("body-small"); + flex-grow: 1; width: 100%; max-width: initial; @@ -73,6 +75,7 @@ cursor: pointer; fill: var(--button-add-background-color); + &:hover { --button-add-background-color: var(--button-add-background-color-hover); } diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs index 8a9f8caad4..837c72f598 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.cljs +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.cljs @@ -98,6 +98,7 @@ {:keys [options-mode tooltip show-distances? + preview-id picking-color?]} wglobal @@ -283,7 +284,9 @@ hide-ui? (contains? layout :hide-ui) show-rulers? (and (contains? layout :rulers) (not hide-ui?)) - disabled-guides? (or drawing-tool transform path-drawing? path-editing?) + + disabled-guides? (or drawing-tool transform path-drawing? path-editing? + (contains? layout :lock-guides)) single-select? (= (count selected-shapes) 1) @@ -450,27 +453,33 @@ (hooks/setup-cursor cursor alt? mod? space? panning drawing-tool path-drawing? path-editing? z? read-only?) (hooks/setup-keyboard alt? mod? space? z? shift?) (hooks/setup-hover-shapes page-id move-stream base-objects selected mod? hover measure-hover - hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures?) + hover-ids hover-top-frame-id @hover-disabled? focus zoom show-measures? read-only?) (hooks/setup-shortcuts path-editing? path-drawing? text-editing? grid-editing?) (hooks/setup-active-frames base-objects hover-ids selected active-frames zoom transform vbox) [:div {:class (stl/css :viewport) :style #js {"--zoom" zoom} :data-testid "viewport"} - (when (:can-edit permissions) - (if read-only? - [:> view-only-bar* {}] - [:* - (when-not hide-ui? - [:> top-toolbar* {:layout layout}]) - (when (and ^boolean path-editing? - ^boolean single-select?) - [:> path-edition-bar* {:shape editing-shape - :edit-path-state edit-path-state - :layout layout}]) + (cond + (some? preview-id) + nil - (when (and ^boolean grid-editing? - ^boolean single-select?) - [:> grid-edition-bar* {:shape editing-shape}])])) + (and read-only? (:can-edit permissions)) + [:> view-only-bar* {}] + + :else + [:* + (when-not hide-ui? + [:> top-toolbar* {:layout layout}]) + + (when (and ^boolean path-editing? + ^boolean single-select?) + [:> path-edition-bar* {:shape editing-shape + :edit-path-state edit-path-state + :layout layout}]) + + (when (and ^boolean grid-editing? + ^boolean single-select?) + [:> grid-edition-bar* {:shape editing-shape}])]) [:div {:class (stl/css :viewport-overlays)} (when show-comments? diff --git a/frontend/src/app/main/ui/workspace/viewport_wasm.scss b/frontend/src/app/main/ui/workspace/viewport_wasm.scss index a83fde4650..99af2bee48 100644 --- a/frontend/src/app/main/ui/workspace/viewport_wasm.scss +++ b/frontend/src/app/main/ui/workspace/viewport_wasm.scss @@ -29,10 +29,7 @@ overflow: hidden; pointer-events: none; position: absolute; - top: 0; - left: 0; - bottom: 0; - right: 0; + inset: 0; z-index: 10; } @@ -40,7 +37,7 @@ position: fixed; inset: 0; z-index: 100; - background-color: rgba(0, 0, 0, 0.5); + background-color: rgb(0 0 0 / 0.5); display: grid; place-items: center; cursor: default; diff --git a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss index 0ad86b0c29..d57571ef0c 100644 --- a/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss +++ b/frontend/src/app/main/ui/workspace/webgl_unavailable_modal.scss @@ -6,15 +6,14 @@ @use "ds/_utils.scss" as *; @use "ds/_borders.scss" as *; - @use "refactor/common-refactor.scss" as deprecated; .modal-overlay { - @extend .modal-overlay-base; + @extend %modal-overlay-base; } .modal-dialog { - @extend .modal-container-base; + @extend %modal-container-base; color: var(--color-foreground-secondary); display: grid; diff --git a/frontend/src/app/plugins/api.cljs b/frontend/src/app/plugins/api.cljs index 68526ae8a4..f7c5a324a6 100644 --- a/frontend/src/app/plugins/api.cljs +++ b/frontend/src/app/plugins/api.cljs @@ -284,7 +284,23 @@ {:file-id file-id :local? false :name name - :blobs [(js/Blob. #js [data] #js {:type mime-type})] + :blobs [(js/Blob. + #js [(cond + (instance? js/Uint8Array data) + data + + (instance? js/ArrayBuffer data) + (js/Uint8Array. data) + + (array? data) + (js/Uint8Array.from data) + + (and (some? data) (= (type data) js/Object)) + (js/Uint8Array.from (js/Object.values data)) + + :else + data)] + #js {:type mime-type})] :on-image identity :on-svg identity}) (rx/take 1) diff --git a/frontend/src/app/plugins/library.cljs b/frontend/src/app/plugins/library.cljs index 1022a1a65c..8a249d32dc 100644 --- a/frontend/src/app/plugins/library.cljs +++ b/frontend/src/app/plugins/library.cljs @@ -1108,23 +1108,20 @@ :connectLibrary (fn [library-id] - (cond - (not (r/check-permission plugin-id "library:write")) - (u/not-valid plugin-id :connectLibrary "Plugin doesn't have 'library:write' permission") + (js/Promise. + (fn [resolve reject] + (cond + (not (r/check-permission plugin-id "library:write")) + (u/reject-not-valid reject :connectLibrary "Plugin doesn't have 'library:write' permission") - :else - (js/Promise. - (fn [resolve reject] - (cond - (not (string? library-id)) - (do (u/not-valid plugin-id :connectLibrary library-id) - (reject nil)) + (not (string? library-id)) + (u/reject-not-valid reject :connectLibrary library-id) - :else - (let [file-id (:current-file-id @st/state) - library-id (uuid/parse library-id)] - (->> st/stream - (rx/filter (ptk/type? ::dwl/attach-library-finished)) - (rx/take 1) - (rx/subs! #(resolve (library-proxy plugin-id library-id)) reject)) - (st/emit! (dwl/link-file-to-library file-id library-id)))))))))) + :else + (let [file-id (:current-file-id @st/state) + library-id (uuid/parse library-id)] + (->> st/stream + (rx/filter (ptk/type? ::dwl/attach-library-finished)) + (rx/take 1) + (rx/subs! #(resolve (library-proxy plugin-id library-id)) reject)) + (st/emit! (dwl/link-file-to-library file-id library-id))))))))) diff --git a/frontend/src/app/plugins/parser.cljs b/frontend/src/app/plugins/parser.cljs index 5d4148662d..29ec5c2bf7 100644 --- a/frontend/src/app/plugins/parser.cljs +++ b/frontend/src/app/plugins/parser.cljs @@ -7,6 +7,7 @@ (ns app.plugins.parser (:require [app.common.data :as d] + [app.common.geom.point :as gpt] [app.common.json :as json] [app.common.types.path :as path] [app.common.uuid :as uuid] @@ -26,10 +27,16 @@ (if (string? color) (-> color str/lower) color)) (defn parse-point + "Parses a point-like JS object into a `gpt/point` record. + + The schema for shape interactions (`schema:open-overlay-interaction`, + `::gpt/point`) requires a Point record — returning a plain map caused + plugin `addInteraction` calls with an `open-overlay` action and a + `manualPositionLocation` to be silently rejected. See issue #8409." [^js point] (when point - {:x (obj/get point "x") - :y (obj/get point "y")})) + (gpt/point (obj/get point "x") + (obj/get point "y")))) (defn parse-shape-type [type] diff --git a/frontend/src/app/plugins/register.cljs b/frontend/src/app/plugins/register.cljs index e3792f3fc9..df9afb6380 100644 --- a/frontend/src/app/plugins/register.cljs +++ b/frontend/src/app/plugins/register.cljs @@ -54,7 +54,10 @@ (conj "library:read") (contains? permissions "comment:write") - (conj "comment:read")) + (conj "comment:read") + + (contains? permissions "clipboard:write") + (conj "clipboard:read")) plugin-url (u/uri plugin-url) diff --git a/frontend/src/app/plugins/ruler_guides.cljs b/frontend/src/app/plugins/ruler_guides.cljs index 696df23001..8c90b74ca9 100644 --- a/frontend/src/app/plugins/ruler_guides.cljs +++ b/frontend/src/app/plugins/ruler_guides.cljs @@ -24,7 +24,7 @@ (defn ruler-guide-proxy [plugin-id file-id page-id id] - (obj/reify {:name "RuleGuideProxy"} + (obj/reify {:name "RulerGuideProxy"} :$plugin {:enumerable false :get (constantly plugin-id)} :$file {:enumerable false :get (constantly file-id)} :$page {:enumerable false :get (constantly page-id)} @@ -94,6 +94,22 @@ value)] (st/emit! (dwgu/update-guides (assoc guide :position position))))))} + :color + {:this true + :get + (fn [self] + (-> self u/proxy->ruler-guide :color)) + + :set + (fn [self value] + (cond + (not (r/check-permission plugin-id "content:write")) + (u/not-valid plugin-id :color "Plugin doesn't have 'content:write' permission") + + :else + (let [guide (u/proxy->ruler-guide self)] + (st/emit! (dwgu/update-guides (assoc guide :color value))))))} + :remove (fn [] (let [guide (u/locate-ruler-guide file-id page-id id)] diff --git a/frontend/src/app/plugins/shape.cljs b/frontend/src/app/plugins/shape.cljs index 1a1144b086..63037e405c 100644 --- a/frontend/src/app/plugins/shape.cljs +++ b/frontend/src/app/plugins/shape.cljs @@ -1186,8 +1186,8 @@ (let [objects (u/locate-objects file-id page-id) shape (u/locate-shape file-id page-id id)] (when (ctn/in-any-component? objects shape) - (let [[root component] (u/locate-component objects shape)] - (lib-component-proxy plugin-id (:component-file root) (:id component)))))) + (when-let [[head component] (u/locate-head-component objects shape)] + (lib-component-proxy plugin-id (:component-file head) (:id component)))))) :detach (fn [] @@ -1293,7 +1293,7 @@ (u/not-valid plugin-id :addRulerGuide "Plugin doesn't have 'content:write' permission") :else - (let [id (uuid/next) + (let [ruler-id (uuid/next) axis (parser/orientation->axis orientation) objects (u/locate-objects file-id page-id) frame (get objects id) @@ -1301,11 +1301,11 @@ position (+ board-pos value)] (st/emit! (dwgu/update-guides - {:id id + {:id ruler-id :axis axis :position position :frame-id id})) - (rg/ruler-guide-proxy plugin-id file-id page-id id))))) + (rg/ruler-guide-proxy plugin-id file-id page-id ruler-id))))) :removeRulerGuide (fn [_ value] diff --git a/frontend/src/app/plugins/tokens.cljs b/frontend/src/app/plugins/tokens.cljs index a6f1d3a850..776a905308 100644 --- a/frontend/src/app/plugins/tokens.cljs +++ b/frontend/src/app/plugins/tokens.cljs @@ -214,127 +214,134 @@ (obj/type-of? p "TokenSetProxy")) (defn token-set-proxy - [plugin-id file-id id] - (obj/reify {:name "TokenSetProxy" - :on-error (u/handle-error plugin-id)} - :$plugin {:enumerable false :get (constantly plugin-id)} - :$file-id {:enumerable false :get (constantly file-id)} - :$id {:enumerable false :get (constantly id)} + ([plugin-id file-id id] + (token-set-proxy plugin-id file-id id nil)) + ([plugin-id file-id id initial-name] + (obj/reify {:name "TokenSetProxy" + :on-error (u/handle-error plugin-id)} + :$plugin {:enumerable false :get (constantly plugin-id)} + :$file-id {:enumerable false :get (constantly file-id)} + :$id {:enumerable false :get (constantly id)} - :id - {:get #(dm/str id)} + :id + {:get #(dm/str id)} - :name - {:this true - :get + :name + {:this true + :get + (fn [_] + ;; Prefer the authoritative state lookup; fall back to initial-name + ;; when the async state update from `catalog.addSet()` hasn't + ;; propagated yet. + (let [set (u/locate-token-set file-id id)] + (if (some? set) + (ctob/get-name set) + initial-name))) + :schema (cfo/make-token-set-name-schema + (u/locate-tokens-lib file-id) + id) + :set + (fn [_ name] + (let [set (u/locate-token-set file-id id)] + (st/emit! (dwtl/rename-token-set set name))))} + + :active + {:this true + :enumerable false + :get + (fn [_] + (let [tokens-lib (u/locate-tokens-lib file-id) + set (u/locate-token-set file-id id)] + (ctob/token-set-active? tokens-lib (ctob/get-name set)))) + :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))))} + + :toggleActive (fn [_] (let [set (u/locate-token-set file-id id)] - (ctob/get-name set))) - :schema (cfo/make-token-set-name-schema - (u/locate-tokens-lib file-id) - id) - :set - (fn [_ name] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/rename-token-set set name))))} + (st/emit! (dwtl/toggle-token-set (ctob/get-name set))))) - :active - {:this true - :enumerable false - :get - (fn [_] - (let [tokens-lib (u/locate-tokens-lib file-id) - set (u/locate-token-set file-id id)] - (ctob/token-set-active? tokens-lib (ctob/get-name set)))) - :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))))} + :tokens + {:this true + :enumerable false + :get + (fn [_] + (let [tokens-lib (u/locate-tokens-lib file-id)] + (->> (ctob/get-tokens tokens-lib id) + (vals) + (map #(token-proxy plugin-id file-id id (:id %))) + (apply array))))} - :toggleActive - (fn [_] - (let [set (u/locate-token-set file-id id)] - (st/emit! (dwtl/toggle-token-set (ctob/get-name set))))) + :tokensByType + {:this true + :enumerable false + :get + (fn [_] + (let [tokens-lib (u/locate-tokens-lib file-id) + tokens (ctob/get-tokens tokens-lib id)] + (->> tokens + (vals) + (sort-by :name) + (group-by #(cto/token-type->dtcg-token-type (:type %))) + (into []) + (mapv (fn [[type tokens]] + #js [(name type) + (->> tokens + (map #(token-proxy plugin-id file-id id (:id %))) + (apply array))])) + (apply array))))} - :tokens - {:this true - :enumerable false - :get - (fn [_] - (let [tokens-lib (u/locate-tokens-lib file-id)] - (->> (ctob/get-tokens tokens-lib id) - (vals) - (map #(token-proxy plugin-id file-id id (:id %))) - (apply array))))} + :getTokenById + {:enumerable false + :schema [:tuple ::sm/uuid] + :fn (fn [token-id] + (let [token (u/locate-token file-id id token-id)] + (when (some? token) + (token-proxy plugin-id file-id id token-id))))} - :tokensByType - {:this true - :enumerable false - :get - (fn [_] - (let [tokens-lib (u/locate-tokens-lib file-id) - tokens (ctob/get-tokens tokens-lib id)] - (->> tokens - (vals) - (sort-by :name) - (group-by #(cto/token-type->dtcg-token-type (:type %))) - (into []) - (mapv (fn [[type tokens]] - #js [(name type) - (->> tokens - (map #(token-proxy plugin-id file-id id (:id %))) - (apply array))])) - (apply array))))} + :addToken + {:enumerable false + :schema (fn [args] + (let [tokens-tree (-> (u/locate-tokens-lib file-id) + (ctob/get-tokens id) + ;; Convert to the adecuate format for schema + (ctob/tokens-tree))] + [:tuple (-> (cfo/make-token-schema + tokens-tree + (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) + ;; Don't allow plugins to set the id + (sm/dissoc-key :id) + ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) + ;; and set a converter that changes DTCG types to internal types (:decode/json). + ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width + (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) + tokens-tree (-> (ctob/get-tokens-in-active-sets tokens-lib) + (assoc (:name token) token)) + resolved-tokens (ts/resolve-tokens tokens-tree) - :getTokenById - {:enumerable false - :schema [:tuple ::sm/uuid] - :fn (fn [token-id] - (let [token (u/locate-token file-id id token-id)] - (when (some? token) - (token-proxy plugin-id file-id id token-id))))} + {:keys [errors resolved-value] :as resolved-token} + (get resolved-tokens (:name token))] - :addToken - {:enumerable false - :schema (fn [args] - (let [tokens-tree (-> (u/locate-tokens-lib file-id) - (ctob/get-tokens id) - ;; Convert to the adecuate format for schema - (ctob/tokens-tree))] - [:tuple (-> (cfo/make-token-schema - tokens-tree - (cto/dtcg-token-type->token-type (-> args (first) (get "type")))) - ;; Don't allow plugins to set the id - (sm/dissoc-key :id) - ;; Instruct the json decoder in obj/reify not to process map keys (:key-fn below) - ;; and set a converter that changes DTCG types to internal types (:decode/json). - ;; E.g. "FontFamilies" -> :font-family or "BorderWidth" -> :stroke-width - (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) - tokens-tree (-> (ctob/get-tokens-in-active-sets tokens-lib) - (assoc (:name token) token)) - resolved-tokens (ts/resolve-tokens tokens-tree) + (if resolved-value + (do (st/emit! (dwtl/create-token id token)) + (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))] + :duplicate + (fn [] + (st/emit! (dwtl/duplicate-token-set id))) - (if resolved-value - (do (st/emit! (dwtl/create-token id token)) - (token-proxy plugin-id file-id id (:id token))) - (do (u/not-valid plugin-id :addToken (str errors)) - nil))))} - - :duplicate - (fn [] - (st/emit! (dwtl/duplicate-token-set id))) - - :remove - (fn [] - (st/emit! (dwtl/delete-token-set id))))) + :remove + (fn [] + (st/emit! (dwtl/delete-token-set id)))))) (defn token-theme-proxy? [p] (obj/type-of? p "TokenThemeProxy")) @@ -423,15 +430,26 @@ {:enumerable false :schema [:tuple [:fn token-set-proxy?]] :fn (fn [token-set] - (let [theme (u/locate-token-theme file-id id)] - (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme (obj/get token-set :name))))))} + ;; Resolve the set name before the theme lookup. The proxy's :name + ;; getter now falls back to `initial-name` when state hasn't + ;; propagated, so this is safe even for freshly created sets. + ;; Guard against nil to prevent `enable-set` from conj'ing nil + ;; into the theme's :sets — which would send `:sets #{nil}` to the + ;; backend and crash the workspace. + (let [set-name (obj/get token-set :name) + theme (u/locate-token-theme file-id id)] + (when (and (some? set-name) (some? theme)) + (st/emit! (dwtl/update-token-theme id (ctob/enable-set theme set-name))))))} :removeSet {:enumerable false :schema [:tuple [:fn token-set-proxy?]] :fn (fn [token-set] - (let [theme (u/locate-token-theme file-id id)] - (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme (obj/get token-set :name))))))} + ;; Same nil guard as addSet — see comment above. + (let [set-name (obj/get token-set :name) + theme (u/locate-token-theme file-id id)] + (when (and (some? set-name) (some? theme)) + (st/emit! (dwtl/update-token-theme id (ctob/disable-set theme set-name))))))} :duplicate (fn [] @@ -499,7 +517,10 @@ (let [attrs (update attrs :name ctob/normalize-set-name) set (ctob/make-token-set attrs)] (st/emit! (dwtl/create-token-set set)) - (token-set-proxy plugin-id file-id (ctob/get-id set))))} + ;; 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/src/app/plugins/utils.cljs b/frontend/src/app/plugins/utils.cljs index 19de73fcde..81dfb6cb82 100644 --- a/frontend/src/app/plugins/utils.cljs +++ b/frontend/src/app/plugins/utils.cljs @@ -100,6 +100,17 @@ root (ctn/get-instance-root objects shape)] [root (ctf/resolve-component root file libraries {:include-deleted? true})])) +(defn locate-head-component + "Like locate-component but resolves via the nearest component head + instead of the outermost instance root." + [objects shape] + (let [state (deref st/state) + file (dsh/lookup-file state) + libraries (dsh/lookup-libraries state) + head (ctn/get-head-shape objects shape)] + (when head + [head (ctf/resolve-component head file libraries {:include-deleted? true})]))) + (defn proxy->file [proxy] (let [id (obj/get proxy "$id")] diff --git a/frontend/src/app/render_wasm/api.cljs b/frontend/src/app/render_wasm/api.cljs index e42afc9e25..737131d243 100644 --- a/frontend/src/app/render_wasm/api.cljs +++ b/frontend/src/app/render_wasm/api.cljs @@ -656,47 +656,49 @@ [shape-id strokes thumbnail?] (h/call wasm/internal-module "_clear_shape_strokes") (keep (fn [stroke] - (let [opacity (or (:stroke-opacity stroke) 1.0) - color (:stroke-color stroke) - gradient (:stroke-color-gradient stroke) - image (:stroke-image stroke) - width (:stroke-width stroke) - align (:stroke-alignment stroke) - style (-> stroke :stroke-style sr/translate-stroke-style) - cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) - cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) - offset (mem/alloc types.fills.impl/FILL-U8-SIZE) - heap (mem/get-heap-u8) - dview (js/DataView. (.-buffer heap))] - (case align - :inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end) - :outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end) - (h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end)) + (when-not (:hidden stroke) + (let [opacity (or (:stroke-opacity stroke) 1.0) + color (:stroke-color stroke) + gradient (:stroke-color-gradient stroke) + image (:stroke-image stroke) + width (:stroke-width stroke) + align (:stroke-alignment stroke) + style (-> stroke :stroke-style sr/translate-stroke-style) + cap-start (-> stroke :stroke-cap-start sr/translate-stroke-cap) + cap-end (-> stroke :stroke-cap-end sr/translate-stroke-cap) + offset (mem/alloc types.fills.impl/FILL-U8-SIZE) + heap (mem/get-heap-u8) + dview (js/DataView. (.-buffer heap))] + (case align + :inner (h/call wasm/internal-module "_add_shape_inner_stroke" width style cap-start cap-end) + :outer (h/call wasm/internal-module "_add_shape_outer_stroke" width style cap-start cap-end) + (h/call wasm/internal-module "_add_shape_center_stroke" width style cap-start cap-end)) - (cond - (some? gradient) - (do - (types.fills.impl/write-gradient-fill offset dview opacity gradient) - (h/call wasm/internal-module "_add_shape_stroke_fill") - nil) + (cond + (some? gradient) + (do + (types.fills.impl/write-gradient-fill offset dview opacity gradient) + (h/call wasm/internal-module "_add_shape_stroke_fill") + nil) - (some? image) - (let [image-id (get image :id) - buffer (uuid/get-u32 image-id) - cached-image? (h/call wasm/internal-module "_is_image_cached" - (aget buffer 0) (aget buffer 1) - (aget buffer 2) (aget buffer 3) - thumbnail?)] - (types.fills.impl/write-image-fill offset dview opacity image) - (h/call wasm/internal-module "_add_shape_stroke_fill") - (when (== cached-image? 0) - (fetch-image shape-id image-id thumbnail?))) + (some? image) + (let [image-id (get image :id) + buffer (uuid/get-u32 image-id) + cached-image? (h/call wasm/internal-module "_is_image_cached" + (aget buffer 0) (aget buffer 1) + (aget buffer 2) (aget buffer 3) + thumbnail?)] + (types.fills.impl/write-image-fill offset dview opacity image) + (h/call wasm/internal-module "_add_shape_stroke_fill") + (when (== cached-image? 0) + (fetch-image shape-id image-id thumbnail?))) + + (some? color) + (do + (types.fills.impl/write-solid-fill offset dview opacity color) + (h/call wasm/internal-module "_add_shape_stroke_fill") + nil))))) - (some? color) - (do - (types.fills.impl/write-solid-fill offset dview opacity color) - (h/call wasm/internal-module "_add_shape_stroke_fill") - nil)))) strokes)) (defn set-shape-svg-attrs diff --git a/frontend/src/app/util/clipboard.cljs b/frontend/src/app/util/clipboard.cljs index 5f18798847..d06aa5c22e 100644 --- a/frontend/src/app/util/clipboard.cljs +++ b/frontend/src/app/util/clipboard.cljs @@ -88,3 +88,22 @@ (let [clipboard (unchecked-get js/navigator "clipboard") data (create-clipboard-item mimetype promise)] (.write ^js clipboard #js [data]))) + +(defn to-clipboard-multi + "Write multiple MIME representations as a single ClipboardItem. + `items` is a map of mime-type (string) -> string payload. + Falls back to `writeText` when the async Clipboard API is unavailable." + [items] + (let [clipboard (unchecked-get js/navigator "clipboard")] + (if (and clipboard (unchecked-get clipboard "write")) + (let [obj (reduce-kv + (fn [acc mime payload] + (let [blob (js/Blob. #js [payload] #js {:type mime})] + (unchecked-set acc mime (js/Promise.resolve blob)) + acc)) + #js {} items) + item (js/ClipboardItem. obj)] + (.write ^js clipboard #js [item])) + (when-let [text (or (get items "text/plain") + (first (vals items)))] + (.writeText ^js clipboard text))))) diff --git a/frontend/src/app/util/clipboard.js b/frontend/src/app/util/clipboard.js index 294652666a..43bd636b88 100644 --- a/frontend/src/app/util/clipboard.js +++ b/frontend/src/app/util/clipboard.js @@ -24,6 +24,13 @@ const exclusiveTypes = [ "text/plain" ]; +const svgTextPattern = + /^(\s*<\?xml[^?]*\?>\s*)?(\s*\s*)*]/i; + +function hasSvgItem(items) { + return items.some((item) => item?.type === "image/svg+xml"); +} + /** * @typedef {Object} ClipboardSettings * @property {Function} [decodeTransit] @@ -59,7 +66,7 @@ function parseText(text, options) { } } - if (/^]/i.test(text)) { + if (svgTextPattern.test(text)) { return new Blob([text], { type: "image/svg+xml" }); } else { return new Blob([text], { type: "text/plain" }); @@ -207,14 +214,20 @@ export async function fromDataTransfer(dataTransfer, options) { }), ); return items - .filter((item) => !!item) - .reduce((filtered, item) => { + .filter((item) => !!item && item.size > 0) + .reduce((filtered, item, _index, all) => { if ( exclusiveTypes.includes(item.type) && filtered.find((filteredItem) => exclusiveTypes.includes(filteredItem.type)) ) { return filtered; } + if ( + item.type !== "image/svg+xml" && item.type.startsWith("image/") && + hasSvgItem(all) + ) { + return filtered; + } filtered.push(item); return filtered; }, []); diff --git a/frontend/src/app/util/code_gen/markup_svg.cljs b/frontend/src/app/util/code_gen/markup_svg.cljs index 65044af6d7..6ab4ad0799 100644 --- a/frontend/src/app/util/code_gen/markup_svg.cljs +++ b/frontend/src/app/util/code_gen/markup_svg.cljs @@ -9,10 +9,9 @@ ["react-dom/server" :as rds] [app.main.render :as render] [app.util.code-beautify :as cb] - [cuerdas.core :as str] [rumext.v2 :as mf])) -(defn generate-svg +(defn- generate-single-svg [objects shape] (rds/renderToStaticMarkup (mf/element @@ -20,13 +19,26 @@ #js {:objects objects :object-id (-> shape :id)}))) +(defn- generate-multi-svg + [objects shapes] + (rds/renderToStaticMarkup + (mf/element + render/objects-svg + #js {:objects objects + :object-ids (mapv :id shapes)}))) + +(defn generate-svg + [objects shape] + (generate-single-svg objects shape)) + (defn generate-markup [objects shapes] - (->> shapes - (map #(generate-svg objects %)) - (str/join "\n"))) + (case (count shapes) + 0 "" + 1 (generate-single-svg objects (first shapes)) + (generate-multi-svg objects shapes))) (defn generate-formatted-markup [objects shapes] - (let [markup (generate-markup objects shapes)] - (cb/format-code markup "svg"))) + (-> (generate-markup objects shapes) + (cb/format-code "svg"))) diff --git a/frontend/src/app/util/dom.cljs b/frontend/src/app/util/dom.cljs index aa46be4497..2ce67382b1 100644 --- a/frontend/src/app/util/dom.cljs +++ b/frontend/src/app/util/dom.cljs @@ -122,6 +122,14 @@ (fn? (.-preventDefault event))) (.preventDefault event))) +(defn prevent-default-context-menu + [^js event] + (let [target (some-> event .-target) + tag (some-> target .-tagName .toLowerCase)] + (when-not (or (#{"input" "textarea"} tag) + (some-> target .-isContentEditable)) + (.preventDefault event)))) + (defn get-target "Extract the target from event instance." [^js event] diff --git a/frontend/src/app/util/i18n.cljs b/frontend/src/app/util/i18n.cljs index 93282d9c56..db8c2dccc3 100644 --- a/frontend/src/app/util/i18n.cljs +++ b/frontend/src/app/util/i18n.cljs @@ -213,6 +213,9 @@ (when (not= pv cv) (ct/set-default-locale cv)))) +;; Initialize date-fns locale on startup, the watch above only fires on changes +(ct/set-default-locale *current-locale*) + ;; We set the real translation function in the common i18n namespace, ;; so that when common code calls (tr ...) it uses this function. (set! app.common.i18n/tr tr) diff --git a/frontend/src/app/util/simple_math.cljs b/frontend/src/app/util/simple_math.cljs index 7b203fdd12..ca6f05536a 100644 --- a/frontend/src/app/util/simple_math.cljs +++ b/frontend/src/app/util/simple_math.cljs @@ -90,7 +90,16 @@ init-value (or init-value 0)] (s/assert number? init-value) (if-not (insta/failure? result) - (interpret result init-value) + (try + (let [value (interpret result init-value)] + ;; Check for division by zero (Infinity or -Infinity) + (if (or (js/Number.isFinite value) (nil? value)) + value + nil)) + (catch :default err + (js/console.debug (str "Expression evaluation error: " (ex-message err)) + (str "Expression: '" expr "'")) + nil)) (let [text (:text result) index (:index result) expecting (->> result diff --git a/frontend/src/app/util/webapi.cljs b/frontend/src/app/util/webapi.cljs index 1b3a63b97a..b877108695 100644 --- a/frontend/src/app/util/webapi.cljs +++ b/frontend/src/app/util/webapi.cljs @@ -97,17 +97,22 @@ (defn data-uri->blob [data-uri] - (let [[mtype b64-data] (str/split data-uri ";base64," 2) - mtype (subs mtype (inc (str/index-of mtype ":"))) - decoded (.atob js/window b64-data) - size (.-length ^js decoded) - content (js/Uint8Array. size)] - - (loop [i 0] - (when (< i size) - (aset content i (.charCodeAt ^js decoded i)) - (recur (inc i)))) - + (let [[meta data] (str/split data-uri "," 2) + mtype-end (or (str/index-of meta ";") (count meta)) + mtype (subs meta (inc (str/index-of meta ":")) mtype-end) + base64? (str/includes? meta ";base64") + content (if base64? + (let [decoded (.atob js/globalThis data) + size (.-length ^js decoded) + bytes (js/Uint8Array. size)] + (loop [i 0] + (when (< i size) + (aset bytes i (.charCodeAt ^js decoded i)) + (recur (inc i)))) + bytes) + ;; Data URIs can be plain/URL-encoded (e.g. ;utf8,). + ;; Encode into UTF-8 bytes before creating the Blob. + (.encode (js/TextEncoder.) (.decodeURIComponent js/globalThis data)))] (create-blob content mtype))) (defn get-current-selected-text diff --git a/frontend/src/app/worker/import.cljs b/frontend/src/app/worker/import.cljs index 20c314f012..402da4ad5c 100644 --- a/frontend/src/app/worker/import.cljs +++ b/frontend/src/app/worker/import.cljs @@ -23,6 +23,22 @@ (log/set-level! :warn) +(defn- import-cause-message + "Prefer the server `:hint` (full text, e.g. SSE error payload), then `:explain` + when present; avoid the generic `stream exception` wrapper when a payload exists." + [cause default-msg] + (let [data (ex-data cause) + hint (some-> data :hint str/trim) + explain (some-> data :explain str/trim)] + (cond + (not (str/blank? hint)) hint + (not (str/blank? explain)) explain + :else + (let [msg (some-> (ex-message cause) str/trim)] + (if (or (str/blank? msg) (= msg "stream exception")) + default-msg + msg))))) + ;; Upload changes batches size (def ^:const change-batch-size 100) @@ -122,7 +138,7 @@ :error (tr "dashboard.import.analyze-error")})))) (rx/catch (fn [cause] - (let [error (or (ex-message cause) (tr "dashboard.import.analyze-error"))] + (let [error (import-cause-message cause (tr "dashboard.import.analyze-error"))] (rx/of (assoc file :error error :status :error)))))))) (defmethod impl/handler :analyze-import @@ -178,7 +194,7 @@ :project-id project-id :cause cause) (rx/of {:status :error - :error (ex-message cause) + :error (import-cause-message cause (tr "labels.error")) :file-id (:file-id data)}))))))) (->> (rx/from binfile-v3) @@ -212,8 +228,9 @@ :project-id project-id ::log/sync? true :cause cause) - (->> (rx/from entries) - (rx/map (fn [entry] - {:status :error - :error (ex-message cause) - :file-id (:file-id entry)})))))))))))) + (let [err (import-cause-message cause (tr "labels.error"))] + (->> (rx/from entries) + (rx/map (fn [entry] + {:status :error + :error err + :file-id (:file-id entry)}))))))))))))) diff --git a/frontend/src/app/worker/selection.cljs b/frontend/src/app/worker/selection.cljs index c3bbdde95c..333f0af73b 100644 --- a/frontend/src/app/worker/selection.cljs +++ b/frontend/src/app/worker/selection.cljs @@ -157,8 +157,6 @@ match-criteria? (fn [shape] (and (not (:hidden shape)) - (or (cfh/frame-shape? shape) ;; We return frames even if blocked - (not (:blocked shape))) (or (not frame-id) (= frame-id (:frame-id shape))) (case (:type shape) :frame include-frames? diff --git a/frontend/stylelint.config.mjs b/frontend/stylelint.config.mjs new file mode 100644 index 0000000000..99a9474a2b --- /dev/null +++ b/frontend/stylelint.config.mjs @@ -0,0 +1,67 @@ +import postcssScss from "postcss-scss"; + +/** @type {import("stylelint").Config} */ +export default { + extends: ["stylelint-config-standard-scss"], + plugins: ["stylelint-scss", "stylelint-use-logical-spec"], + overrides: [ + { + files: ["**/*.scss"], + customSyntax: postcssScss, + }, + ], + rules: { + "at-rule-no-unknown": null, + "declaration-property-value-no-unknown": null, + "selector-pseudo-class-no-unknown": [ + true, + { ignorePseudoClasses: ["global"] }, // TODO: Avoid global selector usage and remove this exception + ], + + // scss + "scss/comment-no-empty": null, + "scss/at-rule-no-unknown": true, + // TODO: this rule should be enabled to follow scss conventions + "scss/load-no-partial-leading-underscore": null, + // This allows using the characters - or _ as a prefix and is ISO compliant with the Sass specification. + "scss/dollar-variable-pattern": "^[-_]?([a-z][a-z0-9]*)(-[a-z0-9]+)*$", + // This allows using the characters - or _ as a prefix and is ISO compliant with the Sass specification. + "scss/at-mixin-pattern": "^[-_]?([a-z][a-z0-9]*)(-[a-z0-9]+)*$", + + // TODO: Enable rules secuentially + // // Using quotes + "font-family-name-quotes": "always-unless-keyword", + "function-url-quotes": "always", + "selector-attribute-quotes": "always", + // // Disallow vendor prefixes + "at-rule-no-vendor-prefix": true, + "media-feature-name-no-vendor-prefix": true, + "property-no-vendor-prefix": true, + "selector-no-vendor-prefix": true, + "value-no-vendor-prefix": true, + // // Specificity + "no-descending-specificity": null, + // "max-nesting-depth": 3, + "selector-max-compound-selectors": 3, + "selector-max-specificity": "1,2,1", + // // Miscellanea + "color-named": "never", + // "declaration-no-important": true, + "declaration-property-unit-allowed-list": { + "font-size": ["rem", "lh"], + "/^animation/": ["s"], + }, + // // 'order/properties-alphabetical-order': true, + "selector-max-type": 1, + "selector-type-no-unknown": true, + // // Notation + "font-weight-notation": "numeric", + // // URLs + "function-url-no-scheme-relative": true, + // "liberty/use-logical-spec": "always", + "selector-class-pattern": null, + "alpha-value-notation": "number", + "color-function-notation": "modern", + "value-keyword-case": "lower", + }, +}; diff --git a/frontend/test/frontend_tests/copy_as_svg_test.cljs b/frontend/test/frontend_tests/copy_as_svg_test.cljs new file mode 100644 index 0000000000..c2aee4a298 --- /dev/null +++ b/frontend/test/frontend_tests/copy_as_svg_test.cljs @@ -0,0 +1,61 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.copy-as-svg-test + "Regression tests for the Copy as SVG action (issue #838). + + The bug: when multiple shapes were selected, `generate-markup` emitted + several sibling `` roots concatenated with newlines. External SVG + parsers (Inkscape, browsers) only read the first root, so multi-shape + selection appeared to copy only one shape. The fix wraps 2+ shapes in a + single `` root with a combined viewBox." + (:require + [app.common.test-helpers.files :as cthf] + [app.common.test-helpers.ids-map :as cthi] + [app.common.test-helpers.shapes :as cths] + [app.util.code-gen.markup-svg :as svg] + [cljs.test :refer [deftest is testing] :include-macros true])) + +(defn- setup-shapes + "Build a file with `n` sample rectangles on the current page. + Returns a map with `:objects` and `:shapes` keys, mirroring the inputs + that `copy-selected-svg` feeds into `generate-markup`." + [labels] + (let [file (reduce (fn [f label] + (cths/add-sample-shape f label)) + (cthf/sample-file :file1 :page-label :page1) + labels) + page (cthf/current-page file) + objects (:objects page) + shapes (mapv #(get objects (cthi/id %)) labels)] + {:objects objects :shapes shapes})) + +(defn- count-matches + [re s] + (count (re-seq re s))) + +(deftest empty-selection-yields-empty-string + (is (= "" (svg/generate-markup {} [])))) + +(deftest single-shape-produces-one-svg-root + (testing "Regression guard: the single-shape path stays unchanged" + (let [{:keys [objects shapes]} (setup-shapes [:rect-1]) + markup (svg/generate-markup objects shapes)] + (is (string? markup)) + (is (pos? (count markup))) + (is (= 1 (count-matches #" root")))) + +(deftest multi-shape-produces-single-svg-root + (testing "Fix for #838: multiple shapes share one outer " + (let [{:keys [objects shapes]} (setup-shapes [:rect-1 :rect-2 :rect-3]) + markup (svg/generate-markup objects shapes)] + (is (string? markup)) + (is (pos? (count markup))) + (is (= 1 (count-matches #" roots") + (is (= 1 (count-matches #"" markup)) + "multi-select must NOT emit multiple closing tags")))) diff --git a/frontend/test/frontend_tests/plugins/parser_test.cljs b/frontend/test/frontend_tests/plugins/parser_test.cljs new file mode 100644 index 0000000000..4b257b2023 --- /dev/null +++ b/frontend/test/frontend_tests/plugins/parser_test.cljs @@ -0,0 +1,33 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.plugins.parser-test + (:require + [app.common.geom.point :as gpt] + [app.plugins.parser :as parser] + [cljs.test :as t :include-macros true])) + +(t/deftest test-parse-point-returns-gpt-point-record + ;; Regression test for issue #8409. + ;; + ;; The plugin parser used to return a plain map `{:x … :y …}`, but the + ;; shape-interaction schema expects `::gpt/point` (a Point record). + ;; Plugin `addInteraction` calls with an `open-overlay` action and + ;; `manualPositionLocation` were silently rejected by validation. + (t/testing "parse-point returns nil for nil input" + (t/is (nil? (parser/parse-point nil)))) + + (t/testing "parse-point returns a gpt/point record for valid input" + (let [result (parser/parse-point #js {:x 10 :y 20})] + (t/is (gpt/point? result)) + (t/is (= 10 (:x result))) + (t/is (= 20 (:y result))))) + + (t/testing "parse-point passes gpt/point? for a zero point" + (let [result (parser/parse-point #js {:x 0 :y 0})] + (t/is (gpt/point? result)) + (t/is (= 0 (:x result))) + (t/is (= 0 (:y result)))))) diff --git a/frontend/test/frontend_tests/runner.cljs b/frontend/test/frontend_tests/runner.cljs index 8260e62a45..5f9078f910 100644 --- a/frontend/test/frontend_tests/runner.cljs +++ b/frontend/test/frontend_tests/runner.cljs @@ -2,6 +2,7 @@ (:require [cljs.test :as t] [frontend-tests.basic-shapes-test] + [frontend-tests.copy-as-svg-test] [frontend-tests.data.repo-test] [frontend-tests.data.uploads-test] [frontend-tests.data.viewer-test] @@ -19,6 +20,7 @@ [frontend-tests.logic.pasting-in-containers-test] [frontend-tests.main-errors-test] [frontend-tests.plugins.context-shapes-test] + [frontend-tests.plugins.parser-test] [frontend-tests.plugins.tokens-test] [frontend-tests.svg-fills-test] [frontend-tests.tokens.import-export-test] @@ -32,6 +34,7 @@ [frontend-tests.util-object-test] [frontend-tests.util-range-tree-test] [frontend-tests.util-simple-math-test] + [frontend-tests.util-webapi-test] [frontend-tests.worker-snap-test])) (enable-console-print!) @@ -45,6 +48,7 @@ [] (t/run-tests 'frontend-tests.basic-shapes-test + 'frontend-tests.copy-as-svg-test 'frontend-tests.data.repo-test 'frontend-tests.errors-test 'frontend-tests.main-errors-test @@ -62,6 +66,7 @@ 'frontend-tests.logic.groups-test 'frontend-tests.logic.pasting-in-containers-test 'frontend-tests.plugins.context-shapes-test + 'frontend-tests.plugins.parser-test 'frontend-tests.plugins.tokens-test 'frontend-tests.svg-fills-test 'frontend-tests.tokens.import-export-test @@ -75,4 +80,5 @@ 'frontend-tests.util-object-test 'frontend-tests.util-range-tree-test 'frontend-tests.util-simple-math-test + 'frontend-tests.util-webapi-test 'frontend-tests.worker-snap-test)) diff --git a/frontend/test/frontend_tests/util_simple_math_test.cljs b/frontend/test/frontend_tests/util_simple_math_test.cljs index 15bb2198c2..eeae0ed40f 100644 --- a/frontend/test/frontend_tests/util_simple_math_test.cljs +++ b/frontend/test/frontend_tests/util_simple_math_test.cljs @@ -8,7 +8,6 @@ (:require [app.common.math :as cm] [app.util.simple-math :as sm] - [cljs.pprint :refer [pprint]] [cljs.test :as t :include-macros true])) (t/deftest test-parser-inst @@ -88,3 +87,24 @@ result2 (sm/expr-eval "(20,333 + 10%) * (1 / 3)" 20)] (t/is (cm/close? result1 result2 7.44433333))))) +(t/deftest test-error-handling + (t/testing "Division by zero should return nil" + (let [result (sm/expr-eval "10/0" 999)] + (t/is (= result nil)))) + + (t/testing "Expression with division by zero should return nil" + (let [result (sm/expr-eval "(10 + 5) / 0" 999)] + (t/is (= result nil)))) + + (t/testing "Invalid syntax should return nil" + (let [result (sm/expr-eval "asdasd+2" 999)] + (t/is (= result nil)))) + + (t/testing "Empty expression with no init-value should return nil" + (let [result (sm/expr-eval "" nil)] + (t/is (= result nil)))) + + (t/testing "Partial invalid expression should return nil" + (let [result (sm/expr-eval "10 + abc" 100)] + (t/is (= result nil))))) + diff --git a/frontend/test/frontend_tests/util_webapi_test.cljs b/frontend/test/frontend_tests/util_webapi_test.cljs new file mode 100644 index 0000000000..1307526ffb --- /dev/null +++ b/frontend/test/frontend_tests/util_webapi_test.cljs @@ -0,0 +1,34 @@ +;; This Source Code Form is subject to the terms of the Mozilla Public +;; License, v. 2.0. If a copy of the MPL was not distributed with this +;; file, You can obtain one at http://mozilla.org/MPL/2.0/. +;; +;; Copyright (c) KALEIDOS INC + +(ns frontend-tests.util-webapi-test + (:require + [app.util.webapi :as wapi] + [cljs.test :as t :include-macros true])) + +(t/deftest data-uri->blob-supports-base64 + (t/async done + (let [blob (wapi/data-uri->blob "data:text/plain;base64,SGVsbG8=")] + (-> (.text blob) + (.then (fn [text] + (t/is (= "text/plain" (.-type blob))) + (t/is (= "Hello" text)) + (done))) + (.catch (fn [err] + (t/is false (str "unexpected error: " err)) + (done))))))) + +(t/deftest data-uri->blob-supports-utf8-data + (t/async done + (let [blob (wapi/data-uri->blob "data:image/svg+xml;utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3C%2Fsvg%3E")] + (-> (.text blob) + (.then (fn [text] + (t/is (= "image/svg+xml" (.-type blob))) + (t/is (= "" text)) + (done))) + (.catch (fn [err] + (t/is false (str "unexpected error: " err)) + (done))))))) diff --git a/frontend/translations/ca.po b/frontend/translations/ca.po index 586495e0ad..2739db1bce 100644 --- a/frontend/translations/ca.po +++ b/frontend/translations/ca.po @@ -4272,6 +4272,30 @@ msgstr "Pàgines" msgid "workspace.sitemap" msgstr "Mapa del lloc" +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-references-title" +msgstr "Canviar el nom de `%s` a `%s` i remapejar tots els tokens d'aquest grup?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-token-warning-effects" +msgstr "Això canviarà totes les capes i referències que utilitzen el token antic." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Aquest procés pot trigar una mica" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group" +msgstr "Canviar nom del grup de tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.duplicate-group" +msgstr "Duplicar grup de tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group-name-hint" +msgstr "Els teus tokens es renomenaran automàticament a %s.(sufix).(token)" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Recursos" diff --git a/frontend/translations/cs.po b/frontend/translations/cs.po index a39f7dc50f..3657d89e77 100644 --- a/frontend/translations/cs.po +++ b/frontend/translations/cs.po @@ -2916,7 +2916,7 @@ msgstr "Přestávka na údržbu: do 5 minut budeme mimo provoz na krátkou údr #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "K dispozici je nová verze, obnovte prosím stránku" +msgstr "K dispozici je nová verze." #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" diff --git a/frontend/translations/de.po b/frontend/translations/de.po index d3b1388070..e74244a898 100644 --- a/frontend/translations/de.po +++ b/frontend/translations/de.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: German " -"\n" +"PO-Revision-Date: 2026-04-23 10:09+0000\n" +"Last-Translator: Stas Haas \n" +"Language-Team: German \n" "Language: de\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -208,7 +208,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "...Branding, Illustrationen, Marketingmaterialien, usw." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Dieses Token existiert nicht oder wurde gelöscht." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1357,7 +1357,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Token-Liste öffnen" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Token trennen" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -1408,11 +1408,11 @@ msgstr "" #: src/app/main/errors.cljs:305 msgid "errors.deprecated.contact.after" -msgstr "damit wir Ihnen helfen können." +msgstr ", damit wir Ihnen helfen können." #: src/app/main/errors.cljs:304 msgid "errors.deprecated.contact.text" -msgstr "kontaktieren Sie uns" +msgstr "kontaktieren" #: src/app/main/data/workspace/tokens/library_edit.cljs:338 msgid "errors.drop-token-set-parent-to-child" @@ -3661,7 +3661,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Eine neue Version ist verfügbar, bitte aktualisieren Sie die Seite" +msgstr "Eine neue Version ist verfügbar." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -4822,11 +4822,11 @@ msgid "subscription.settings.subscribe" msgstr "Abonnieren" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Viel Spaß mit Ihrem Abonnement!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Sie sind %s!" #: src/app/main/ui/settings/subscription.cljs:558, src/app/main/ui/settings/subscription.cljs:574 @@ -6704,6 +6704,7 @@ msgid "workspace.options.shadow-options.remove-shadow" msgstr "Schatten entfernen" #: src/app/main/ui/inspect/attributes/shadow.cljs:48, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:191, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:193 +#, fuzzy msgid "workspace.options.shadow-options.spread" msgstr "Streuung" @@ -7191,7 +7192,7 @@ msgstr "Anmerkung erstellen" #: src/app/main/ui/workspace/context_menu.cljs:382 msgid "workspace.shape.menu.create-artboard-from-selection" -msgstr "Auswahl auf Zeichenfläche" +msgstr "Auswahl als Zeichenfläche" #: src/app/main/ui/workspace/context_menu.cljs:592 msgid "workspace.shape.menu.create-component" @@ -7530,7 +7531,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s Token bearbeiten" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Der Token-Wert darf nicht leer sein" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7538,7 +7539,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "%s Token-Name eingeben" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Fehler beim Importieren. JSON konnte nicht verarbeitet werden." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7602,7 +7603,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "%s importieren" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Fehler beim Import:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7659,57 +7660,57 @@ msgid "workspace.tokens.individual-tokens" msgstr "Einzelne Token verwenden" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Ungültiger Farbwert: %s" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Ungültiger Wert für die Schriftstärke: Verwenden Sie numerische Werte " "(100–950) oder Standardbezeichnungen (dünn, leicht, normal, fett usw.), " "optional gefolgt von „kursiv”" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Fehler beim Importieren: Ihre JSON-Datei enthält ungültige Token-Daten." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Fehler beim Importieren: Ihre JSON-Datei enthält ungültige Token-Namen." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "„%s“ ist kein gültiger Namen für ein Token.\n" "Token-Namen dürfen nur Buchstaben und Ziffern enthalten, die durch Punkte " "getrennt sind und dürfen nicht mit einem Dollarzeichen beginnen." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "" "Ungültiger Schattentyp: Es werden nur „innerShadow” oder „dropShadow” " "akzeptiert" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Ungültiger Token-Wert: Es werden nur „none“, „Uppercase“, „Lowercase“ oder " "„Capitalize“ akzeptiert" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Ungültiger Token-Wert: Es werden nur „none“, „underline“ oder " "„strike-through“ akzeptiert" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "" "Ungültiger Wert: Der Wert muss auf ein zusammengesetztes Typografie-Token " "verweisen." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Ungültiger Token-Wert: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7749,7 +7750,7 @@ msgid "workspace.tokens.min-size" msgstr "Mindestgröße" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Fehlende Token-Referenzen: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7789,7 +7790,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Sie haben derzeit keine Themes." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "In dieser Datei wurden keine Tokens, Sets oder Themen gefunden." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7797,11 +7798,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s aktivierte Sets" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Ungültiger Tokenwert. Der ermittelte Wert ist zu hoch: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "Die Deckkraft muss zwischen 0 und 100 % oder zwischen 0 und 1 liegen (z. B. " "50 % oder 0,5)." @@ -7846,7 +7847,7 @@ msgid "workspace.tokens.select-set" msgstr "Set auswählen." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Der Token referenziert sich selbst" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7887,7 +7888,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Weichzeichnen" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Wert muss größer oder gleich 0 sein." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -7925,7 +7926,7 @@ msgid "workspace.tokens.size" msgstr "Größe" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Die Rahmenbreite muss größer oder gleich 0 sein." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -7987,10 +7988,6 @@ msgstr "Schriftfamilie oder eine durch Kommas (,) getrennte Liste von Schriften" msgid "workspace.tokens.token-name" msgstr "Name" -#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 -msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "Unter diesem Speicherort existiert bereits ein Token: %s" - #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" msgstr "Der Name muss mindestens 1 Zeichen lang sein" @@ -8023,13 +8020,13 @@ msgstr "TOKENS - %s" msgid "workspace.tokens.tools" msgstr "Werkzeuge" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "Der Import war erfolgreich. Einige Token wurden nicht übernommen." +msgstr "Der Import war erfolgreich, aber einige Token wurden übersprungen, da sie nicht unterstützte $type-Werte verwenden. Details aufklappen, um zu sehen, welche Token betroffen sind." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "„%s“ wird nicht als Datentyp unterstützt (%s)\n" +msgstr "„%s“ wird nicht als Datentyp unterstützt (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8040,11 +8037,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Der Wert ist nicht gültig" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Ungültiger Wert: % ist nicht zulässig." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Ungültiger Wert: Einheiten sind hier nicht zulässig." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8376,3 +8373,135 @@ msgstr "Automatisch gespeicherte Versionen werden für %s Tage aufbewahrt." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Klicken Sie, um den Pfad zu schließen" + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "Wird gelöscht am %s" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Unerwarteter Fehler: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL funktioniert nicht mehr. Bitte laden Sie die Seite neu, um sie " +"zurückzusetzen" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "Etwas Schlimmes ist passiert." + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Seite neu laden" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Neue Organisation erstellen" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"Wollen Sie wirklich das Projekt %s löschen? Diese Aktion kann nicht " +"rückgängig gemacht werden." + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "In Arbeitsbereich bearbeiten" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "Optionen für Abstände anzeigen" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "Inhalt horizontal anpassen" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "Inhalt vertikal anpassen" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Gesamte Höhe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Gesamte Breite" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "Verbunden mit" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Werkzeuge zur Fehlersuche" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, fuzzy, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "Der Wert für die Streuung darf nicht negativ sein" + +#: src/app/main/errors.cljs:303 +msgid "errors.deprecated.contact.before" +msgstr "Penpot unterstützt diese Assets nicht mehr. Sie können uns allerdings" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Ups! Der Canvas-Kontext ist verloren gegangen" + +#: src/app/main/ui/ds/product/loader.cljs:25 +msgid "loader.tips.03.message" +msgstr "" +"Gestalten Sie flexibel mit vertrauten, CSS-ähnlichen Layout-Steuerelementen." + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:132 +#, unused +msgid "workspace.tokens.choose-folder" +msgstr "Ordner auswählen" + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:127 +#, unused +msgid "workspace.tokens.choose-file" +msgstr "Datei auswählen" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:498 +msgid "workspace.shape.menu.restore-variant" +msgstr "Variante wiederherstellen" + +#: src/app/main/ui/workspace/context_menu.cljs:619, src/app/main/ui/workspace/sidebar/assets/components.cljs:633, src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "Als Varianten kombinieren" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "Die Komponenten müssen sich auf derselben Seite befinden" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1157 +msgid "workspace.shape.menu.remove-variant-property" +msgstr "Eigenschaft entfernen" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:512, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1073, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1221, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1307 +msgid "workspace.shape.menu.add-variant-property" +msgstr "Neue Eigenschaft hinzufügen" + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "Daten im Browser speichern." + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.unlock" +msgstr "Seitenverhältnis entsperren" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.lock" +msgstr "Seitenverhältnis sperren" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Rechts" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Links" diff --git a/frontend/translations/en.po b/frontend/translations/en.po index 2b2d91ddd7..a38b24ba03 100644 --- a/frontend/translations/en.po +++ b/frontend/translations/en.po @@ -108,6 +108,9 @@ msgstr "Password recovery link sent to your inbox." msgid "auth.notifications.team-invitation-accepted" msgstr "Joined the team successfully" +msgid "auth.notifications.org-invitation-accepted" +msgstr "You're now part of %s" + #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 msgid "auth.password" msgstr "Password" @@ -197,7 +200,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "...branding, illustrations, marketing pieces, etc." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "This token does not exists or has been deleted." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -338,6 +341,18 @@ msgstr "You're going to restore %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restore file" +msgid "dashboard.org-deleted" +msgstr "The %s organization has been deleted." + +msgid "dashboard.team-no-longer-belong-org" +msgstr "This team no longer belongs to the organization %s" + +msgid "dashboard.team-belong-org" +msgstr "This team now belongs to %s" + +msgid "dashboard.user-no-longer-belong-org" +msgstr "You are no longer a member of the organization %s" + #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Add file" @@ -764,6 +779,9 @@ msgstr "Invite people" msgid "dashboard.leave-team" msgstr "Leave team" +msgid "dashboard.leave-org" +msgstr "Leave org" + #: src/app/main/ui/dashboard/templates.cljs:84, src/app/main/ui/dashboard/templates.cljs:169 msgid "dashboard.libraries-and-templates" msgstr "Libraries & Templates" @@ -1117,6 +1135,48 @@ msgstr "Team info" msgid "dashboard.team-members" msgstr "Team members" +msgid "dashboard.team-organization" +msgstr "Team organization" + +msgid "dashboard.team-organization.none" +msgstr "This team is not part of any organization" + +msgid "dashboard.team-organization.add" +msgstr "Add to an organization" + +msgid "dashboard.select-org-modal.title" +msgstr "Add team to an organization" + +msgid "dashboard.select-org-modal.choose" +msgstr "Choose an organization:" + +msgid "dashboard.select-org-modal.select" +msgstr "Select an organization" + +msgid "dashboard.select-org-modal.accept" +msgstr "ADD TO ORGANIZATION" + +msgid "dashboard.change-org-modal.title" +msgstr "Change team's organization" + +msgid "dashboard.change-org-modal.text" +msgstr "Projects and files will remain available to team members. The team will get the configuration from the new organization." + +msgid "dashboard.change-org-modal.choose" +msgstr "Move to:" + +msgid "dashboard.change-org-modal.select" +msgstr "Select an organization" + +msgid "dashboard.change-org-modal.accept" +msgstr "MOVE TEAM" + +msgid "dashboard.team-organization.change" +msgstr "Change team organization" + +msgid "dashboard.team-organization.remove" +msgstr "Remove team from organization" + #: src/app/main/ui/dashboard/team.cljs:1344 msgid "dashboard.team-projects" msgstr "Team projects" @@ -1306,7 +1366,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Open token list" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Detach token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -1314,7 +1374,7 @@ msgid "ds.inputs.token-field.no-active-token-option" msgstr "{%s} is not in any active set or has an invalid value." #: src/app/main/ui/ds/controls/utilities/token_field.cljs -msgid "ds.inputs.token-field.no-active-color.token-option" +msgid "not-active-token.no-name" msgstr "This token is not in any active set or has an invalid value." #: src/app/main/data/auth.cljs:339 @@ -1492,6 +1552,14 @@ msgstr "Invite invalid" msgid "errors.invite-invalid.info" msgstr "This invite might be canceled or may be expired." +#: src/app/main/ui/static.cljs +msgid "errors.invite-expired" +msgstr "This invitation has expired. Ask the team owner to send you a new one." + +#: src/app/main/ui/static.cljs +msgid "errors.invite-email-mismatch" +msgstr "This invitation is for a different email. Log out and sign in with the invited account, or ask the team owner for a new invitation." + #: src/app/main/ui/auth/login.cljs:89 msgid "errors.ldap-disabled" msgstr "LDAP authentication is disabled." @@ -1573,6 +1641,15 @@ msgstr "SVG is invalid or malformed" msgid "errors.team-feature-mismatch" msgstr "Detected incompatible feature '%s'" +msgid "errors.org-leave.org-owner-cannot-leave" +msgstr "The organization owner can't leave the organization." + +msgid "errors.org-leave.no-valid-teams" +msgstr "There was a problem leaving the organization. Please try again." + +msgid "errors.team-leave.only-owner-can-delete" +msgstr "Only the owner of a team can delete it." + #: src/app/main/ui/dashboard/sidebar.cljs:373, src/app/main/ui/dashboard/team.cljs:393 msgid "errors.team-leave.insufficient-members" msgstr "Insufficient members to leave team, you probably want to delete it." @@ -1674,6 +1751,9 @@ msgstr "Email or password is incorrect." msgid "errors.wrong-old-password" msgstr "Old password is incorrect" +msgid "errors.org-not-found" +msgstr "That organization doesn't exists" + #: src/app/main/ui/settings/feedback.cljs:120 msgid "feedback.description" msgstr "Description" @@ -2550,6 +2630,12 @@ msgstr "Delete %s files" msgid "labels.deleted" msgstr "Deleted" +msgid "labels.delete-profile-photo.title" +msgstr "Delete profile photo" + +msgid "labels.delete-profile-photo.message" +msgstr "Are you sure you want to delete your profile photo?" + #: src/app/main/ui/onboarding/questions.cljs:86 msgid "labels.developer" msgstr "Development" @@ -2601,6 +2687,12 @@ msgstr "Empty" msgid "labels.error" msgstr "Error" +#: src/app/main/ui/dashboard/import.cljs:297 +msgid "labels.warning-count" +msgid_plural "labels.warning-count" +msgstr[0] "%s warning" +msgstr[1] "%s warnings" + #: src/app/main/ui/onboarding/questions.cljs:404 #, unused msgid "labels.event" @@ -2748,6 +2840,14 @@ msgstr "Libraries & Templates" msgid "labels.loading" msgstr "Loading…" +#: src/app/main/ui/dashboard/import.cljs +msgid "labels.uploading-file" +msgstr "Uploading file…" + +#: src/app/main/ui/exports/files.cljs +msgid "labels.downloading-file" +msgstr "Downloading file…" + #: src/app/main/ui/workspace/sidebar/versions.cljs:210 msgid "labels.lock" msgstr "Lock" @@ -2985,6 +3085,9 @@ msgstr "Resend invitation" msgid "labels.restore" msgstr "Restore" +msgid "labels.exit" +msgstr "Exit" + #: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419 msgid "labels.retry" msgstr "Retry" @@ -3473,6 +3576,16 @@ msgstr "Are you sure you want to delete this page?" msgid "modals.delete-page.title" msgstr "Delete page" +#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/ui/workspace/sidebar/assets/colors.cljs, src/app/main/ui/workspace/sidebar/assets/typographies.cljs +msgid "modals.delete-asset-group.title" +msgstr "Delete group" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs, src/app/main/ui/workspace/sidebar/assets/colors.cljs, src/app/main/ui/workspace/sidebar/assets/typographies.cljs +msgid "modals.delete-asset-group.message" +msgid_plural "modals.delete-asset-group.message" +msgstr[0] "Are you sure you want to delete this asset?" +msgstr[1] "Are you sure you want to delete these %s assets?" + #: src/app/main/ui/dashboard/project_menu.cljs:73 msgid "modals.delete-project-confirm.accept" msgstr "Delete project" @@ -3615,6 +3728,15 @@ msgstr "" "You are the owner of this team. Please select another member to promote to " "owner before you leave." +msgid "modals.leave-org-and-reassign.hint" +msgstr "You are the owner of some organization's teams. Please promote another member to become an owner." + +msgid "modals.leave-org-and-reassign.hint-delete" +msgstr "You are the only member of some of the teams. Those teams will be deleted along with its projects and files." + +msgid "modals.leave-org-and-reassign.hint-promote" +msgstr "Also, you are the owner of some organization's teams. Please promote another member to become an owner." + #: src/app/main/ui/dashboard/change_owner.cljs:73 msgid "modals.leave-and-reassign.promote-and-leave" msgstr "Promote and leave" @@ -3631,14 +3753,35 @@ msgstr "Before you leave" msgid "modals.leave-confirm.accept" msgstr "Leave team" +msgid "modals.leave-org-confirm.accept" +msgstr "Leave organization" + #: src/app/main/ui/dashboard/sidebar.cljs:409, src/app/main/ui/dashboard/team.cljs:449 msgid "modals.leave-confirm.message" msgstr "Are you sure you want to leave this team?" +msgid "modals.leave-org-confirm.message" +msgstr "You will permanently lose access to all teams, projects, and files within it." + #: src/app/main/ui/dashboard/sidebar.cljs:408, src/app/main/ui/dashboard/sidebar.cljs:429, src/app/main/ui/dashboard/team.cljs:425, src/app/main/ui/dashboard/team.cljs:448 msgid "modals.leave-confirm.title" msgstr "Leaving team" +msgid "modals.leave-org-confirm.title" +msgstr "Leaving %s organization?" + +msgid "modals.before-leave-org.title" +msgstr "BEFORE YOU LEAVE THE ORGANIZATION" + +msgid "modals.before-leave-org.message" +msgstr "You are the only member of some of the teams. Those teams will be deleted along with its projects and files." + +msgid "modals.before-leave-org.warning" +msgstr "Any team where you’re the only member will be deleted." + +msgid "dasboard.leave-org.toast" +msgstr "You're no longer part of the %s organization." + #: src/app/main/ui/delete_shared.cljs:56 msgid "modals.move-shared-confirm.accept" msgid_plural "modals.move-shared-confirm.accept" @@ -3868,7 +4011,7 @@ msgstr "Maintenance break: we will be down for a short maintenance within 5 minu #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "A new version is available, please refresh the page" +msgstr "A new version is available." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -5148,6 +5291,9 @@ msgstr "editor per month" msgid "subscription.settings.price-organization-month" msgstr "organization per month" +msgid "subscription.settings.organization-member-month" +msgstr "org member per month" + #: src/app/main/ui/dashboard/subscription.cljs:140, src/app/main/ui/settings/subscription.cljs:102, src/app/main/ui/settings/subscription.cljs:469, src/app/main/ui/settings/subscription.cljs:538 msgid "subscription.settings.professional" msgstr "Professional" @@ -5191,11 +5337,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Thank your for chosing the Penpot %s plan!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Enjoy your plan!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "You are %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5615,6 +5761,10 @@ msgstr "Text Transform" msgid "workspace.assets.ungroup" msgstr "Ungroup" +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs +msgid "workspace.assets.delete-group" +msgstr "Delete group" + #: src/app/main/ui/workspace/colorpicker.cljs:428, src/app/main/ui/workspace/colorpicker.cljs:441 msgid "workspace.colorpicker.color-tokens" msgstr "Color tokens" @@ -5641,6 +5791,21 @@ msgstr "Create board" msgid "workspace.context-menu.grid-cells.merge" msgstr "Merge cells" +msgid "workspace.context-menu.grid-cells.copy-rows" +msgstr "Copy rows" + +msgid "workspace.context-menu.grid-cells.copy-columns" +msgstr "Copy columns" + +msgid "workspace.context-menu.grid-cells.paste-tracks" +msgstr "Paste" + +msgid "workspace.context-menu.guides.change-color" +msgstr "Guide color" + +msgid "workspace.context-menu.guides.remove" +msgstr "Remove guide" + #: src/app/main/ui/workspace/context_menu.cljs:754 msgid "workspace.context-menu.grid-track.column.add-after" msgstr "Add 1 column to the right" @@ -5767,6 +5932,14 @@ msgstr "Hide board names" msgid "workspace.header.menu.hide-guides" msgstr "Hide guides" +#: src/app/main/ui/workspace/main_menu.cljs:387 +msgid "workspace.header.menu.lock-guides" +msgstr "Lock guides" + +#: src/app/main/ui/workspace/main_menu.cljs:387 +msgid "workspace.header.menu.unlock-guides" +msgstr "Unlock guides" + #: src/app/main/ui/workspace/main_menu.cljs:393 msgid "workspace.header.menu.hide-palette" msgstr "Hide color palette" @@ -5831,6 +6004,14 @@ msgstr "Redo" msgid "workspace.header.menu.select-all" msgstr "Select all" +#: src/app/main/ui/workspace/main_menu.cljs +msgid "workspace.header.menu.find" +msgstr "Find" + +#: src/app/main/ui/workspace/main_menu.cljs +msgid "workspace.header.menu.find-and-replace" +msgstr "Find and Replace" + #: src/app/main/ui/workspace/main_menu.cljs:423 msgid "workspace.header.menu.show-artboard-names" msgstr "Show boards names" @@ -6420,6 +6601,10 @@ msgstr "Export unexpectedly slow" msgid "workspace.options.fill" msgstr "Fill" +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +msgid "workspace.options.fill.section" +msgstr "Fill section" + #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:208 msgid "workspace.options.fill.add-fill" msgstr "Add fill" @@ -6888,6 +7073,10 @@ msgstr "Selected layers" msgid "workspace.options.layer-options.toggle-layer" msgstr "Toggle layer visibility" +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:255 +msgid "workspace.options.layer-options.layer-section" +msgstr "Layer menu section" + #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.advanced-ops" @@ -7031,6 +7220,10 @@ msgstr "More library colors" msgid "workspace.options.more-token-colors" msgstr "More color tokens" +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +msgid "workspace.options.selection-color.section" +msgstr "Color selection section" + #: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:229, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:241 msgid "workspace.options.opacity" msgstr "Opacity" @@ -7072,6 +7265,10 @@ msgstr "Collapse independent radius" msgid "workspace.options.radius.show-single-corners" msgstr "Show independent radius" +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:341 +msgid "workspace.options.radius.radius-section" +msgstr "Border radius section" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:191 msgid "workspace.options.recent-fonts" msgstr "Recent" @@ -7172,6 +7369,14 @@ msgstr "Size" msgid "workspace.options.size-presets" msgstr "Size presets" +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +msgid "workspace.options.search-size-preset" +msgstr "Search size preset" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +msgid "workspace.options.no-size-preset-results" +msgstr "No matching size preset" + #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 msgid "workspace.options.size.lock" msgstr "Lock ratio" @@ -7282,6 +7487,10 @@ msgstr "Outside" msgid "workspace.options.stroke.remove-stroke" msgstr "Remove stroke" +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs +msgid "workspace.options.stroke.toggle-stroke" +msgstr "Toggle stroke" + #: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:137 msgid "workspace.options.stroke.solid" msgstr "Solid" @@ -7377,6 +7586,14 @@ msgstr "Title case" msgid "workspace.options.text-options.underline" msgstr "Underline (%s)" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.convert-to-typography" +msgstr "Create typography style" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-section" +msgstr "Text section" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs #, unused msgid "workspace.options.text-options.uppercase" @@ -7496,6 +7713,14 @@ msgstr "" msgid "workspace.plugins.permissions.allow-download" msgstr "Start file downloads." +#: src/app/main/ui/workspace/plugins.cljs +msgid "workspace.plugins.permissions.clipboard-read" +msgstr "Read the contents of your clipboard." + +#: src/app/main/ui/workspace/plugins.cljs +msgid "workspace.plugins.permissions.clipboard-write" +msgstr "Read and write to your clipboard." + #: src/app/main/ui/workspace/plugins.cljs:287 msgid "workspace.plugins.permissions.allow-localstorage" msgstr "Store data in the browser." @@ -7831,6 +8056,9 @@ msgid "workspace.shape.menu.show-main" msgstr "Show main component" #: src/app/main/ui/workspace/context_menu.cljs:314 +msgid "workspace.shape.menu.clear-guides" +msgstr "Clear artboard guides" + msgid "workspace.shape.menu.thumbnail-remove" msgstr "Remove thumbnail" @@ -7920,6 +8148,34 @@ msgstr "Shapes" msgid "workspace.sidebar.layers.texts" msgstr "Texts" +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.replace" +msgstr "Replace" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.replace-all" +msgstr "Replace all" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.replace-placeholder" +msgstr "Replace with..." + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.match-count" +msgstr "%s of %s" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.no-matches" +msgstr "No matches" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.search-scope-layers" +msgstr "Search layers" + +#: src/app/main/ui/workspace/sidebar/layers.cljs +msgid "workspace.sidebar.layers.search-scope-canvas" +msgstr "Search on canvas" + #: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:101 msgid "workspace.sidebar.options.svg-attrs.title" msgstr "Imported SVG Attributes" @@ -7992,7 +8248,7 @@ msgid "workspace.tokens.color" msgstr "Color" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "Line Height depends on Font Size. Add a Font Size to get the resolved value." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 @@ -8019,6 +8275,10 @@ msgstr "Delete theme" msgid "workspace.tokens.duplicate" msgstr "Duplicate token" +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:350 +msgid "workspace.tokens.copy-name" +msgstr "Copy token path" + #: src/app/main/data/workspace/tokens/library_edit.cljs:240, src/app/main/data/workspace/tokens/library_edit.cljs:509 msgid "workspace.tokens.duplicate-suffix" msgstr "copy" @@ -8040,7 +8300,7 @@ msgid "workspace.tokens.edit-token" msgstr "Edit %s token" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Token value cannot be empty" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -8048,7 +8308,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Enter %s token name" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Import Error: Could not parse JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -8112,7 +8372,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Import %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Import Error:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8163,58 +8423,58 @@ msgid "workspace.tokens.individual-tokens" msgstr "Use individual tokens" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Invalid color value: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Invalid token value: you can only reference a font-family token" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Invalid font weight value: use numeric values (100-950) or standard names " "(thin, light, regular, bold, etc.) optionally followed by 'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Import Error: Invalid token data in JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Import Error: Invalid token name in JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" is not a valid token name.\n" "Token names should only contain letters and digits separated by . " "characters and must not start with a $ sign." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Invalid shadow type: only 'innerShadow' or 'dropShadow' are accepted" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Invalid token value: only none, Uppercase, Lowercase or Capitalize are " "accepted" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "Invalid token value: only none, underline and strike-through are accepted" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Invalid value: must reference a composite shadow token." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Invalid value: must reference a composite typography token." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Invalid token value: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8258,7 +8518,7 @@ msgid "workspace.tokens.missing-reference" msgstr "Missing reference" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Missing token references: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8308,7 +8568,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "You currently have no themes." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "No tokens, sets, or themes were found in this file." #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 @@ -8320,11 +8580,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s active sets" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Invalid token value. The resolved value is too large: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opacity must be between 0 and 100% or 0 and 1 (e.g. 50% or 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -8365,11 +8625,19 @@ msgstr "Remap tokens" msgid "workspace.tokens.remap-token-references-title" msgstr "Remap all tokens that use `%s` to `%s`?" -#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 -msgid "workspace.tokens.remap-warning-effects" +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-references-title" +msgstr "Rename `%s` to `%s` and remap all tokens in this group?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-token-warning-effects" msgstr "This will change all layers and references that use the old token name." -#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-warning-effects" +msgstr "This will update all tokens and references that use the old tokens name." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 msgid "workspace.tokens.remap-warning-time" msgstr "This action could take a while." @@ -8392,7 +8660,7 @@ msgid "workspace.tokens.select-set" msgstr "Select set." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token has self reference" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8429,7 +8697,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Blur" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Shadow blur must be greater than or equal to 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8450,7 +8718,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spread" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Shadow spread must be greater than or equal to 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8480,7 +8748,7 @@ msgid "workspace.tokens.size" msgstr "Size" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Stroke width must be greater than or equal to 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8544,7 +8812,7 @@ msgstr "Name" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "A token already exists at the path: %s" +msgstr "A token already exists at the path: %s or at a prefix thereof." #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" @@ -8578,13 +8846,13 @@ msgstr "TOKENS - %s" msgid "workspace.tokens.tools" msgstr "Tools" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "Import was successful. Some tokens were not included." +msgstr "Import was successful, but some tokens were skipped because they use unsupported $type values. Expand details to see which tokens were affected." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Type '%s' is not supported (%s)\n" +msgstr "Type '%s' is not supported (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8595,11 +8863,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "The value is not valid" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Invalid value: % is not allowed." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Invalid value: Units are not allowed." #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -8611,6 +8879,18 @@ msgstr "Renaming this token will break any reference to its old name" msgid "workspace.tokens.error-text-edition" msgstr "Tokens can't be applied while editing text. Select the text layer instead." +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group" +msgstr "Rename Tokens Group" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.duplicate-group" +msgstr "Duplicate Tokens Group" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group-name-hint" +msgstr "Your tokens will automatically be renamed to %s.(suffix).(tokenName)" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Assets" @@ -8862,6 +9142,9 @@ msgstr "Autosaved %s" msgid "workspace.versions.button.pin" msgstr "Pin version" +msgid "workspace.versions.button.preview" +msgstr "Preview version" + #: src/app/main/ui/workspace/sidebar/versions.cljs:273 msgid "workspace.versions.button.restore" msgstr "Restore version" @@ -8906,6 +9189,12 @@ msgstr "This version is locked by %s and cannot be modified" msgid "workspace.versions.locked-by-you" msgstr "This version is locked by you" +msgid "workspace.versions.preview-banner-title" +msgstr "Previewing version: %s" + +msgid "workspace.versions.preview.unnamed" +msgstr "Unnamed version" + #: src/app/main/ui/workspace/sidebar/versions.cljs:83 msgid "workspace.versions.restore-warning" msgstr "Do you want to restore this version?" @@ -8962,6 +9251,18 @@ msgstr "Go to dashboard" msgid "webgl.modals.webgl-unavailable.cta-troubleshooting" msgstr "Troubleshooting guide" +msgid "modals.remove-team-org.title" +msgstr "REMOVE TEAM FROM THE ORGANIZATION" + +msgid "modals.remove-team-org.text" +msgstr "Are you sure you want to remove the '%s' team from the '%s' organization?" + +msgid "modals.remove-team-org.info" +msgstr "Projects and files will remain available to team members, but the organization's settings will no longer apply." + +msgid "modals.remove-team-org.accept" +msgstr "Remove from organization" + msgid "webgl.toast.webgl-render-enabled" msgstr "WebGL rendering enabled" @@ -8972,5 +9273,126 @@ msgstr "WebGL rendering disabled" msgid "workspace.viewport.click-to-close-path" msgstr "Click to close the path" +msgid "modals.delete-org-team-confirm.message" +msgstr "Are you sure you want to delete this team that is part of %s org?" + msgid "plugins.validation.message" msgstr "Field %s is invalid: %s" + +msgid "nitrate.code-activation.title" +msgstr "Activate Nitrate" + +msgid "nitrate.code-activation.input-label" +msgstr "Enter your activation code" + +msgid "nitrate.code-activation.submit" +msgstr "Activate" + +msgid "nitrate.code-activation.footer" +msgstr "Need a code? Contact us:" + +msgid "nitrate.code-activation.placeholder" +msgstr "Paste your activation code here" + + +msgid "nitrate.activation-success.title" +msgstr "You are Business Nitrate!" + +msgid "nitrate.activation-success.active-until" +msgstr "Your plan is active until %s." + +msgid "nitrate.activation-success.manage-info" +msgstr "You can manage your subscription anytime from the Subscription page in your account settings." + +msgid "nitrate.activation-success.enjoy" +msgstr "Enjoy your plan!" + +msgid "nitrate.activation-success.create-org" +msgstr "Create organization" + +msgid "nitrate.form.title" +msgstr "Unlock Nitrate Features" + +msgid "nitrate.form.billing-monthly" +msgstr "Price Tag Monthly" + +msgid "nitrate.form.billing-yearly" +msgstr "Price Tag Yearly (Discount)" + +msgid "nitrate.form.upgrade" +msgstr "Upgrade to Nitrate" + +msgid "nitrate.form.try-free" +msgstr "Try it free for 14 days" + +msgid "nitrate.form.cancel-anytime" +msgstr "Cancel anytime before your next billing cycle." + +msgid "nitrate.form.have-code" +msgstr "Have an activation code?" + +msgid "nitrate.form.enter-code" +msgstr "Enter activation code" + +msgid "nitrate.form.see-plan" +msgstr "See my current plan" + +msgid "nitrate.form.contact-upgrade" +msgstr "Contact us to upgrade to Nitrate:" + +msgid "nitrate.form.contact-trial" +msgstr "Contact us to try Nitrate for 14 days:" + +msgid "nitrate.activation-code.invalid-error" +msgstr "Invalid code." + +msgid "nitrate.activation-code.expired-error" +msgstr "This code has expired." + +msgid "nitrate.contact-sales.title" +msgstr "Switch to %s plan?" + +msgid "nitrate.contact-sales.downgrade-title" +msgstr "When you downgrade:" + +msgid "nitrate.contact-sales.downgrade-org-deleted" +msgstr "Your organization will be deleted." + +msgid "nitrate.contact-sales.downgrade-teams-available" +msgstr "The teams, projects and files will no longer be part of any organization but they will remain available." + +msgid "nitrate.contact-sales.downgrade-storage-limited" +msgstr "Your total storage, auto-version history, and file recovery period will be limited." + +msgid "nitrate.contact-sales.downgrade-contact-info" +msgstr "To switch to this plan, please contact our sales team. We'll help you update your subscription and ensure everything is set up correctly." + +msgid "nitrate.contact-sales.button" +msgstr "Contact sales" + +msgid "nitrate.subscription.active-until" +msgstr "Active until %s" + +msgid "subscription.settings.activate-by-code" +msgstr "Enter activation code" + +msgid "subscription.current-plan.title" +msgstr "Your subscription" + +msgid "subscription.current-plan.professional" +msgstr "Professional" + +msgid "subscription.current-plan.unlimited" +msgstr "Unlimited" + +msgid "subscription.current-plan.unlimited-trial" +msgstr "Unlimited Trial" + +msgid "subscription.current-plan.nitrate" +msgstr "Nitrate" + +msgid "subscription.current-plan.nitrate-trial" +msgstr "Nitrate Trial" + +msgid "subscription.current-plan.enterprise" +msgstr "Enterprise" \ No newline at end of file diff --git a/frontend/translations/es.po b/frontend/translations/es.po index 6938c78bcf..6a6814859c 100644 --- a/frontend/translations/es.po +++ b/frontend/translations/es.po @@ -114,6 +114,9 @@ msgstr "Hemos enviado a tu buzón un enlace para recuperar tu contraseña." msgid "auth.notifications.team-invitation-accepted" msgstr "Te uniste al equipo" +msgid "auth.notifications.org-invitation-accepted" +msgstr "Te uniste a la organización %s" + #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 msgid "auth.password" msgstr "Contraseña" @@ -204,7 +207,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "diseño de marca, ilustraciones, piezas de marketing..." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Este token no existe o ha sido borrado." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -347,6 +350,18 @@ msgstr "Vas a restaurar %s." msgid "dashboard-restore-file-confirmation.title" msgstr "Restaurar archivo" +msgid "dashboard.org-deleted" +msgstr "La organización %s se ha borrado." + +msgid "dashboard.team-no-longer-belong-org" +msgstr "Este equipo ya no pertenece a la organización %s" + +msgid "dashboard.team-belong-org" +msgstr "Este equipo ahora pertenece a la organización %s" + +msgid "dashboard.user-no-longer-belong-org" +msgstr "Ya no perteneces a la organización %s" + #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "Añadir archivo" @@ -768,6 +783,9 @@ msgstr "Invitar a la gente" msgid "dashboard.leave-team" msgstr "Abandonar equipo" +msgid "dashboard.leave-org" +msgstr "Abandonar organización" + #: src/app/main/ui/dashboard/templates.cljs:84, src/app/main/ui/dashboard/templates.cljs:169 msgid "dashboard.libraries-and-templates" msgstr "Bibliotecas y plantillas" @@ -1121,6 +1139,48 @@ msgstr "Información del equipo" msgid "dashboard.team-members" msgstr "Integrantes del equipo" +msgid "dashboard.team-organization" +msgstr "Organización del equipo" + +msgid "dashboard.team-organization.none" +msgstr "Este equipo no pertenece a ninguna organización" + +msgid "dashboard.team-organization.add" +msgstr "Añadir a una organización" + +msgid "dashboard.select-org-modal.title" +msgstr "Añadir el equipo a una organización" + +msgid "dashboard.select-org-modal.choose" +msgstr "Elige una organización:" + +msgid "dashboard.select-org-modal.select" +msgstr "Elige una organización" + +msgid "dashboard.select-org-modal.accept" +msgstr "AÑADIR A UNA ORGANIZACIÓN" + +msgid "dashboard.change-org-modal.title" +msgstr "Cambiar el equipo de organización" + +msgid "dashboard.change-org-modal.text" +msgstr "Los miembros del equipo seguirán teniendo acceso a los proyectos y ficheros. El equipo tendrá la configuración de la nueva organización." + +msgid "dashboard.change-org-modal.choose" +msgstr "Mover a:" + +msgid "dashboard.change-org-modal.select" +msgstr "Elige una organización" + +msgid "dashboard.change-org-modal.accept" +msgstr "MOVER EL EQUIPO" + +msgid "dashboard.team-organization.change" +msgstr "Cambiar el equipo de organización" + +msgid "dashboard.team-organization.remove" +msgstr "Eliminar equipo de la organización" + #: src/app/main/ui/dashboard/team.cljs:1344 msgid "dashboard.team-projects" msgstr "Proyectos del equipo" @@ -1296,7 +1356,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Abrir lista de tokens" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Desvincular token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -1304,7 +1364,7 @@ msgid "ds.inputs.token-field.no-active-token-option" msgstr "{%s} no está disponible en ningún set o tiene un valor inválido." #: src/app/main/ui/ds/controls/utilities/token_field.cljs -msgid "ds.inputs.token-field.no-active-color.token-option" +msgid "not-active-token.no-name" msgstr "Este token no está disponible en ningún set o tiene un valor inválido." #: src/app/main/data/auth.cljs:339 @@ -1540,6 +1600,15 @@ msgstr "El SVG no es válido o está mal formado" msgid "errors.team-feature-mismatch" msgstr "Detectada funcionalidad incompatible '%s'" +msgid "errors.org-leave.org-owner-cannot-leave" +msgstr "El dueño de la organización no puede abandonarla." + +msgid "errors.org-leave.no-valid-teams" +msgstr "Ha habido un problema abandonando la organización. Intentalo de nuevo, por favor." + +msgid "errors.team-leave.only-owner-can-delete" +msgstr "Sólo puede borrar un equipo su propietario." + #: src/app/main/ui/dashboard/sidebar.cljs:373, src/app/main/ui/dashboard/team.cljs:393 msgid "errors.team-leave.insufficient-members" msgstr "" @@ -1639,6 +1708,9 @@ msgstr "El email o la contraseña son incorrectos." msgid "errors.wrong-old-password" msgstr "La contraseña anterior no es correcta" +msgid "errors.org-not-found" +msgstr "Esa organización no existe" + #: src/app/main/ui/settings/feedback.cljs:120 msgid "feedback.description" msgstr "Descripción" @@ -2928,6 +3000,9 @@ msgstr "Reenviar invitacion" msgid "labels.restore" msgstr "Restaurar" +msgid "labels.exit" +msgstr "Salir" + #: src/app/main/ui/components/progress.cljs:80, src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, src/app/main/ui/static.cljs:419 msgid "labels.retry" msgstr "Reintentar" @@ -3562,6 +3637,19 @@ msgstr "" "Tienes la propiedad de este equipo. Por favor selecciona otra persona " "integrante para promover al rol Propiedad." +msgid "modals.leave-org-and-reassign.hint" +msgstr "" +"Tienes la propiedad de algunos equipos de esta organización. " +"Por favor selecciona otra persona integrante para promover al rol Propiedad." + +msgid "modals.leave-org-and-reassign.hint-delete" +msgstr "Eres el único miembro de algunos equipos. Esos equipos se van a borrar, junto con sus proyectos y ficheros." + +msgid "modals.leave-org-and-reassign.hint-promote" +msgstr "" +"Además, tienes la propiedad de algunos equipos de esta organización. " +"Por favor selecciona otra persona integrante para promover al rol Propiedad." + #: src/app/main/ui/dashboard/change_owner.cljs:73 msgid "modals.leave-and-reassign.promote-and-leave" msgstr "Promocionar y abandonar" @@ -3578,14 +3666,35 @@ msgstr "Antes de que abandones" msgid "modals.leave-confirm.accept" msgstr "Abandonar el equipo" +msgid "modals.leave-org-confirm.accept" +msgstr "Abandonar organización" + #: src/app/main/ui/dashboard/sidebar.cljs:409, src/app/main/ui/dashboard/team.cljs:449 msgid "modals.leave-confirm.message" msgstr "¿Seguro que quieres abandonar este equipo?" +msgid "modals.leave-org-confirm.message" +msgstr "Perderás permanentemente el acceso a todos los equipos, proyectos y archivos en ella." + #: src/app/main/ui/dashboard/sidebar.cljs:408, src/app/main/ui/dashboard/sidebar.cljs:429, src/app/main/ui/dashboard/team.cljs:425, src/app/main/ui/dashboard/team.cljs:448 msgid "modals.leave-confirm.title" msgstr "Abandonando el equipo" +msgid "modals.leave-org-confirm.title" +msgstr "¿Abandonando la organización %s?" + +msgid "modals.before-leave-org.title" +msgstr "ANTES DE ABANDONAR LA ORGANIZACIÓN" + +msgid "modals.before-leave-org.message" +msgstr "Eres el único miembro de algunos equipos. Esos equipos se van a borrar, junto con sus proyectos y ficheros." + +msgid "modals.before-leave-org.warning" +msgstr "Se van a borrar todos los equipos en los que eres el único miembro." + +msgid "dasboard.leave-org.toast" +msgstr "Ya no eres miembro de la organización %s." + #: src/app/main/ui/delete_shared.cljs:56 msgid "modals.move-shared-confirm.accept" msgid_plural "modals.move-shared-confirm.accept" @@ -3817,7 +3926,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Una nueva versión está disponible, por favor actualiza la página" +msgstr "Una nueva versión está disponible." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -5105,6 +5214,9 @@ msgstr "editor por mes" msgid "subscription.settings.price-organization-month" msgstr "organización por mes" +msgid "subscription.settings.organization-member-month" +msgstr "miembro organización por mes" + #: src/app/main/ui/dashboard/subscription.cljs:140, src/app/main/ui/settings/subscription.cljs:102, src/app/main/ui/settings/subscription.cljs:469, src/app/main/ui/settings/subscription.cljs:538 msgid "subscription.settings.professional" msgstr "Professional" @@ -5148,11 +5260,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "¡Gracias por elegir el plan %s de Penpot!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "¡Disfruta de tu plan!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Eres %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5548,7 +5660,7 @@ msgstr "Estilo de fuente" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:546 msgid "workspace.assets.typography.go-to-edit" -msgstr "Ir al archivo de la biblioteca del estilo para editar" +msgstr "Editar en biblioteca de estilo" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:536 msgid "workspace.assets.typography.letter-spacing" @@ -6339,6 +6451,10 @@ msgstr "Exportación lenta" msgid "workspace.options.fill" msgstr "Relleno" +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs +msgid "workspace.options.fill.section" +msgstr "Sección de relleno" + #: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:208 msgid "workspace.options.fill.add-fill" msgstr "Añadir relleno" @@ -6807,6 +6923,10 @@ msgstr "Capas seleccionadas" msgid "workspace.options.layer-options.toggle-layer" msgstr "Mostrar/ocultar capa" +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:255 +msgid "workspace.options.layer-options.layer-section" +msgstr "Sección del menú de capas" + #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.advanced-ops" @@ -6950,6 +7070,10 @@ msgstr "Más colores de la biblioteca" msgid "workspace.options.more-token-colors" msgstr "Más tokens de color" +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs +msgid "workspace.options.selection-color.section" +msgstr "Sección de colores seleccionados" + #: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:229, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:241 msgid "workspace.options.opacity" msgstr "Opacidad" @@ -6991,6 +7115,10 @@ msgstr "Colapsar radios individuales" msgid "workspace.options.radius.show-single-corners" msgstr "Mostrar radios individuales" +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:341 +msgid "workspace.options.radius.radius-section" +msgstr "Sección de radios de borde" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:191 msgid "workspace.options.recent-fonts" msgstr "Recientes" @@ -7298,6 +7426,14 @@ msgstr "Título" msgid "workspace.options.text-options.underline" msgstr "Subrayado (%s)" +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.convert-to-typography" +msgstr "Crear estilo de tipografía" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs +msgid "workspace.options.text-options.text-section" +msgstr "Sección de textos" + #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs #, unused msgid "workspace.options.text-options.uppercase" @@ -7900,7 +8036,7 @@ msgid "workspace.tokens.choose-folder" msgstr "Elige carpeta" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "El Line Height depende del Font Size. Añade un Font Size para obtener el " "valor computado." @@ -7929,6 +8065,10 @@ msgstr "Borrar theme" msgid "workspace.tokens.duplicate" msgstr "Duplicar token" +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:350 +msgid "workspace.tokens.copy-name" +msgstr "Copiar ruta de token" + #: src/app/main/data/workspace/tokens/library_edit.cljs:240, src/app/main/data/workspace/tokens/library_edit.cljs:509 msgid "workspace.tokens.duplicate-suffix" msgstr "copiar" @@ -7950,7 +8090,7 @@ msgid "workspace.tokens.edit-token" msgstr "Editar token de %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "El valor del token no puede estar vacío" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7958,7 +8098,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Introduce un nombre para el token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Error al importar: No se pudo procesar el JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -8018,7 +8158,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importar %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Error al importar:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8073,55 +8213,55 @@ msgid "workspace.tokens.individual-tokens" msgstr "Usa tokens individuales" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Valor de color inválido: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Valor de token no válido: solo puedes referenciar tokens tipo font-family" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Valor de peso de fuente inválido: usa valores numéricos (100-950) o nombres " "estándar (thin, light, regular, bold, etc.) opcionalmente seguidos de " "'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Error al importar: Datos de token no válidos en JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Error al importar: Nombre de token no válido en JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" no es un nombre de token válido.\n" "Los nombres de token solo pueden contener letras y dígitos separados por " "caracteres . y no pueden empezar con un signo $." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Tipo de sombra no válida: solo se aceptan 'innerShadow' o 'dropShadow'" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Valor de token no válido: solo none, underline y strike-through son " "aceptados" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Valor no válido: debe hacer referencia a un token de sombra compuesto." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Valor no válido: debe hacer referencia a un token tipográfico compuesto." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valor de token no válido: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8153,7 +8293,7 @@ msgid "workspace.tokens.missing-reference" msgstr "Referencia no encontrada" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Referencias de tokens no encontradas: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8209,11 +8349,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s sets activos" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valor de token no valido. El valor resuelto es muy grande: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "La opacidad debe estar entre 0 y 100% o 0 y 1 (p.e. 50% o 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -8238,11 +8378,13 @@ msgstr "Actualizar tokens" msgid "workspace.tokens.remap-token-references-title" msgstr "¿Actualizar todas las referencias de `%s` a `%s`?" +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:78 +msgid "workspace.tokens.remap-node-references-title" +msgstr "¿Renombrar `%s` to `%s` y remapear todos los tokens de este grupo?" + #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 msgid "workspace.tokens.remap-warning-effects" -msgstr "" -"Esta acción actualizará todas las capas y referencias que usen el token " -"antiguo" +msgstr "Esta acción actualizará todas las capas y referencias que usen el token antiguo" #: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 msgid "workspace.tokens.remap-warning-time" @@ -8267,7 +8409,7 @@ msgid "workspace.tokens.select-set" msgstr "Selecciona set" #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "El token tiene una autoreferencia" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8306,7 +8448,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Blur" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "El desenfoque (blur) de la sombra debe ser mayor o igual a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8327,7 +8469,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spread" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "La extensión (spread) de la sombra debe ser mayor o igual a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8353,7 +8495,7 @@ msgid "workspace.tokens.shadow-y" msgstr "Y" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Stroke width debe ser mayor o igual a 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8404,7 +8546,7 @@ msgstr "Nombre" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "Ya existe un token en la ruta: %s" +msgstr "Ya existe un token en la ruta: %s o en un prefijo del mismo." #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" @@ -8434,15 +8576,13 @@ msgstr "Introduce un valor o un alias usando {alias}" msgid "workspace.tokens.tools" msgstr "Herramientas" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "" -"La importación se ha realizado correctamente. Algunos tokens no se " -"incluyeron." +msgstr "La importación se ha realizado correctamente, pero algunos tokens fueron omitidos porque usan valores de $type no soportados. Expande los detalles para ver qué tokens se vieron afectados." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "El tipo '%s' no está soportado (%s)\n" +msgstr "El tipo '%s' no está soportado (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8453,7 +8593,7 @@ msgid "workspace.tokens.value-not-valid" msgstr "El valor no es válido" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valor no válido: No se permiten unidades." #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs @@ -8467,6 +8607,18 @@ msgstr "" msgid "workspace.tokens.error-text-edition" msgstr "No se pueden aplicar tokens mientras se edita texto. Seleccione la capa de texto en su lugar." +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group" +msgstr "Renombrar grupo de tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.duplicate-group" +msgstr "Duplicar grupo de tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/rename_node_modal.cljs +msgid "workspace.tokens.rename-group-name-hint" +msgstr "Tus tokens serán automáticamente renombrados a %s.(sufijo).(token)" + #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 msgid "workspace.toolbar.assets" msgstr "Recursos" @@ -8754,6 +8906,9 @@ msgstr "Versiones de %s" msgid "workspace.versions.loading" msgstr "Cargando..." +msgid "workspace.versions.preview-banner-title" +msgstr "Previsualizando version: %s" + #: src/app/main/ui/workspace/sidebar/versions.cljs:83 msgid "workspace.versions.restore-warning" msgstr "¿Quieres restaurar esta versión?" @@ -8804,6 +8959,18 @@ msgstr "Ir al panel" msgid "webgl.modals.webgl-unavailable.cta-troubleshooting" msgstr "Guía de solución de problemas" +msgid "modals.remove-team-org.title" +msgstr "ELIMINAR EQUIPO DE LA ORGANIZACIÓN" + +msgid "modals.remove-team-org.text" +msgstr "¿Estás seguro de que quieres eliminar el equipo %s de la organización %s?" + +msgid "modals.remove-team-org.info" +msgstr "Los proyectos y archivos seguirán estando disponibles para los miembros del equipo, pero la configuración de la organización dejará de aplicarse." + +msgid "modals.remove-team-org.accept" +msgstr "Eliminar de la organización" + msgid "webgl.toast.webgl-render-enabled" msgstr "Renderizado WebGL activado" @@ -8813,3 +8980,127 @@ msgstr "Renderizado WebGL desactivado" #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Pulsar para cerrar la ruta" + +msgid "modals.delete-org-team-confirm.message" +msgstr "¿Estás seguro de que deseas eliminar este equipo que forma parte de la organización %s?" + +msgid "nitrate.code-activation.title" +msgstr "Activar Nitrate" + +msgid "nitrate.code-activation.input-label" +msgstr "Introduce tu código de activación" + +msgid "nitrate.code-activation.submit" +msgstr "Activar" + +msgid "nitrate.code-activation.footer" +msgstr "¿Necesitas un código? Contáctanos:" + +msgid "nitrate.code-activation.placeholder" +msgstr "Pega aquí tu código de activación" + + +msgid "nitrate.activation-success.title" +msgstr "¡Ya eres Business Nitrate!" + +msgid "nitrate.activation-success.active-until" +msgstr "Tu plan está activo hasta el %s." + +msgid "nitrate.activation-success.manage-info" +msgstr "Puedes gestionar tu suscripción en cualquier momento desde la página de Suscripción en la configuración de tu cuenta." + +msgid "nitrate.activation-success.enjoy" +msgstr "¡Disfruta de tu plan!" + +msgid "nitrate.activation-success.create-org" +msgstr "Crear organización" + +msgid "nitrate.form.title" +msgstr "Desbloquea las funciones de Nitrate" + +msgid "nitrate.form.billing-monthly" +msgstr "Precio mensual" + +msgid "nitrate.form.billing-yearly" +msgstr "Precio anual (descuento)" + +msgid "nitrate.form.upgrade" +msgstr "Actualizar a Nitrate" + +msgid "nitrate.form.try-free" +msgstr "Pruébalo gratis durante 14 días" + +msgid "nitrate.form.cancel-anytime" +msgstr "Cancela en cualquier momento antes de tu próximo ciclo de facturación." + +msgid "nitrate.form.have-code" +msgstr "¿Tienes un código de activación?" + +msgid "nitrate.form.enter-code" +msgstr "Introducir código de activación" + +msgid "nitrate.form.see-plan" +msgstr "Ver mi plan actual" + +msgid "nitrate.form.contact-upgrade" +msgstr "Contáctanos para actualizar a Nitrate:" + +msgid "nitrate.form.contact-trial" +msgstr "Contáctanos para probar Nitrate durante 14 días:" + +msgid "nitrate.activation-code.invalid-error" +msgstr "Código inválido." + +msgid "nitrate.activation-code.expired-error" +msgstr "Este código ha caducado." + +msgid "nitrate.contact-sales.title" +msgstr "¿Cambiar al plan %s?" + +msgid "nitrate.contact-sales.downgrade-title" +msgstr "Al bajar de plan:" + +msgid "nitrate.contact-sales.downgrade-org-deleted" +msgstr "Tu organización será eliminada." + +msgid "nitrate.contact-sales.downgrade-teams-available" +msgstr "Los equipos, proyectos y archivos dejarán de pertenecer a la organización pero seguirán disponibles." + +msgid "nitrate.contact-sales.downgrade-storage-limited" +msgstr "Tu almacenamiento total, el historial de versiones automático y el período de recuperación de archivos serán limitados." + +msgid "nitrate.contact-sales.downgrade-contact-info" +msgstr "Para cambiar a este plan, contacta con nuestro equipo de ventas. Te ayudaremos a actualizar tu suscripción y a asegurarnos de que todo esté configurado correctamente." + +msgid "nitrate.contact-sales.button" +msgstr "Contactar con ventas" + +msgid "nitrate.subscription.active-until" +msgstr "Activo hasta el %s" + +msgid "subscription.settings.more-information" +msgstr "Más información" + +msgid "subscription.settings.activate-by-code" +msgstr "Introducir código de activación" + +msgid "subscription.current-plan.title" +msgstr "Tu suscripción" + +msgid "subscription.current-plan.professional" +msgstr "Professional" + +msgid "subscription.current-plan.unlimited" +msgstr "Unlimited" + +msgid "subscription.current-plan.unlimited-trial" +msgstr "Unlimited (Prueba)" + +msgid "subscription.current-plan.nitrate" +msgstr "Nitrate" + +msgid "subscription.current-plan.nitrate-trial" +msgstr "Nitrate (Prueba)" + +msgid "subscription.current-plan.enterprise" +msgstr "Enterprise" \ No newline at end of file diff --git a/frontend/translations/fa.po b/frontend/translations/fa.po index 55e50951db..14e02fc6eb 100644 --- a/frontend/translations/fa.po +++ b/frontend/translations/fa.po @@ -199,7 +199,7 @@ msgid "auth.work-email" msgstr "ایمیلِ کار" #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "این توکن وجود ندارد یا حذف شده است." #: src/app/main/ui/workspace/libraries.cljs:323 diff --git a/frontend/translations/fr.po b/frontend/translations/fr.po index 98b90ac8a9..c65ce98b8e 100644 --- a/frontend/translations/fr.po +++ b/frontend/translations/fr.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: French " -"\n" +"PO-Revision-Date: 2026-04-29 17:10+0000\n" +"Last-Translator: Ingrid Pigueron \n" +"Language-Team: French \n" "Language: fr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n!=1);\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -110,7 +110,7 @@ msgstr "Lien de récupération de mot de passe envoyé." #: src/app/main/ui/auth/verify_token.cljs:49 msgid "auth.notifications.team-invitation-accepted" -msgstr "Vous avez rejoint l’équipe avec succès" +msgstr "Vous avez bien rejoint l’équipe" #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 msgid "auth.password" @@ -206,7 +206,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "...image de marque, illustrations, supports marketing, etc." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Ce token n'existe pas ou a été supprimé." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -230,8 +230,8 @@ msgstr "Tous les utilisateurs de Penpot" #: src/app/main/ui/viewer/share_link.cljs:204 msgid "common.share-link.confirm-deletion-link-description" msgstr "" -"Êtes-vous certain de vouloir supprimer ce lien ? Si oui, plus personne ne " -"pourra y accéder" +"Voulez -vous vraiment supprimer ce lien ? Si oui, plus personne ne pourra y " +"accéder" #: src/app/main/ui/viewer/share_link.cljs:259, src/app/main/ui/viewer/share_link.cljs:289 msgid "common.share-link.current-tag" @@ -247,7 +247,7 @@ msgstr "Obtenir le lien" #: src/app/main/ui/viewer/share_link.cljs:142 msgid "common.share-link.link-copied-success" -msgstr "Lien copié avec succès" +msgstr "Lien copié" #: src/app/main/ui/viewer/share_link.cljs:231 msgid "common.share-link.manage-ops" @@ -312,7 +312,7 @@ msgstr "Faites une équipe  !" #: src/app/main/ui/dashboard/projects.cljs #, unused msgid "dasboard.tutorial-hero.info" -msgstr "Apprenez les bases de Penpot en s'amusant avec ce tutoriel pratique." +msgstr "Apprenez les bases de Penpot en vous amusant avec ce tutoriel pratique." #: src/app/main/ui/dashboard/projects.cljs #, unused @@ -357,11 +357,11 @@ msgstr "Générer un nouveau jeton" #: src/app/main/ui/settings/access_tokens.cljs:64 msgid "dashboard.access-tokens.create.success" -msgstr "Jeton d'accès créé avec succès." +msgstr "Le jeton d'accès a bien été créé." #: src/app/main/ui/settings/access_tokens.cljs:286 msgid "dashboard.access-tokens.empty.add-one" -msgstr "Pressez le bouton \"Générer un nouveau jeton\" pour en générer un." +msgstr "Appuyez sur le bouton \"Générer un nouveau jeton\" pour en générer un." #: src/app/main/ui/settings/access_tokens.cljs:285 msgid "dashboard.access-tokens.empty.no-access-tokens" @@ -369,19 +369,19 @@ msgstr "Vous n'avez pas encore de jeton." #: src/app/main/ui/settings/access_tokens.cljs:135 msgid "dashboard.access-tokens.expiration-180-days" -msgstr "180 jours" +msgstr "180 jours" #: src/app/main/ui/settings/access_tokens.cljs:132 msgid "dashboard.access-tokens.expiration-30-days" -msgstr "30 jours" +msgstr "30 jours" #: src/app/main/ui/settings/access_tokens.cljs:133 msgid "dashboard.access-tokens.expiration-60-days" -msgstr "60 jours" +msgstr "60 jours" #: src/app/main/ui/settings/access_tokens.cljs:134 msgid "dashboard.access-tokens.expiration-90-days" -msgstr "90 jours" +msgstr "90 jours" #: src/app/main/ui/settings/access_tokens.cljs:131 msgid "dashboard.access-tokens.expiration-never" @@ -389,11 +389,11 @@ msgstr "Jamais" #: src/app/main/ui/settings/access_tokens.cljs:268 msgid "dashboard.access-tokens.expired-on" -msgstr "A expiré le %s" +msgstr "Est arrivé à expiration le %s" #: src/app/main/ui/settings/access_tokens.cljs:269 msgid "dashboard.access-tokens.expires-on" -msgstr "Expire le %s" +msgstr "Arrive à expiration le %s" #: src/app/main/ui/settings/access_tokens.cljs:267 msgid "dashboard.access-tokens.no-expiration" @@ -412,7 +412,7 @@ msgstr "" #: src/app/main/ui/settings/access_tokens.cljs:142 msgid "dashboard.access-tokens.token-will-expire" -msgstr "Le jeton expirera le %s" +msgstr "Le jeton arrivera à expiration le %s" #: src/app/main/ui/settings/access_tokens.cljs:143 msgid "dashboard.access-tokens.token-will-not-expire" @@ -453,18 +453,18 @@ msgstr "Votre Penpot" #: src/app/main/ui/dashboard/deleted.cljs:262 msgid "dashboard.delete-all-forever-confirmation.description" msgstr "" -"Êtes-vous sûr de vouloir supprimer tous vos projets et fichiers effacés " -"pour toujours ? Cette action est irréversible." +"Voulez-vous vraiment supprimer définitivement tous vos projets et fichiers " +"effacés ? Cette action est irréversible." #: src/app/main/ui/dashboard/file_menu.cljs:221 msgid "dashboard.delete-file-forever-confirmation.description" msgstr "" -"Êtes-vous sûr de vouloir supprimer %s pour toujours ? Cette action est " +"Voulez-vous vraiment supprimer définitivement %s ? Cette action est " "irréversible." #: src/app/main/data/dashboard.cljs:778 msgid "dashboard.delete-files-success-notification" -msgstr "%s fichiers ont été effacés avec succès." +msgstr "%s fichiers ont bien été effacés." #: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 msgid "dashboard.delete-forever-confirmation.title" @@ -477,13 +477,13 @@ msgstr "Supprimer le projet" #: src/app/main/ui/dashboard/deleted.cljs:52 msgid "dashboard.delete-project-forever-confirmation.description" msgstr "" -"Êtes-vous sûr de vouloir supprimer définitivement le projet %s ? Vous allez " -"le supprimer définitivement avec tous les fichiers qu'il contient. Cette " -"action est irréversible." +"Voulez-vous vraiment supprimer définitivement le projet %s ? Vous allez le " +"supprimer définitivement avec tous les fichiers qu'il contient. Cette action " +"est irréversible." #: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 msgid "dashboard.delete-success-notification" -msgstr "%s a été supprimé avec succès." +msgstr "%s a bien été supprimé." #: src/app/main/ui/dashboard/sidebar.cljs:495 msgid "dashboard.delete-team" @@ -586,7 +586,7 @@ msgstr "Commencez à fabriquer des choses géniales" #, unused msgid "dashboard.errors.error-on-delete-file" -msgstr "Il y a eu une erreur lors de la suppression du fichier %s." +msgstr "Une erreur s'est produite lors de la suppression du fichier %s." #: src/app/main/data/dashboard.cljs:781 msgid "dashboard.errors.error-on-delete-files" @@ -718,10 +718,10 @@ msgstr "" #, markdown msgid "dashboard.fonts.hero-text2" msgstr "" -"Ne téléchargez que des polices que vous possédez ou dont la license vous " +"Ne téléchargez que des polices que vous possédez ou dont la licence vous " "permet de les utiliser dans Penpot. Vous trouverez plus d'informations dans " -"la section Propriété des Contenus des [conditions générales d'utilisation " -"de Penpot](%s). Vous pouvez également vous renseigner sur les [licenses de " +"la section Propriété des Contenus des [conditions générales d'utilisation de " +"Penpot](%s). Vous pouvez également vous renseigner sur les [licences de " "polices](https://www.typography.com/faq)." #: src/app/main/ui/dashboard/fonts.cljs:214 @@ -810,7 +810,7 @@ msgstr "Médias en cours de traitement" #: src/app/main/ui/dashboard/import.cljs:125 msgid "dashboard.import.progress.process-page" -msgstr "Traitement de la page : %s" +msgstr "Traitement de la page : %s" #: src/app/main/ui/dashboard/import.cljs:131 msgid "dashboard.import.progress.process-typographies" @@ -822,7 +822,7 @@ msgstr "Envoi des données au serveur (%s/%s)" #: src/app/main/ui/dashboard/import.cljs:122 msgid "dashboard.import.progress.upload-media" -msgstr "Envoi du fichier : %s" +msgstr "Envoi du fichier : %s" #: src/app/main/ui/dashboard/team.cljs:765 msgid "dashboard.invitation-modal.delete" @@ -938,7 +938,7 @@ msgstr "Les paramètres des notifications ont été mis à jour" #: src/app/main/ui/settings/password.cljs:38 msgid "dashboard.notifications.password-saved" -msgstr "Mot de passe enregistré avec succès !" +msgstr "Mot de passe enregistré !" #: src/app/main/ui/dashboard/comments.cljs:45 msgid "dashboard.notifications.view" @@ -1049,7 +1049,7 @@ msgstr "Tout restaurer" #: src/app/main/data/dashboard.cljs:903 msgid "dashboard.restore-files-success-notification" -msgstr "%s fichiers ont été restaurés avec succès." +msgstr "%s fichiers ont bien été restaurés." #: src/app/main/ui/dashboard/deleted.cljs:82 msgid "dashboard.restore-project-button" @@ -1065,7 +1065,7 @@ msgstr "Restaurer le projet" #: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 msgid "dashboard.restore-success-notification" -msgstr "%s a été restauré avec succès." +msgstr "%s a bien été restauré." #: src/app/main/ui/settings/profile.cljs:78 msgid "dashboard.save-settings" @@ -1189,7 +1189,7 @@ msgstr "Votre projet a bien été dupliqué" #: src/app/main/ui/dashboard/file_menu.cljs:132, src/app/main/ui/dashboard/grid.cljs:634, src/app/main/ui/dashboard/sidebar.cljs:166 msgid "dashboard.success-move-file" -msgstr "Votre fichier a été déplacé avec succès" +msgstr "Votre fichier a bien été déplacé" #: src/app/main/ui/dashboard/file_menu.cljs:131 msgid "dashboard.success-move-files" @@ -1229,7 +1229,7 @@ msgstr "Les fichiers supprimés resteront dans la corbeille pendant" #: src/app/main/ui/dashboard/deleted.cljs:300 msgid "dashboard.trash-info-text-part2" -msgstr " %s jours. " +msgstr " %s jours. " #: src/app/main/ui/dashboard/deleted.cljs:301 msgid "dashboard.trash-info-text-part3" @@ -1247,7 +1247,7 @@ msgstr "Écrivez pour rechercher" #: src/app/main/ui/dashboard/file_menu.cljs:319, src/app/main/ui/workspace/main_menu.cljs:642 msgid "dashboard.unpublish-shared" -msgstr "Retirer la Bibliothèque" +msgstr "Supprimer la bibliothèque" #: src/app/main/ui/settings/options.cljs:74 msgid "dashboard.update-settings" @@ -1285,7 +1285,7 @@ msgstr "Créer un webhook" #: src/app/main/ui/dashboard/team.cljs:1031 msgid "dashboard.webhooks.create.success" -msgstr "Webhook créé avec succès." +msgstr "Webhook créé." #: src/app/main/ui/dashboard/team.cljs:1136 msgid "dashboard.webhooks.description" @@ -1305,7 +1305,7 @@ msgstr "Aucun webhook créé jusqu’à présent." #, unused msgid "dashboard.webhooks.update.success" -msgstr "Webhook mis à jour avec succès." +msgstr "Webhook mis à jour." #: src/app/main/ui/settings.cljs:34 msgid "dashboard.your-account-title" @@ -1364,7 +1364,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Ouvrir la liste des tokens" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Détacher le token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -1453,21 +1453,21 @@ msgstr "Domaine non autorisé" #: src/app/main/ui/auth/recovery_request.cljs:57, src/app/main/ui/auth/register.cljs:98, src/app/main/ui/auth/register.cljs:101, src/app/main/ui/dashboard/team.cljs:627, src/app/main/ui/settings/change_email.cljs:37 msgid "errors.email-has-permanent-bounces" -msgstr "L'adresse e-mail « %s » a un taux de rebond trop élevé." +msgstr "L'adresse e-mail « %s » a un taux de rebond trop élevé." #: src/app/main/ui/dashboard/team.cljs:196, src/app/main/ui/dashboard/team.cljs:858, src/app/main/ui/onboarding/team_choice.cljs:110 msgid "errors.email-spam-or-permanent-bounces" -msgstr "L'e-mail \"%s\" a été signalé comme spam ou a été rejeté." +msgstr "L'e-mail « %s » a été signalé comme spam ou a été rejeté." #: src/app/main/errors.cljs:279 msgid "errors.feature-mismatch" msgstr "" -"Il semble que vous ouvrez un fichier qui a la fonctionnalité '%s' activée, " +"Vous semblez ouvrir un fichier pour lequel la fonctionnalité « %s » activée, " "mais votre interface Penpot ne la prend pas en charge ou l'a désactivée." #: src/app/main/errors.cljs:283, src/app/main/errors.cljs:297 msgid "errors.feature-not-supported" -msgstr "La fonctionnalité '%s' n'est pas prise en charge." +msgstr "La fonctionnalité « %s » n'est pas prise en charge." #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:296, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:240 msgid "errors.field-max-length" @@ -1491,7 +1491,7 @@ msgid "errors.file-feature-mismatch" msgstr "" "Il semble y avoir une incompatibilité entre les fonctionnalités actives et " "celles du fichier que tentez d'ouvrir. Vous devez activer les migrations " -"pour '%s' avant de pouvoir ouvrir le fichier." +"pour « %s » avant de pouvoir ouvrir le fichier." #: src/app/main/data/auth.cljs:347, src/app/main/ui/auth/login.cljs:104, src/app/main/ui/auth/register.cljs:110, src/app/main/ui/auth/register.cljs:304, src/app/main/ui/auth/verify_token.cljs:94, src/app/main/ui/dashboard/team.cljs:199, src/app/main/ui/dashboard/team.cljs:861, src/app/main/ui/onboarding/team_choice.cljs:113, src/app/main/ui/settings/access_tokens.cljs:79, src/app/main/ui/settings/feedback.cljs:84 msgid "errors.generic" @@ -1538,7 +1538,7 @@ msgstr "Invitation non valide" #: src/app/main/ui/static.cljs:75 msgid "errors.invite-invalid.info" -msgstr "Cette invitation est peut-être été annulée ou a expiré." +msgstr "Cette invitation a peut-être été annulée ou est arrivée à expiration." #: src/app/main/ui/auth/login.cljs:89 msgid "errors.ldap-disabled" @@ -1553,7 +1553,7 @@ msgstr "" #: src/app/main/ui/dashboard/team.cljs:187, src/app/main/ui/dashboard/team.cljs:849, src/app/main/ui/onboarding/team_choice.cljs:101 msgid "errors.maximum-invitations-by-request-reached" msgstr "" -"Le nombre maximum (%s) d'e-mails qui peuvent être invités dans une seule " +"Le nombre maximal (%s) d'e-mails qui peuvent être invités dans une seule " "demande est atteint" #: src/app/main/data/workspace/media.cljs:190 @@ -1563,8 +1563,7 @@ msgstr "L’image est trop grande." #: src/app/main/data/media.cljs:70, src/app/main/data/workspace/media.cljs:193 msgid "errors.media-type-mismatch" msgstr "" -"Il semble que le contenu de l’image ne correspond pas à l’extension de " -"fichier." +"Le contenu de l’image semble ne pas correspondre à l’extension de fichier." #: src/app/main/data/media.cljs:67, src/app/main/data/workspace/media.cljs:178, src/app/main/data/workspace/media.cljs:181, src/app/main/data/workspace/media.cljs:184, src/app/main/data/workspace/media.cljs:187 msgid "errors.media-type-not-allowed" @@ -1622,7 +1621,7 @@ msgstr "Le fichier SVG n'est pas valide ou est mal formé" #: src/app/main/errors.cljs:270 msgid "errors.team-feature-mismatch" -msgstr "Fonctionnalité incompatible détectée '%s'" +msgstr "Fonctionnalité incompatible détectée « %s »" #: src/app/main/ui/dashboard/sidebar.cljs:373, src/app/main/ui/dashboard/team.cljs:393 msgid "errors.team-leave.insufficient-members" @@ -1632,17 +1631,17 @@ msgstr "" #: src/app/main/ui/dashboard/sidebar.cljs:376, src/app/main/ui/dashboard/team.cljs:396 msgid "errors.team-leave.member-does-not-exists" -msgstr "Le membre que vous essayez d'assigner n'existe pas." +msgstr "Le membre que vous essayez d'affecter n'existe pas." #: src/app/main/ui/dashboard/sidebar.cljs:379, src/app/main/ui/dashboard/team.cljs:399 msgid "errors.team-leave.owner-cant-leave" msgstr "" -"Le propriétaire ne peut pas quitter l'équipe, vous devez réassigner le rôle " +"Le propriétaire ne peut pas quitter l'équipe, vous devez réaffecter le rôle " "de propriétaire." #: src/app/main/ui/workspace/tokens/sets/helpers.cljs:26, src/app/main/ui/workspace/tokens/sets/helpers.cljs:45 msgid "errors.token-set-already-exists" -msgstr "Une collection avec le même nom existe déjà" +msgstr "Il existe déjà une collection portant ce nom" #: src/app/main/data/tokens.cljs: #, unused @@ -1652,12 +1651,12 @@ msgstr "Impossible de dupliquer une collection inconnue" #: src/app/main/data/workspace/tokens/library_edit.cljs:337 msgid "errors.token-set-exists-on-drop" msgstr "" -"Impossible de déposer, une collection avec le même nom existe déjà dans ce " +"Impossible de déposer. Il existe déjà une collection portant ce nom à ce " "chemin." #: src/app/main/data/workspace/tokens/library_edit.cljs:125, src/app/main/data/workspace/tokens/library_edit.cljs:144 msgid "errors.token-theme-already-exists" -msgstr "Une option de thème avec le même nom existe déjà" +msgstr "Il existe déjà une option de thème portant ce nom" #: src/app/main/data/media.cljs:73 msgid "errors.unexpected-error" @@ -2061,7 +2060,7 @@ msgstr "" #: src/app/main/ui/inspect/right_sidebar.cljs:166 msgid "inspect.layer-info" -msgstr "Info sur la couche" +msgstr "Infos sur le calque" #: src/app/main/ui/inspect/right_sidebar.cljs:137 msgid "inspect.multiple-selected" @@ -2412,7 +2411,7 @@ msgstr "Événement" #: src/app/main/ui/dashboard/team.cljs:668 msgid "labels.expired-invitation" -msgstr "Expirée" +msgstr "Arrivée à expiration" #: src/app/main/ui/exports/assets.cljs:172, src/app/main/ui/workspace/tokens/sidebar.cljs:134 msgid "labels.export" @@ -2743,7 +2742,7 @@ msgstr "Supprimer" #: src/app/main/ui/dashboard/team.cljs:355 msgid "labels.remove-member" -msgstr "Retirer le membre" +msgstr "Supprimer le membre" #: src/app/main/ui/dashboard/file_menu.cljs:299, src/app/main/ui/dashboard/project_menu.cljs:88, src/app/main/ui/dashboard/sidebar.cljs:471, src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/versions.cljs:192, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 msgid "labels.rename" @@ -3553,7 +3552,7 @@ msgstr "" #: src/app/main/ui/workspace/header.cljs, src/app/main/ui/dashboard/file_menu.cljs #, unused msgid "modals.remove-shared-confirm.message" -msgstr "Retirer « %s » en tant que bibliothèque partagée" +msgstr "Supprimer « %s » en tant que bibliothèque partagée" #: src/app/main/ui/workspace/nudge.cljs:52 msgid "modals.small-nudge" @@ -3568,14 +3567,14 @@ msgstr[1] "Dépublier" #: src/app/main/ui/delete_shared.cljs:50 msgid "modals.unpublish-shared-confirm.message" msgid_plural "modals.unpublish-shared-confirm.message" -msgstr[0] "Vous êtes sûr de vouloir retirer cette bibliothèque ?" -msgstr[1] "Vous êtes sûr de vouloir retirer ces bibliothèques ?" +msgstr[0] "Voulez-vous vraiment supprimer cette bibliothèque ?" +msgstr[1] "Voulez-vous vraiment supprimer ces bibliothèques ?" #: src/app/main/ui/delete_shared.cljs:45 msgid "modals.unpublish-shared-confirm.title" msgid_plural "modals.unpublish-shared-confirm.title" -msgstr[0] "Retirer la bibliothèque" -msgstr[1] "Retirer les bibliothèques" +msgstr[0] "Supprimer la bibliothèque" +msgstr[1] "Supprimer les bibliothèques" #: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/context_menu.cljs #, unused @@ -3717,7 +3716,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Il y a une nouvelle version disponible. Rafraîchissez la page" +msgstr "Il y a une nouvelle version disponible." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -3739,7 +3738,7 @@ msgstr "" #: src/app/main/ui/settings/options.cljs:27, src/app/main/ui/settings/profile.cljs:30 msgid "notifications.profile-saved" -msgstr "Profil enregistré avec succès !" +msgstr "Profil enregistré !" #: src/app/main/ui/settings/change_email.cljs:46 msgid "notifications.validation-email-sent" @@ -4696,11 +4695,11 @@ msgstr "Activer/désactiver les calques" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:190 msgid "shortcuts.toggle-layout-flex" -msgstr "Ajouter/supprimer flex layout" +msgstr "Ajouter/supprimer la disposition flex" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:191 msgid "shortcuts.toggle-layout-grid" -msgstr "Ajouter / Retirer grid layout" +msgstr "Ajouter/Supprimer la disposition en grille" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:192 msgid "shortcuts.toggle-lock" @@ -4921,8 +4920,8 @@ msgstr[1] "Il y a actuellement %s personnes dans vos équipe qui peuvent être #: src/app/main/ui/settings/subscription.cljs:230 msgid "subscription.settings.management.dialog.downgrade" msgstr "" -"Attention : changer vers un abonnement plus bas signifie moins de stockage " -"et des sauvegardes et des version d'historique plus courtes." +"Attention : en passant à un abonnement inférieur, le stockage est moins " +"important et les sauvegardes et les version d'historique sont plus courtes." #: src/app/main/ui/settings/subscription.cljs:211 msgid "subscription.settings.management.dialog.editors" @@ -5010,11 +5009,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Merci d'avoir choisi l'abonnement Penpot %s !" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Profitez bien de votre abonnement !" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Vous êtes %s !" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5056,8 +5055,8 @@ msgstr "Mettre à niveau votre abonnement" #, markdown msgid "subscription.workspace.versions.warning.enterprise.subtext-owner" msgstr "" -"Si vous souhaitez augmenter cette limite, écrivez-nous à l'adressse " -"[%s](mailto)" +"Si vous souhaitez augmenter cette limite, écrivez-nous à l'adresse [%s]" +"(mailto)" #: src/app/main/ui/workspace/sidebar/versions.cljs:59 #, markdown @@ -5946,7 +5945,7 @@ msgstr "Activer/Désactiver le flou" #: src/app/main/ui/workspace/sidebar/options/page.cljs:42, src/app/main/ui/workspace/sidebar/options/page.cljs:50 msgid "workspace.options.canvas-background" -msgstr "Couleur de fond du canvas" +msgstr "Couleur de fond du canevas" #: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 msgid "workspace.options.clip-content" @@ -6424,7 +6423,7 @@ msgstr "Naviguer vers" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:52 msgid "workspace.options.interaction-navigate-to-dest" -msgstr "Naviguer vers : %s" +msgstr "Accéder à : %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:53, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:55, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:57, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:357 msgid "workspace.options.interaction-none" @@ -6444,7 +6443,7 @@ msgstr "Ouvrir la superposition" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:54 msgid "workspace.options.interaction-open-overlay-dest" -msgstr "Ouvrir la superposition : %s" +msgstr "Ouvrir la superposition : %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 msgid "workspace.options.interaction-open-url" @@ -6513,7 +6512,7 @@ msgstr "Activer/désactiver la superposition" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:56 msgid "workspace.options.interaction-toggle-overlay-dest" -msgstr "Activer/désactiver la superposition : %s" +msgstr "Activer/désactiver la superposition : %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:415 msgid "workspace.options.interaction-trigger" @@ -7357,7 +7356,7 @@ msgstr "Copier en CSS" #: src/app/main/ui/workspace/context_menu.cljs:220 msgid "workspace.shape.menu.copy-css-nested" -msgstr "Copier en CSS (couches imbriquées)" +msgstr "Copier en CSS (calques imbriqués)" #: src/app/main/ui/workspace/context_menu.cljs:203 msgid "workspace.shape.menu.copy-link" @@ -7498,7 +7497,7 @@ msgstr "Chemin" #: src/app/main/ui/workspace/context_menu.cljs:549 msgid "workspace.shape.menu.remove-flex" -msgstr "Retirer flex layout" +msgstr "Supprimer la disposition flex" #: src/app/main/ui/workspace/context_menu.cljs:552 msgid "workspace.shape.menu.remove-grid" @@ -7550,7 +7549,7 @@ msgstr "Afficher le composant principal" #: src/app/main/ui/workspace/context_menu.cljs:314 msgid "workspace.shape.menu.thumbnail-remove" -msgstr "Retirer la miniature" +msgstr "Supprimer la miniature" #: src/app/main/ui/workspace/context_menu.cljs:316 msgid "workspace.shape.menu.thumbnail-set" @@ -7706,7 +7705,7 @@ msgid "workspace.tokens.color" msgstr "Couleur" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "L'interlignage dépend de la taille de la police. Ajoutez une taille de " "police pour obtenir la valeur déduite." @@ -7721,7 +7720,7 @@ msgstr "En créer un." #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:235 msgid "workspace.tokens.create-token" -msgstr "Créer un nouveau token %s" +msgstr "Créer un token %s" #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:353 msgid "workspace.tokens.delete" @@ -7753,10 +7752,10 @@ msgstr "Modifier les thèmes" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 msgid "workspace.tokens.edit-token" -msgstr "Modifier le token" +msgstr "Modifier le token %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "La valeur du token doit être renseignée" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7764,7 +7763,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Entrez le nom du token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Erreur d'importation : Impossible d'interpréter le fichier JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7818,7 +7817,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importer %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Erreur d'importation :" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7877,45 +7876,44 @@ msgid "workspace.tokens.individual-tokens" msgstr "Utiliser des tokens individuels" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Couleur non valide : %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Erreur d'importation : données du token non valides dans le fichier JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Erreur lors de l'importation : nom du token non valide au format JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "« %s » n'est pas un nom de token valide.\n" -"Les noms des tokens ne doivent pas comporter de lettres et de chiffres " -"séparés par des caractères « . » et ne doivent pas commencer par le symbole " -"« $ »." +"Les noms de token ne doivent pas comporter de lettres et de chiffres séparés " +"par des caractères « . » et ne doivent pas commencer par le symbole « $ »." #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Valeur du token non valide : seules les valeurs Aucune, Majuscules, " "Minuscules ou Première lettre en capitale sont acceptées" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Valeur du token non valide : seules les valeurs none, underline et " "strike-through sont acceptées" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "" "Valeur non valide : elle doit faire référence à un token de typographie " "composite." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valeur du token non valide : %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7951,7 +7949,7 @@ msgid "workspace.tokens.min-size" msgstr "Taille min." #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Références du token manquantes : " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7991,7 +7989,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Vous n'avez actuellement aucun thème." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Aucun token, collection ou thème n'ont été trouvés dans ce fichier." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7999,11 +7997,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s collections actives" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valeur du token non valide. La valeur est trop grande : %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "L'opacité doit être comprise entre 0 % et 100 % ou entre 0 et 1 (ex : 50 % " "ou 0.5)." @@ -8044,7 +8042,7 @@ msgid "workspace.tokens.select-set" msgstr "Sélectionner la collection." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Le token s'auto-référence" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8079,7 +8077,7 @@ msgid "workspace.tokens.size" msgstr "Taille" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "La largueur du tracé doit être plus grand ou égal à 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 @@ -8132,7 +8130,7 @@ msgstr "" #: src/app/main/ui/workspace/tokens/style_dictionary.cljs:259 #, unused msgid "workspace.tokens.token-not-resolved" -msgstr "Impossible de trouver une référence de token ayant comme nom : %s" +msgstr "Impossible de trouver une référence de token portant le nom : %s" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:267 msgid "workspace.tokens.token-value" @@ -8144,19 +8142,19 @@ msgstr "Entrez une valeur ou un alias avec {alias}" #: src/app/main/ui/workspace/tokens/management.cljs:67 msgid "workspace.tokens.tokens-section-title" -msgstr "TOKENS - %s" +msgstr "TOKENS – %s" #: src/app/main/ui/workspace/tokens/sidebar.cljs:122 msgid "workspace.tokens.tools" msgstr "Outils" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "L'importation a réussi. Certains tokens n'ont pas été inclus." +msgstr "L'importation a réussi, mais certains tokens ont été ignorés car ils utilisent des valeurs $type non prises en charge. Développez les détails pour voir quels tokens ont été affectés." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Le type « %s » n'est pas pris en charge (%s)\n" +msgstr "Le type « %s » n'est pas pris en charge (%s) :" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8167,11 +8165,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "La valeur n'est pas valide" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Valeur non valide : % n'est pas autorisé." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valeur non valide : les unités ne sont pas autorisées." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8498,8 +8496,362 @@ msgstr "" #: src/app/main/ui/workspace/sidebar/versions.cljs:431 msgid "workspace.versions.warning.text" -msgstr "Les versions auto-enregistrées seront gardées %s jours." +msgstr "Les versions auto-enregistrées seront conservées %s jours." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Cliquez pour fermer le chemin" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Créer une organisation" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Erreur inattendue : %s" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Recharger la page" + +#: src/app/main/ui/settings/subscription.cljs:50 +msgid "subscription.settings.recommended" +msgstr "Recommandé" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "1 invitation sélectionnée" +msgstr[1] "%s invitations sélectionnées" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Hauteur 100 %" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Largeur 100 %" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +#, fuzzy +msgid "workspace.options.orientation.horizontal" +msgstr "Horizontal" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +#, fuzzy +msgid "workspace.options.orientation.vertical" +msgstr "Vertical" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Ajouter une ombre" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "Le flou de l'ombre doit être supérieur ou égal à 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +#, unused +msgid "workspace.tokens.shadow-title" +msgstr "Ombres" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "La valeur de flou ne peut pas être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "La valeur de la portée ne peut pas être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +#, fuzzy +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +#, fuzzy +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "" +"Type d'ombre non valide : seuls les types « innerShadow » ou « dropShadow » " +"sont acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "Valeur non valide : doit référencer un token d'ombre composite." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Référence manquante" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Cette action pourrait prendre un instant." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +#, unused +msgid "workspace.tokens.shadow-color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Supprimer l'ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "Portée" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "La portée de l'ombre doit être supérieure ou égale à 0." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +#, unused +msgid "workspace.tokens.theme-name-already-exists" +msgstr "Il existe déjà un thème portant ce nom" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "Désactiver" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "Activer" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "Il existe déjà un token au chemin : %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "Le nom doit comporter au moins 1 caractère" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Outils de débogage" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL ne fonctionne plus. Rechargez la page pour le réinitialiser" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oups ! Le contexte du canevas a été perdu" + +#: src/app/main/ui/dashboard/subscription.cljs:196 +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "" +"Il y a %s éditeurs dans les équipes dont vous êtes propriétaire alors que " +"votre abonnement Professionnel permet d'en avoir jusqu'à 8." + +#: src/app/main/ui/dashboard/subscription.cljs:184 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "" +"Seuls les nouveaux éditeurs dans les équipes dont vous êtes propriétaire " +"sont pris en compte pour la facturation future. Un forfait de 175 $/mois " +"s'applique toujours au-delà de 25 éditeurs." + +#: src/app/main/ui/settings/subscription.cljs:298 +msgid "subscription.settings.management-dialog.step-2-add-payment-button" +msgstr "Ajouter les détails de paiement" + +#: src/app/main/ui/settings/subscription.cljs:285 +msgid "subscription.settings.management-dialog.step-2-description" +msgstr "" +"Ajoutez vos détails de paiement dès maintenant pour que votre abonnement ne " +"soit pas interrompu après la période d'essai et pour continuer à soutenir " +"notre projet Open Source. Vous n'allez pas être facturé pour le moment." + +#: src/app/main/ui/settings/subscription.cljs:293 +msgid "subscription.settings.management-dialog.step-2-skip-button" +msgstr "Ignorer pour le moment et commencer l'essai" + +#: src/app/main/ui/settings/subscription.cljs:203 +msgid "subscription.settings.management-dialog.step-2-title" +msgstr "Aidez-nous à nous développer et à faciliter votre essai" + +#: src/app/main/ui/settings/subscription.cljs:263 +msgid "subscription.settings.management.dialog.input-error" +msgstr "" +"Vous pouvez réduire votre nombre actuel d'éditeurs. Dans les paramètres de " +"l'équipe, modifiez le rôle (d'éditeur/admin en spectateur) des personnes qui " +"ne modifient pas vraiment des fichiers." + +#: src/app/main/ui/settings/subscription.cljs:266 +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "" +"Conseil : vous pouvez augmenter le nombre de vos utilisateurs dès maintenant " +"pour anticiper les invitations. Au-delà de 25 éditeurs dans vos équipes, un " +"forfait de 175 $/mois vous sera facturé." + +#: src/app/main/ui/workspace/sidebar/versions.cljs:58 +#, markdown +msgid "subscription.workspace.versions.warning.subtext-owner" +msgstr "" +"Si vous souhaitez augmenter cette limite, [mettez votre abonnement à " +"niveau|target:self](%s)" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "Modifier dans l'espace de travail" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Outils de débogage" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "Adapter le contenu (horizontalement)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "Adapter le contenu (verticalement)" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:567 +msgid "workspace.options.component.variant.duplicated.copy.title" +msgstr "" +"Ce composant comporte des variantes en conflit. Assurez-vous que chaque " +"variante possède une collection de valeurs de propriétés unique." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:267 +msgid "workspace.options.component.variant.duplicated.single.all" +msgstr "" +"Ces variantes possèdent des propriétés et des valeurs identiques. Ajustez " +"les valeurs afin de pouvoir les extraire." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"Ce composant comporte des variantes dont le nom n'est pas valide. Assurez-" +"vous que chaque variante respecte la structure appropriée." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"Si vous modifiez l'un de ces éléments (par exemple, en renommant ou en " +"regroupant un calque), la connexion est rompue. Si vous annulez la " +"modification, la connexion est rétablie." + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "Taille de police ou {alias}" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "" +"Erreur lors de la définition de la graisse/du style de la police. Ce style " +"de police n'existe pas dans la police active" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "" +"Valeur de token non valide : vous ne pouvez référencer qu'un token de " +"famille de polices" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "Interlettrage ou {alias}" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "" +"Ce token n'est pas utilisé actuellement dans votre conception. Aucun " +"remappage n'est donc nécessaire." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Ne pas remapper" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "Entrer un alias de typographie pour un token" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Entrer un alias d'ombre pour un token" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Remapper des tokens" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Remapper tous les tokens qui utilisent «  %s  » dans « %s » ?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Tous les calques et les références utilisant l'ancien nom du token vont être " +"modifiés." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "Remappage des références du token..." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "Si vous renommez ce token, toute référence à son ancien nom sera rompue" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "Changer" + +#: src/app/main/ui/dashboard/subscription.cljs:84 +msgid "subscription.dashboard.power-up.professional.bottom-button" +msgstr "Passez à un forfait supérieur !" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "Corriger la hauteur" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "Corriger la largeur" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:570 +msgid "workspace.options.component.variant.duplicated.copy.locate" +msgstr "Chercher les variantes en conflit" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "Vers le bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "À l'intérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "À l'extérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "Plus de tokens de couleur" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"Valeur de graisse de la police non valide : utilisez des valeurs numériques " +"(100 à 950) ou des noms standard (thin, light, regular, bold, etc.) " +"éventuellement suivi de la mention « Italic »" diff --git a/frontend/translations/fr_CA.po b/frontend/translations/fr_CA.po index 999ea56579..27a5e058a3 100644 --- a/frontend/translations/fr_CA.po +++ b/frontend/translations/fr_CA.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:15+0000\n" +"PO-Revision-Date: 2026-02-28 00:09+0000\n" "Last-Translator: Alexis Morin \n" -"Language-Team: French (Canada) " -"\n" +"Language-Team: French (Canada) \n" "Language: fr_CA\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n > 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -208,7 +208,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "images de marque, illustrations, matériel de marketing..." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Ce token n'existe pas ou a été supprimé." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1355,7 +1355,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Ouvrir la liste de tokens" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Détacher du token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -3690,7 +3690,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Une nouvelle version est disponible. Merci de rafraîchir la page" +msgstr "Une nouvelle version est disponible." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -4663,7 +4663,7 @@ msgstr "Basculer les calques" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:190 msgid "shortcuts.toggle-layout-flex" -msgstr "Ajouter / retirer mise en page flex" +msgstr "Ajouter / retirer disposition flex" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:191 msgid "shortcuts.toggle-layout-grid" @@ -5025,11 +5025,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Merci d'avoir choisi le forfait Penpot %s!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Profite bien de ton forfait!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Tu es %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -5458,3 +5458,3381 @@ msgstr "Tokens de couleur" #: src/app/main/ui/workspace/colorpicker.cljs:434 msgid "workspace.colorpicker.get-color" msgstr "Pipette de couleur" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oups! Le contexte du canevas a été perdu" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Crée une nouvelle organisation" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Erreur inattendue : %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL a arrêté de fonctionner. Recharger la page pour le réinitialiser" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Recharger la page" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:506 +msgid "workspace.component.swap.loop-error" +msgstr "Les composants ne peuvent pas être imbriqués dans eux-mêmes." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:505 +msgid "workspace.component.switch.loop-error-multi" +msgstr "" +"Certaines copies n'ont pu être interchangées. Les composants ne peuvent pas " +"être imbriqués dans eux-mêmes." + +#: src/app/main/ui/workspace/context_menu.cljs:796 +msgid "workspace.context-menu.grid-cells.area" +msgstr "Créer une zone" + +#: src/app/main/ui/workspace/context_menu.cljs:799 +msgid "workspace.context-menu.grid-cells.create-board" +msgstr "Créer un tableau" + +#: src/app/main/ui/workspace/context_menu.cljs:791 +msgid "workspace.context-menu.grid-cells.merge" +msgstr "Fusionner les cellules" + +#: src/app/main/ui/workspace/context_menu.cljs:754 +msgid "workspace.context-menu.grid-track.column.add-after" +msgstr "Ajouter 1 colonne à droite" + +#: src/app/main/ui/workspace/context_menu.cljs:753 +msgid "workspace.context-menu.grid-track.column.add-before" +msgstr "Ajouter 1 colonne à gauche" + +#, unused +msgid "workspace.focus.selection" +msgstr "Sélection" + +#: src/app/main/ui/workspace/context_menu.cljs:755 +msgid "workspace.context-menu.grid-track.column.delete" +msgstr "Supprimer la colonne" + +#: src/app/main/ui/workspace/context_menu.cljs:756 +msgid "workspace.context-menu.grid-track.column.delete-shapes" +msgstr "Supprimer la colonne et les formes" + +#: src/app/main/ui/workspace/context_menu.cljs:752 +msgid "workspace.context-menu.grid-track.column.duplicate" +msgstr "Dupliquer la colonne" + +#: src/app/main/ui/workspace/context_menu.cljs:761 +msgid "workspace.context-menu.grid-track.row.add-after" +msgstr "Ajouter 1 colonne dessous" + +#: src/app/main/ui/workspace/context_menu.cljs:760 +msgid "workspace.context-menu.grid-track.row.add-before" +msgstr "Ajouter 1 colonne dessus" + +#: src/app/main/ui/workspace/context_menu.cljs:762 +msgid "workspace.context-menu.grid-track.row.delete" +msgstr "Supprimer la rangée" + +#: src/app/main/ui/workspace/context_menu.cljs:763 +msgid "workspace.context-menu.grid-track.row.delete-shapes" +msgstr "Supprimer la rangée et les formes" + +#: src/app/main/ui/workspace/context_menu.cljs:759 +msgid "workspace.context-menu.grid-track.row.duplicate" +msgstr "Dupliquer la rangée" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Outils de débogage" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:512 +msgid "workspace.focus.focus-mode" +msgstr "Mode focus" + +#: src/app/main/ui/workspace/context_menu.cljs:396, src/app/main/ui/workspace/context_menu.cljs:711 +msgid "workspace.focus.focus-off" +msgstr "Désactiver mode focus" + +#: src/app/main/ui/workspace/context_menu.cljs:395 +msgid "workspace.focus.focus-on" +msgstr "Activer mode focus" + +#: src/app/util/color.cljs:34 +msgid "workspace.gradients.linear" +msgstr "Dégradé linéaire" + +#: src/app/util/color.cljs:35 +msgid "workspace.gradients.radial" +msgstr "Dégradé radial" + +#: src/app/main/ui/workspace/main_menu.cljs:274 +msgid "workspace.header.menu.disable-dynamic-alignment" +msgstr "Désactiver l'alignement dynamique" + +#: src/app/main/ui/workspace/main_menu.cljs:228 +msgid "workspace.header.menu.disable-scale-content" +msgstr "Désactiver la mise à l'échelle proportionnelle" + +#: src/app/main/ui/workspace/header.cljs +#, unused +msgid "workspace.header.menu.disable-scale-text" +msgstr "Désactiver la mise à l'échelle du texte" + +#: src/app/main/ui/workspace/main_menu.cljs:259 +msgid "workspace.header.menu.disable-snap-guides" +msgstr "Désactiver l'accrochage aux guides" + +#: src/app/main/ui/workspace/main_menu.cljs:289 +msgid "workspace.header.menu.disable-snap-pixel-grid" +msgstr "Désactiver l'accrochage aux pixels" + +#: src/app/main/ui/workspace/main_menu.cljs:243 +msgid "workspace.header.menu.disable-snap-ruler-guides" +msgstr "Désactiver l'accrochage aux guides règles" + +#: src/app/main/ui/workspace/main_menu.cljs:275 +msgid "workspace.header.menu.enable-dynamic-alignment" +msgstr "Activer l'alignement dynamique" + +#: src/app/main/ui/workspace/main_menu.cljs:229 +msgid "workspace.header.menu.enable-scale-content" +msgstr "Activer la mise à l'échelle proportionnelle" + +#: src/app/main/ui/workspace/header.cljs +#, unused +msgid "workspace.header.menu.enable-scale-text" +msgstr "Activer la mise à l'échelle du texte" + +#: src/app/main/ui/workspace/main_menu.cljs:260 +msgid "workspace.header.menu.enable-snap-guides" +msgstr "Accrochage aux guides" + +#: src/app/main/ui/workspace/main_menu.cljs:290 +msgid "workspace.header.menu.enable-snap-pixel-grid" +msgstr "Activer l'accrochage aux pixels" + +#: src/app/main/ui/workspace/main_menu.cljs:244 +msgid "workspace.header.menu.enable-snap-ruler-guides" +msgstr "Accrochage aux règles guides" + +#: src/app/main/ui/workspace/main_menu.cljs:422 +msgid "workspace.header.menu.hide-artboard-names" +msgstr "Masquer le nom des tableaux" + +#: src/app/main/ui/workspace/main_menu.cljs:376 +msgid "workspace.header.menu.hide-guides" +msgstr "Masquer les guides" + +#: src/app/main/ui/workspace/main_menu.cljs:393 +msgid "workspace.header.menu.hide-palette" +msgstr "Masquer la palette de couleurs" + +#: src/app/main/ui/workspace/main_menu.cljs:434 +msgid "workspace.header.menu.hide-pixel-grid" +msgstr "Masquer la grille pixel" + +#: src/app/main/ui/workspace/main_menu.cljs:360 +msgid "workspace.header.menu.hide-rules" +msgstr "Masquer les règles" + +#: src/app/main/ui/workspace/main_menu.cljs:407 +msgid "workspace.header.menu.hide-textpalette" +msgstr "Masquer la palette de polices" + +#: src/app/main/ui/workspace/main_menu.cljs:884 +msgid "workspace.header.menu.option.edit" +msgstr "Éditer" + +#: src/app/main/ui/workspace/main_menu.cljs:873 +msgid "workspace.header.menu.option.file" +msgstr "Fichier" + +#: src/app/main/ui/workspace/main_menu.cljs:930 +msgid "workspace.header.menu.option.help-info" +msgstr "Aide et info" + +#: src/app/main/ui/workspace/main_menu.cljs:916 +#, unused +msgid "workspace.header.menu.option.power-up" +msgstr "Bonifier ton forfait" + +#: src/app/main/ui/workspace/main_menu.cljs:906 +msgid "workspace.header.menu.option.preferences" +msgstr "Préférences" + +#: src/app/main/ui/workspace/main_menu.cljs:895 +msgid "workspace.header.menu.option.view" +msgstr "Affichage" + +#: src/app/main/ui/workspace/main_menu.cljs:506 +msgid "workspace.header.menu.redo" +msgstr "Rétablir" + +#: src/app/main/ui/workspace/main_menu.cljs:477 +msgid "workspace.header.menu.select-all" +msgstr "Tout sélectionner" + +#: src/app/main/ui/workspace/main_menu.cljs:423 +msgid "workspace.header.menu.show-artboard-names" +msgstr "Afficher le nom des tableaux" + +#: src/app/main/ui/workspace/main_menu.cljs:377 +msgid "workspace.header.menu.show-guides" +msgstr "Afficher les guides" + +#: src/app/main/ui/workspace/main_menu.cljs:394 +msgid "workspace.header.menu.show-palette" +msgstr "Afficher la palette de couleurs" + +#: src/app/main/ui/workspace/main_menu.cljs:435 +msgid "workspace.header.menu.show-pixel-grid" +msgstr "Afficher la grille pixel" + +#: src/app/main/ui/workspace/main_menu.cljs:361 +msgid "workspace.header.menu.show-rules" +msgstr "Afficher les règles" + +#: src/app/main/ui/workspace/main_menu.cljs:408 +msgid "workspace.header.menu.show-textpalette" +msgstr "Afficher la palette des polices" + +#: src/app/main/ui/workspace/main_menu.cljs:316 +msgid "workspace.header.menu.toggle-dark-theme" +msgstr "Basculer au mode sombre" + +#: src/app/main/ui/workspace/main_menu.cljs:314, src/app/main/ui/workspace/main_menu.cljs:317 +msgid "workspace.header.menu.toggle-light-theme" +msgstr "Basculer au mode clair" + +#: src/app/main/ui/workspace/main_menu.cljs:315 +msgid "workspace.header.menu.toggle-system-theme" +msgstr "Basculer au thème du système" + +#: src/app/main/ui/workspace/main_menu.cljs:492 +msgid "workspace.header.menu.undo" +msgstr "Défaire" + +#: src/app/main/ui/viewer/header.cljs:93, src/app/main/ui/workspace/right_header.cljs:92 +msgid "workspace.header.reset-zoom" +msgstr "Réinitialiser" + +#: src/app/main/ui/workspace/left_header.cljs:128 +msgid "workspace.header.save-error" +msgstr "Erreur de sauvegarde" + +#: src/app/main/ui/workspace/left_header.cljs:127 +msgid "workspace.header.saved" +msgstr "Sauvegardé" + +#: src/app/main/ui/workspace/left_header.cljs:125, src/app/main/ui/workspace/left_header.cljs:126 +msgid "workspace.header.saving" +msgstr "Sauvegarde en cours" + +#: src/app/main/ui/workspace/right_header.cljs:232 +msgid "workspace.header.share" +msgstr "Partager" + +#: src/app/main/ui/workspace/right_header.cljs:48, src/app/main/ui/workspace/right_header.cljs:53 +#, unused +msgid "workspace.header.unsaved" +msgstr "Changements non sauvegardés" + +#: src/app/main/ui/workspace/right_header.cljs:237 +msgid "workspace.header.viewer" +msgstr "Mode visionnement (%s)" + +#: src/app/main/ui/viewer/header.cljs:74, src/app/main/ui/workspace/right_header.cljs:74 +msgid "workspace.header.zoom" +msgstr "Zoom" + +#: src/app/main/ui/viewer/header.cljs:104 +msgid "workspace.header.zoom-fill" +msgstr "Remplir l'écran" + +#: src/app/main/ui/viewer/header.cljs:97 +msgid "workspace.header.zoom-fit" +msgstr "Ajuster aux dimensions" + +#: src/app/main/ui/workspace/right_header.cljs:96 +msgid "workspace.header.zoom-fit-all" +msgstr "Zoom cadrant tout" + +#: src/app/main/ui/viewer/header.cljs:111 +msgid "workspace.header.zoom-full-screen" +msgstr "Plein écran" + +#: src/app/main/ui/workspace/right_header.cljs:104 +msgid "workspace.header.zoom-selected" +msgstr "Zoom à la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "Afficher la marge des 4 côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:275, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:859 +msgid "workspace.layout-grid.editor.options.edit-grid" +msgstr "Éditer la grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1539 +msgid "workspace.layout-grid.editor.options.exit" +msgstr "Quitter" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:584, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:593, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:599 +msgid "workspace.layout-grid.editor.padding.bottom" +msgstr "Marge interne du bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:669 +msgid "workspace.layout-grid.editor.padding.expand" +msgstr "Afficher la marge interne des 4 côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:416, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:427, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:433 +msgid "workspace.layout-grid.editor.padding.horizontal" +msgstr "Marge interne horizontale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:618, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:627, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:633 +msgid "workspace.layout-grid.editor.padding.left" +msgstr "Marge interne gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:551, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:560, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:566 +msgid "workspace.layout-grid.editor.padding.right" +msgstr "Marge interne droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:517, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:526, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:532 +msgid "workspace.layout-grid.editor.padding.top" +msgstr "Marge interne du haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:380, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:391, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:397 +msgid "workspace.layout-grid.editor.padding.vertical" +msgstr "Marge interne verticale" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:62 +msgid "workspace.layout-grid.editor.title" +msgstr "Édition de la grille" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:70 +msgid "workspace.layout-grid.editor.top-bar.done" +msgstr "Terminé" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:66 +msgid "workspace.layout-grid.editor.top-bar.locate" +msgstr "Situer" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1565 +msgid "workspace.layout-grid.editor.top-bar.locate.tooltip" +msgstr "Situer la mise en page grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "S'ajuster au contenu (horizontal)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "S'ajuster au contenu (vertical)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "Hauteur fixe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "Largeur fixe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Hauteur 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Largeur 100%" + +#: src/app/main/ui/workspace/libraries.cljs +#, unused +msgid "workspace.libraries.add" +msgstr "Ajouter" + +#: src/app/main/ui/workspace/libraries.cljs:100, src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "1 couleur" +msgstr[1] "%s couleurs" + +#: src/app/main/ui/workspace/color_palette.cljs:147 +msgid "workspace.libraries.colors.empty-palette" +msgstr "Aucun style de couleur dans la bibliothèque" + +#: src/app/main/ui/workspace/text_palette.cljs:161 +msgid "workspace.libraries.colors.empty-typography-palette" +msgstr "Aucun style de typographie dans la bibliothèque" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:88, src/app/main/ui/workspace/colorpicker/libraries.cljs:48, src/app/main/ui/workspace/text_palette_ctx_menu.cljs:49 +msgid "workspace.libraries.colors.file-library" +msgstr "Bibliothèque du fichier" + +#: src/app/main/ui/workspace/colorpicker.cljs +#, unused +msgid "workspace.libraries.colors.hsv" +msgstr "HSV" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:111, src/app/main/ui/workspace/colorpicker/libraries.cljs:47 +msgid "workspace.libraries.colors.recent-colors" +msgstr "Couleurs récentes" + +#: src/app/main/ui/workspace/colorpicker.cljs +#, unused +msgid "workspace.libraries.colors.rgb-complementary" +msgstr "RVB complémentaire" + +#: src/app/main/ui/workspace/colorpicker.cljs:355 +msgid "workspace.libraries.colors.rgba" +msgstr "RVBA" + +#: src/app/main/ui/workspace/colorpicker.cljs:555 +msgid "workspace.libraries.colors.save-color" +msgstr "Enregistrer le style de couleur" + +#: src/app/main/ui/workspace/libraries.cljs:94, src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "1 composant" +msgstr[1] "%s composants" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "Connecté à" + +#: src/app/main/ui/workspace/libraries.cljs:392 +msgid "workspace.libraries.empty.add-some" +msgstr "Ou ajoute celles-ci pour tester :" + +#: src/app/main/ui/workspace/libraries.cljs:386 +msgid "workspace.libraries.empty.no-libraries" +msgstr "Aucune bibliothèque partagée dans ton équipe, tu peux chercher" + +#: src/app/main/ui/workspace/libraries.cljs:390 +msgid "workspace.libraries.empty.some-templates" +msgstr "des modèles ici" + +#: src/app/main/ui/workspace/libraries.cljs:313 +msgid "workspace.libraries.file-library" +msgstr "Bibliothèque du fichier" + +#: src/app/main/ui/workspace/libraries.cljs:97, src/app/main/ui/workspace/libraries.cljs:122 +msgid "workspace.libraries.graphics" +msgid_plural "workspace.libraries.graphics" +msgstr[0] "1 graphisme" +msgstr[1] "%s graphismes" + +#: src/app/main/ui/workspace/libraries.cljs:307 +msgid "workspace.libraries.in-this-file" +msgstr "BIBLIOTHÈQUES DANS CE FICHIER" + +#: src/app/main/ui/workspace/libraries.cljs:628, src/app/main/ui/workspace/libraries.cljs:648 +msgid "workspace.libraries.libraries" +msgstr "BIBLIOTHÈQUES" + +#: src/app/main/ui/workspace/libraries.cljs +#, unused +msgid "workspace.libraries.library" +msgstr "BIBLIOTHÈQUE" + +#: src/app/main/ui/workspace/libraries.cljs:487 +msgid "workspace.libraries.library-updates" +msgstr "MISES À JOUR DE BIBLIOTHÈQUES" + +#: src/app/main/ui/workspace/libraries.cljs:381 +msgid "workspace.libraries.loading" +msgstr "Chargement…" + +#: src/app/main/ui/workspace/libraries.cljs:387 +#, unused +msgid "workspace.libraries.more-templates" +msgstr "Tu peux chercher " + +#: src/app/main/ui/workspace/libraries.cljs:485 +msgid "workspace.libraries.no-libraries-need-sync" +msgstr "Aucune bibliothèque partagée ne requiert de mise à jour" + +#: src/app/main/ui/workspace/libraries.cljs:399 +msgid "workspace.libraries.no-matches-for" +msgstr "Aucun résultat trouvé pour « %s »" + +#: src/app/main/ui/workspace/libraries.cljs:356 +msgid "workspace.libraries.search-shared-libraries" +msgstr "Recherche des bibliothèques partagées" + +#: src/app/main/ui/workspace/libraries.cljs:352 +msgid "workspace.libraries.shared-libraries" +msgstr "BIBLIOTHÈQUES PARTAGÉES" + +#: src/app/main/ui/workspace/libraries.cljs:372 +msgid "workspace.libraries.shared-library-btn" +msgstr "Connecter une bibliothèque" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.multiple-typography" +msgstr "Multiples typographies" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-typography-tooltip" +msgstr "Délier toutes les typographies" + +#: src/app/main/ui/workspace/libraries.cljs:103, src/app/main/ui/workspace/libraries.cljs:130 +msgid "workspace.libraries.typography" +msgid_plural "workspace.libraries.typography" +msgstr[0] "1 typographie" +msgstr[1] "%s typographies" + +#: src/app/main/ui/workspace/libraries.cljs:343 +msgid "workspace.libraries.unlink-library-btn" +msgstr "Déconnecter la bibliothèque" + +#: src/app/main/ui/workspace/libraries.cljs:507 +msgid "workspace.libraries.update" +msgstr "Mettre à jour" + +#: src/app/main/ui/workspace/libraries.cljs:583 +msgid "workspace.libraries.update.see-all-changes" +msgstr "voir tous les changements" + +#: src/app/main/ui/workspace/libraries.cljs:630 +msgid "workspace.libraries.updates" +msgstr "MISES À JOUR" + +#: src/app/main/ui/ds/notifications/shared/notification_pill.cljs:67, src/app/main/ui/ds/notifications/shared/notification_pill.cljs:72 +msgid "workspace.notification-pill.detail" +msgstr "Détails" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 +msgid "workspace.options.add-interaction" +msgstr "Cliquer le bouton + pour ajouter des interactions." + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:96 +msgid "workspace.options.blur-options.add-blur" +msgstr "Ajouter un flou" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:119 +msgid "workspace.options.blur-options.remove-blur" +msgstr "Supprimer le flou" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:92, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:112 +msgid "workspace.options.blur-options.title" +msgstr "Flouter" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:91 +msgid "workspace.options.blur-options.title.group" +msgstr "Flou de groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:90 +msgid "workspace.options.blur-options.title.multiple" +msgstr "Flou de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:115 +msgid "workspace.options.blur-options.toggle-blur" +msgstr "Basculer le flou" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:42, src/app/main/ui/workspace/sidebar/options/page.cljs:50 +msgid "workspace.options.canvas-background" +msgstr "Arrière-plan du canevas" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 +msgid "workspace.options.clip-content" +msgstr "Rogner le contenu" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1027, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1033, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1282 +msgid "workspace.options.component" +msgstr "Composant" + +#: src/app/main/ui/inspect/annotation.cljs:19, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:194 +msgid "workspace.options.component.annotation" +msgstr "Annotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1041 +msgid "workspace.options.component.copy" +msgstr "Copier" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:187 +msgid "workspace.options.component.create-annotation" +msgstr "Créer une annotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:186 +msgid "workspace.options.component.edit-annotation" +msgstr "Éditer l'annotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1040, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1286 +msgid "workspace.options.component.main" +msgstr "Principal" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:781 +msgid "workspace.options.component.swap" +msgstr "Permuter composant" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:820 +msgid "workspace.options.component.swap.empty" +msgstr "Aucun atout dans cette bibliothèque" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1066 +msgid "workspace.options.component.unlinked" +msgstr "Non-lié" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:570 +msgid "workspace.options.component.variant.duplicated.copy.locate" +msgstr "Localiser les variants en conflit" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:567 +msgid "workspace.options.component.variant.duplicated.copy.title" +msgstr "" +"Ce composant a des variants en conflit. Assure-toi que chaque variant " +"possède des valeurs de propriétés uniques." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1341 +msgid "workspace.options.component.variant.duplicated.group.locate" +msgstr "Localiser les doublons" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1338 +msgid "workspace.options.component.variant.duplicated.group.title" +msgstr "Certains variants ont des propriétés et valeurs identiques" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:267 +msgid "workspace.options.component.variant.duplicated.single.all" +msgstr "" +"Ces variants ont des propriétés et valeurs identiques. Ajuster les valeurs " +"pour qu'elles puissent être trouvées." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:264 +msgid "workspace.options.component.variant.duplicated.single.one" +msgstr "" +"Ce variant a des propriétés et valeurs identiques à un autre variant. " +"Ajuster les valeurs pour qu'elles puissent être trouvées." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:270 +msgid "workspace.options.component.variant.duplicated.single.some" +msgstr "" +"Certains variants ont des propriétés et valeurs identiques. Ajuster les " +"valeurs pour qu'elles puissent être trouvées." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"Ce composant a des variants avec des noms invalides. Chaque variant doit " +"suivre la structure correcte." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1331 +msgid "workspace.options.component.variant.malformed.group.locate" +msgstr "Localiser les variants invalides" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1328 +msgid "workspace.options.component.variant.malformed.group.title" +msgstr "Certains variants ont des noms invalides" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:560 +msgid "workspace.options.component.variant.malformed.locate" +msgstr "Localiser les variants invalides" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:251 +msgid "workspace.options.component.variant.malformed.single.all" +msgstr "Ces variants ont des noms invalides." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:248 +msgid "workspace.options.component.variant.malformed.single.one" +msgstr "Ce variant a un nom invalide." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:254 +msgid "workspace.options.component.variant.malformed.single.some" +msgstr "Certains variants ont des noms invalides." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:433 +msgid "workspace.options.component.variant.malformed.structure.example" +msgstr "[propriété]=[valeur], [propriété]=[valeur]" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:431 +msgid "workspace.options.component.variant.malformed.structure.title" +msgstr "Utilise la structure suivante :" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:54 +msgid "workspace.options.component.variants-help-modal.intro" +msgstr "" +"Pour maintenir les changements entre les variants, Penpot connecte les " +"calques qui :" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"Chaque changement (ex. renommer ou grouper un calque) brise la connection. " +"Défaire le changement va la restaurer." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:67 +msgid "workspace.options.component.variants-help-modal.rule1" +msgstr "Ont le même nom." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:76 +msgid "workspace.options.component.variants-help-modal.rule2" +msgstr "Sont du même type." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:77 +msgid "workspace.options.component.variants-help-modal.rule2.detail" +msgstr "" +"Les rectangles, ellipses, chemins et opérations booléennes comptent comme " +"étant du même type." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:87 +msgid "workspace.options.component.variants-help-modal.rule3" +msgstr "Ont le même niveau de hiérarchie." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:88 +msgid "workspace.options.component.variants-help-modal.rule3.detail" +msgstr "Les groupes, tableaux et mises en page sont considérés équivalents." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1045, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1289, src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:47 +msgid "workspace.options.component.variants-help-modal.title" +msgstr "Comment les variants demeurent connectés" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:164 +msgid "workspace.options.constraints" +msgstr "Contraintes" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:151 +msgid "workspace.options.constraints.bottom" +msgstr "Bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:142, src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:153 +msgid "workspace.options.constraints.center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:224 +msgid "workspace.options.constraints.fix-when-scrolling" +msgstr "Figer au défilement" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:139 +msgid "workspace.options.constraints.left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:141 +msgid "workspace.options.constraints.leftright" +msgstr "Gauche et droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:140 +msgid "workspace.options.constraints.right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:143, src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:154 +msgid "workspace.options.constraints.scale" +msgstr "Redimensionner" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:150 +msgid "workspace.options.constraints.top" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:152 +msgid "workspace.options.constraints.topbottom" +msgstr "Haut et bas" + +#: src/app/main/ui/workspace/sidebar/options.cljs:197 +msgid "workspace.options.design" +msgstr "Design" + +#: src/app/main/ui/inspect/exports.cljs:140 +msgid "workspace.options.export" +msgstr "Exporter" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, src/app/main/ui/inspect/exports.cljs +#, unused +msgid "workspace.options.export-multiple" +msgstr "Exporter la sélection" + +#: src/app/main/ui/inspect/exports.cljs:196, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 +msgid "workspace.options.export-object" +msgid_plural "workspace.options.export-object" +msgstr[0] "Exporter 1 élément" +msgstr[1] "Exporter %s éléments" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:214 +msgid "workspace.options.export.add-export" +msgstr "Ajouter une exportation" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:226, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:261 +msgid "workspace.options.export.remove-export" +msgstr "Supprimer l'exportation" + +#: src/app/main/ui/inspect/exports.cljs:179, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:255 +msgid "workspace.options.export.suffix" +msgstr "Suffixe" + +#: src/app/main/ui/exports/assets.cljs:250 +msgid "workspace.options.exporting-complete" +msgstr "Exportation complétée" + +#: src/app/main/ui/exports/assets.cljs:171, src/app/main/ui/exports/assets.cljs:251, src/app/main/ui/inspect/exports.cljs:195, src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:272 +msgid "workspace.options.exporting-object" +msgstr "En cours d'exportation…" + +#: src/app/main/ui/exports/assets.cljs:249 +msgid "workspace.options.exporting-object-error" +msgstr "L'exportation a échoué" + +#: src/app/main/ui/exports/assets.cljs:252 +msgid "workspace.options.exporting-object-slow" +msgstr "Exportation inopinément lente" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:107, src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:236 +msgid "workspace.options.fill" +msgstr "Remplissage" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:208 +msgid "workspace.options.fill.add-fill" +msgstr "Ajouter un remplissage" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:223 +msgid "workspace.options.fill.remove-fill" +msgstr "Supprimer le remplissage" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:405 +msgid "workspace.options.fit-content" +msgstr "Redimensionner le tableau au contenu" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:704 +msgid "workspace.options.flows.add-flow-start" +msgstr "Ajouter un début de parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:700 +msgid "workspace.options.flows.flow" +msgstr "Parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:164 +msgid "workspace.options.flows.flow-start" +msgstr "Démarrer le parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:672 +msgid "workspace.options.flows.flow-starts" +msgstr "Départs de parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:155 +#, unused +msgid "workspace.options.flows.remove-flow" +msgstr "Supprimer le parcours" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:32 +msgid "workspace.options.grid.auto" +msgstr "Auto" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:163 +msgid "workspace.options.grid.column" +msgstr "Colonnes" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.grid-title" +msgstr "Grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:204, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:247 +msgid "workspace.options.grid.params.color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.columns" +msgstr "Colonnes" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:270 +msgid "workspace.options.grid.params.gutter" +msgstr "Gouttière" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:257 +msgid "workspace.options.grid.params.height" +msgstr "Hauteur" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:281 +msgid "workspace.options.grid.params.margin" +msgstr "Marge" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.rows" +msgstr "Rangées" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:226, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 +msgid "workspace.options.grid.params.set-default" +msgstr "Définir par défaut" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.size" +msgstr "Taille" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +#, unused +msgid "workspace.options.grid.params.type" +msgstr "Type" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:241 +msgid "workspace.options.grid.params.type.bottom" +msgstr "Bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:239 +msgid "workspace.options.grid.params.type.center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:238 +msgid "workspace.options.grid.params.type.left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:242 +msgid "workspace.options.grid.params.type.right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:235 +msgid "workspace.options.grid.params.type.stretch" +msgstr "Étirer" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:237 +msgid "workspace.options.grid.params.type.top" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:221, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:300 +msgid "workspace.options.grid.params.use-default" +msgstr "Utiliser la valeur par défaut" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:258 +msgid "workspace.options.grid.params.width" +msgstr "Largeur" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:164 +msgid "workspace.options.grid.row" +msgstr "Rangées" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:162 +msgid "workspace.options.grid.square" +msgstr "Carrée" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:106 +msgid "workspace.options.group-fill" +msgstr "Remplissage du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:45 +msgid "workspace.options.group-stroke" +msgstr "Contour du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:332 +msgid "workspace.options.guides.add-guide" +msgstr "Ajouter un guide" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:191 +msgid "workspace.options.guides.remove-guide" +msgstr "Supprimer le guide" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:329 +msgid "workspace.options.guides.title" +msgstr "Guides" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:187 +msgid "workspace.options.guides.toggle-guide" +msgstr "Basculer le guide" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:435, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:454 +msgid "workspace.options.height" +msgstr "Hauteur" + +#: src/app/main/ui/workspace/sidebar/options.cljs:201 +msgid "workspace.options.inspect" +msgstr "Inspection" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:438 +msgid "workspace.options.interaction-action" +msgstr "Action" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:43, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:344 +msgid "workspace.options.interaction-after-delay" +msgstr "Après un délai" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:578 +msgid "workspace.options.interaction-animation" +msgstr "Animation" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "Vers le bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "Entrante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Vers la gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "Sortante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Vers la droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "Vers le haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:383 +msgid "workspace.options.interaction-animation-dissolve" +msgstr "Fondu" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:382 +msgid "workspace.options.interaction-animation-none" +msgstr "Aucune" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:389 +msgid "workspace.options.interaction-animation-push" +msgstr "Pousser" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:384 +msgid "workspace.options.interaction-animation-slide" +msgstr "Glisser" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:368 +msgid "workspace.options.interaction-auto" +msgstr "automatique" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:568 +msgid "workspace.options.interaction-background" +msgstr "Ajouter un recouvrement d'arrière-plan" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:561 +msgid "workspace.options.interaction-close-outside" +msgstr "Fermer en cliquant à l'extérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:349 +msgid "workspace.options.interaction-close-overlay" +msgstr "Fermer le recouvrement" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:58 +msgid "workspace.options.interaction-close-overlay-dest" +msgstr "Fermer le recouvrement : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:426 +msgid "workspace.options.interaction-delay" +msgstr "Délai" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:449 +msgid "workspace.options.interaction-destination" +msgstr "Destination" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:628 +msgid "workspace.options.interaction-duration" +msgstr "Durée" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:641 +msgid "workspace.options.interaction-easing" +msgstr "Lissage de vitesse" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:393 +msgid "workspace.options.interaction-easing-ease" +msgstr "Accélération" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:394 +msgid "workspace.options.interaction-easing-ease-in" +msgstr "Accélération progressive" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:396 +msgid "workspace.options.interaction-easing-ease-in-out" +msgstr "Accélération progressive-dégressive" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:395 +msgid "workspace.options.interaction-easing-ease-out" +msgstr "Accélération dégressive" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:392 +msgid "workspace.options.interaction-easing-linear" +msgstr "Linéaire" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +#, unused +msgid "workspace.options.interaction-in" +msgstr "Entrante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:41, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:341 +msgid "workspace.options.interaction-mouse-enter" +msgstr "Survol de souris" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:42, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:342 +msgid "workspace.options.interaction-mouse-leave" +msgstr "Sortie de souris" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:430, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:632 +msgid "workspace.options.interaction-ms" +msgstr "ms" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:346 +msgid "workspace.options.interaction-navigate-to" +msgstr "Naviguer vers" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:52 +msgid "workspace.options.interaction-navigate-to-dest" +msgstr "Naviguer vers : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:53, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:55, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:57, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:357 +msgid "workspace.options.interaction-none" +msgstr "(non défini)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:654 +msgid "workspace.options.interaction-offset-effect" +msgstr "Effet de décalage" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:37, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:337 +msgid "workspace.options.interaction-on-click" +msgstr "Au clique" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:347 +msgid "workspace.options.interaction-open-overlay" +msgstr "Ouvrir en superposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:54 +msgid "workspace.options.interaction-open-overlay-dest" +msgstr "Ouvrir en superposition : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 +msgid "workspace.options.interaction-open-url" +msgstr "Ouvrir URL" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +#, unused +msgid "workspace.options.interaction-out" +msgstr "Sortante" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:380, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:554 +msgid "workspace.options.interaction-pos-bottom-center" +msgstr "En bas au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:378, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:538 +msgid "workspace.options.interaction-pos-bottom-left" +msgstr "En bas à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:379, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:546 +msgid "workspace.options.interaction-pos-bottom-right" +msgstr "En bas à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:374, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:506 +msgid "workspace.options.interaction-pos-center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:373 +msgid "workspace.options.interaction-pos-manual" +msgstr "Manuelle" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:377, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:530 +msgid "workspace.options.interaction-pos-top-center" +msgstr "En haut au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:375, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:514 +msgid "workspace.options.interaction-pos-top-left" +msgstr "En haut à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:376, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:522 +msgid "workspace.options.interaction-pos-top-right" +msgstr "En haut à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:492 +msgid "workspace.options.interaction-position" +msgstr "Position" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:460 +msgid "workspace.options.interaction-preserve-scroll" +msgstr "Maintenir le défilement" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:60, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:350 +msgid "workspace.options.interaction-prev-screen" +msgstr "Écran précédent" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:482 +msgid "workspace.options.interaction-relative-to" +msgstr "Relatif à" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:59, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:356, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:370, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:371 +msgid "workspace.options.interaction-self" +msgstr "soi-même" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:348 +msgid "workspace.options.interaction-toggle-overlay" +msgstr "Basculer la superposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:56 +msgid "workspace.options.interaction-toggle-overlay-dest" +msgstr "Basculer la superposition : %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:415 +msgid "workspace.options.interaction-trigger" +msgstr "Déclencheur" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:469 +msgid "workspace.options.interaction-url" +msgstr "URL" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:39, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:339 +msgid "workspace.options.interaction-while-hovering" +msgstr "Pendant le survol" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:40, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:340 +msgid "workspace.options.interaction-while-pressing" +msgstr "Pendant l'appui" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:743 +msgid "workspace.options.interactions" +msgstr "Interactions" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:746 +msgid "workspace.options.interactions.add-interaction" +msgstr "Ajouter une interaction" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +#, unused +msgid "workspace.options.interactions.remove-interaction" +msgstr "Supprimer l'interaction" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:197 +msgid "workspace.options.layer-options.blend-mode.color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:186 +msgid "workspace.options.layer-options.blend-mode.color-burn" +msgstr "Densité couleur +" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:189 +msgid "workspace.options.layer-options.blend-mode.color-dodge" +msgstr "Densité couleur -" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:184 +msgid "workspace.options.layer-options.blend-mode.darken" +msgstr "Obscurcir" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:193 +msgid "workspace.options.layer-options.blend-mode.difference" +msgstr "Différence" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:194 +msgid "workspace.options.layer-options.blend-mode.exclusion" +msgstr "Exclusion" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:192 +msgid "workspace.options.layer-options.blend-mode.hard-light" +msgstr "Lumière crue" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:195 +msgid "workspace.options.layer-options.blend-mode.hue" +msgstr "Teinte" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:187 +msgid "workspace.options.layer-options.blend-mode.lighten" +msgstr "Éclaircir" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:198 +msgid "workspace.options.layer-options.blend-mode.luminosity" +msgstr "Luminosité" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:185 +msgid "workspace.options.layer-options.blend-mode.multiply" +msgstr "Produit" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:183 +msgid "workspace.options.layer-options.blend-mode.normal" +msgstr "Normal" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:190 +msgid "workspace.options.layer-options.blend-mode.overlay" +msgstr "Incrustation" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:196 +msgid "workspace.options.layer-options.blend-mode.saturation" +msgstr "Saturation" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:188 +msgid "workspace.options.layer-options.blend-mode.screen" +msgstr "Superposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:191 +msgid "workspace.options.layer-options.blend-mode.soft-light" +msgstr "Lumière tamisée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +#, unused +msgid "workspace.options.layer-options.title" +msgstr "Calque" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +#, unused +msgid "workspace.options.layer-options.title.group" +msgstr "Calques du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +#, unused +msgid "workspace.options.layer-options.title.multiple" +msgstr "Calques sélectionnés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:255, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:261 +msgid "workspace.options.layer-options.toggle-layer" +msgstr "Basculer la visibilité du calque" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.advanced-ops" +msgstr "Options avancées" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 +msgid "workspace.options.layout-item.layout-item-max-h" +msgstr "Hauteur max." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 +msgid "workspace.options.layout-item.layout-item-max-w" +msgstr "Largeur max." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 +msgid "workspace.options.layout-item.layout-item-min-h" +msgstr "Hauteur min." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 +msgid "workspace.options.layout-item.layout-item-min-w" +msgstr "Largeur min." + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-max-h" +msgstr "Hauteur maximale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-max-w" +msgstr "Largeur maximale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-min-h" +msgstr "Hauteur minimale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout-item.title.layout-item-min-w" +msgstr "Largeur minimale" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.bottom" +msgstr "Bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.column" +msgstr "Colonne" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.column-reverse" +msgstr "Colonne inversée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.row" +msgstr "Rangée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.direction.row-reverse" +msgstr "Rangée inversée" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.gap" +msgstr "Gouttière" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.left" +msgstr "Gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout.margin" +msgstr "Marge" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout.margin-all" +msgstr "Tous côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +#, unused +msgid "workspace.options.layout.margin-simple" +msgstr "Marge simple" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.packed" +msgstr "compacté" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.padding" +msgstr "Marge interne" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.padding-all" +msgstr "Tous côtés" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.padding-simple" +msgstr "Marge interne simple" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.right" +msgstr "Droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.space-around" +msgstr "espace autour" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.space-between" +msgstr "espace entre" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +#, unused +msgid "workspace.options.layout.top" +msgstr "Haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:241 +msgid "workspace.options.more-colors" +msgstr "Plus de couleurs" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:223 +msgid "workspace.options.more-lib-colors" +msgstr "Plus de couleurs de bibliothèque" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "Plus de tokens de couleur" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:229, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:241 +msgid "workspace.options.opacity" +msgstr "Opacité" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "Horizontal" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "Vertical" + +#: src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +#, unused +msgid "workspace.options.position" +msgstr "Position" + +#: src/app/main/ui/workspace/sidebar/options.cljs:199 +msgid "workspace.options.prototype" +msgstr "Prototype" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:182, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:206 +msgid "workspace.options.radius" +msgstr "Rayon" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:271, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:323 +msgid "workspace.options.radius-bottom-left" +msgstr "En bas à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:290, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:331 +msgid "workspace.options.radius-bottom-right" +msgstr "En bas à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:234, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:307 +msgid "workspace.options.radius-top-left" +msgstr "En haut à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:253, src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:315 +msgid "workspace.options.radius-top-right" +msgstr "En haut à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:340 +msgid "workspace.options.radius.hide-all-corners" +msgstr "Réduire les rayons indépendants" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:341 +msgid "workspace.options.radius.show-single-corners" +msgstr "Afficher les rayons indépendants" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:191 +msgid "workspace.options.recent-fonts" +msgstr "Récentes" + +#: src/app/main/ui/exports/assets.cljs:298 +msgid "workspace.options.retry" +msgstr "Relancer" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:536, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:544 +msgid "workspace.options.rotation" +msgstr "Rotation" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:188 +msgid "workspace.options.search-font" +msgstr "Recherche de police" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:786 +msgid "workspace.options.select-a-shape" +msgstr "" +"Sélectionner une forme, un tableau ou un groupe pour ensuite glisser une " +"connexion à un autre tableau." + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:201 +msgid "workspace.options.selection-color" +msgstr "Couleurs de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:105 +msgid "workspace.options.selection-fill" +msgstr "Remplissage de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +msgid "workspace.options.selection-stroke" +msgstr "Contour de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:144 +msgid "workspace.options.shadow-options.add-shadow" +msgstr "Ajouter une ombre" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:47, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:181, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:183 +msgid "workspace.options.shadow-options.blur" +msgstr "Flou" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:211 +msgid "workspace.options.shadow-options.color" +msgstr "Couleur de l'ombre" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:122 +msgid "workspace.options.shadow-options.drop-shadow" +msgstr "Ombre portée" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:123 +msgid "workspace.options.shadow-options.inner-shadow" +msgstr "Ombre interne" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:45, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:172 +msgid "workspace.options.shadow-options.offsetx" +msgstr "X" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:46, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:201 +msgid "workspace.options.shadow-options.offsety" +msgstr "Y" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:157, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:161 +msgid "workspace.options.shadow-options.remove-shadow" +msgstr "Supprimer l'ombre" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:48, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:191, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:193 +msgid "workspace.options.shadow-options.spread" +msgstr "Étalement" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:139 +msgid "workspace.options.shadow-options.title" +msgstr "Ombre" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:138 +msgid "workspace.options.shadow-options.title.group" +msgstr "Ombre du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:137 +msgid "workspace.options.shadow-options.title.multiple" +msgstr "Ombre de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:157 +msgid "workspace.options.shadow-options.toggle-shadow" +msgstr "Basculer l'ombre" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:258 +msgid "workspace.options.show-fill-on-export" +msgstr "Afficher dans l'exportation" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:574 +msgid "workspace.options.show-in-viewer" +msgstr "Afficher en mode visionnement" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:168 +msgid "workspace.options.size" +msgstr "Taille" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:71, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:364 +msgid "workspace.options.size-presets" +msgstr "Tailles prédéfinies" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.lock" +msgstr "Verrouiller les proportions" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.unlock" +msgstr "Déverrouiller les proportions" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +#, unused +msgid "workspace.options.stroke" +msgstr "Contour" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.circle-marker" +msgstr "Marqueur en cercle" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:175 +msgid "workspace.options.stroke-cap.circle-marker-short" +msgstr "Cercle" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.diamond-marker" +msgstr "Marquer en diamant" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:176 +msgid "workspace.options.stroke-cap.diamond-marker-short" +msgstr "Diamant" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.line-arrow" +msgstr "Flèche de ligne" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:172 +msgid "workspace.options.stroke-cap.line-arrow-short" +msgstr "Flèche" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:170 +msgid "workspace.options.stroke-cap.none" +msgstr "Aucun" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:178 +msgid "workspace.options.stroke-cap.round" +msgstr "Arrondi" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:179 +msgid "workspace.options.stroke-cap.square" +msgstr "Carré" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.square-marker" +msgstr "Marqueur en carré" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:174 +msgid "workspace.options.stroke-cap.square-marker-short" +msgstr "Rectangle" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +#, unused +msgid "workspace.options.stroke-cap.triangle-arrow" +msgstr "Flèche en triangle" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:173 +msgid "workspace.options.stroke-cap.triangle-arrow-short" +msgstr "Triangle" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:210 +msgid "workspace.options.stroke-color" +msgstr "Couleur du contour" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:225, src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:230 +msgid "workspace.options.stroke-width" +msgstr "Largeur du contour" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:189 +msgid "workspace.options.stroke.add-stroke" +msgstr "Ajouter un contour" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:111 +msgid "workspace.options.stroke.center" +msgstr "Centre" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:139 +msgid "workspace.options.stroke.dashed" +msgstr "Tirets" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:138 +msgid "workspace.options.stroke.dotted" +msgstr "Pointillés" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:112 +msgid "workspace.options.stroke.inner" +msgstr "Intérieur" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:140 +msgid "workspace.options.stroke.mixed" +msgstr "Mixte" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:113 +msgid "workspace.options.stroke.outer" +msgstr "Extérieur" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:202 +msgid "workspace.options.stroke.remove-stroke" +msgstr "Supprimer le contour" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:137 +msgid "workspace.options.stroke.solid" +msgstr "Plein" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:127 +msgid "workspace.options.text-options.align-bottom" +msgstr "Aligner en bas" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:123 +msgid "workspace.options.text-options.align-middle" +msgstr "Aligner au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:119 +msgid "workspace.options.text-options.align-top" +msgstr "Aligner en haut" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:92 +msgid "workspace.options.text-options.direction-ltr" +msgstr "Gauche à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:96 +msgid "workspace.options.text-options.direction-rtl" +msgstr "Droite à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:165 +msgid "workspace.options.text-options.grow-auto-height" +msgstr "Hauteur automatique" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:161 +msgid "workspace.options.text-options.grow-auto-width" +msgstr "Largeur automatique" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:157 +msgid "workspace.options.text-options.grow-fixed" +msgstr "Fixe" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:400 +msgid "workspace.options.text-options.letter-spacing" +msgstr "Interlettrage" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:381 +msgid "workspace.options.text-options.line-height" +msgstr "Hauteur de ligne" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.lowercase" +msgstr "Minuscules" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.none" +msgstr "Aucune" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:192 +msgid "workspace.options.text-options.strikethrough" +msgstr "Barré (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:61 +msgid "workspace.options.text-options.text-align-center" +msgstr "Aligner au centre" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:69 +msgid "workspace.options.text-options.text-align-justify" +msgstr "Justifier" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:57 +msgid "workspace.options.text-options.text-align-left" +msgstr "Aligner à gauche" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:65 +msgid "workspace.options.text-options.text-align-right" +msgstr "Aligner à droite" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:205 +msgid "workspace.options.text-options.title" +msgstr "Texte" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:204 +msgid "workspace.options.text-options.title-group" +msgstr "Texte du groupe" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:203 +msgid "workspace.options.text-options.title-selection" +msgstr "Texte de la sélection" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.titlecase" +msgstr "Titré" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:188 +msgid "workspace.options.text-options.underline" +msgstr "Souligné (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +#, unused +msgid "workspace.options.text-options.uppercase" +msgstr "Majuscules" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788 +msgid "workspace.options.use-play-button" +msgstr "" +"Utiliser le bouton de lecture dans le menu du haut pour voir le prototype." + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:420, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:442 +msgid "workspace.options.width" +msgstr "Largeur" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:482, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:505 +msgid "workspace.options.x" +msgstr "Axe X" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:495, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:516 +msgid "workspace.options.y" +msgstr "Axe Y" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:140 +msgid "workspace.path.actions.add-node" +msgstr "Ajouter un nœud (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:148 +msgid "workspace.path.actions.delete-node" +msgstr "Supprimer les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:123 +msgid "workspace.path.actions.draw-nodes" +msgstr "Dessiner des nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:166 +msgid "workspace.path.actions.join-nodes" +msgstr "Connecter les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:184 +msgid "workspace.path.actions.make-corner" +msgstr "Convertir en coin (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:192 +msgid "workspace.path.actions.make-curve" +msgstr "Convertir en courbe (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:158 +msgid "workspace.path.actions.merge-nodes" +msgstr "Fusionner les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:131 +msgid "workspace.path.actions.move-nodes" +msgstr "Déplacer les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:174 +msgid "workspace.path.actions.separate-nodes" +msgstr "Séparer les nœuds (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:203 +msgid "workspace.path.actions.snap-nodes" +msgstr "S'accrocher aux nœuds (%s)" + +#: src/app/main/ui/workspace/plugins.cljs:85 +msgid "workspace.plugins.button-open" +msgstr "Ouvrir" + +#: src/app/main/ui/workspace/plugins.cljs:199 +#, markdown +msgid "workspace.plugins.discover" +msgstr "Découvrir [d'autres plugiciels](%s)" + +#: src/app/main/ui/workspace/plugins.cljs:206 +msgid "workspace.plugins.empty-plugins" +msgstr "Aucun plugiciel installé" + +#: src/app/main/ui/workspace/plugins.cljs:193 +msgid "workspace.plugins.error.manifest" +msgstr "Le manifeste du plugiciel est invalide." + +#: src/app/main/data/plugins.cljs:105, src/app/main/ui/workspace/main_menu.cljs:766, src/app/main/ui/workspace/plugins.cljs:84 +msgid "workspace.plugins.error.need-editor" +msgstr "Tu dois être un éditeur pour utiliser ce plugiciel" + +#: src/app/main/ui/workspace/plugins.cljs:189 +msgid "workspace.plugins.error.url" +msgstr "Le plugiciel n'existe pas ou l'URL est invalide." + +#: src/app/main/ui/workspace/plugins.cljs:185 +msgid "workspace.plugins.install" +msgstr "Installer" + +#: src/app/main/ui/workspace/plugins.cljs:215 +msgid "workspace.plugins.installed-plugins" +msgstr "Plugiciels installés" + +#: src/app/main/ui/workspace/main_menu.cljs:721 +msgid "workspace.plugins.menu.plugins-manager" +msgstr "Gestionnaire de plugiciels" + +#: src/app/main/ui/workspace/main_menu.cljs:918 +msgid "workspace.plugins.menu.title" +msgstr "Plugiciels" + +#: src/app/main/ui/workspace/plugins.cljs:376 +msgid "workspace.plugins.permissions-update.title" +msgstr "MISE À JOUR DU PLUGICIEL" + +#: src/app/main/ui/workspace/plugins.cljs:380 +msgid "workspace.plugins.permissions-update.warning" +msgstr "" +"Ce plugiciel a été modifié depuis sa dernière ouverture. Des accès " +"supplémentaires sont demandés :" + +#: src/app/main/ui/workspace/plugins.cljs:280 +msgid "workspace.plugins.permissions.allow-download" +msgstr "Démarrer des téléchargements." + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "Stocker des données dans le navigateur." + +#: src/app/main/ui/workspace/plugins.cljs:273 +msgid "workspace.plugins.permissions.comment-read" +msgstr "Lire tes commentaires et réponses." + +#: src/app/main/ui/workspace/plugins.cljs:267 +msgid "workspace.plugins.permissions.comment-write" +msgstr "Lire et modifier tes commentaires et répondre en ton nom." + +#: src/app/main/ui/workspace/plugins.cljs:240 +msgid "workspace.plugins.permissions.content-read" +msgstr "Lire le contenu de fichiers auxquels des usagers ont accès." + +#: src/app/main/ui/workspace/plugins.cljs:234 +msgid "workspace.plugins.permissions.content-write" +msgstr "Lire et modifier le contenu de fichiers auxquels des usagers ont accès." + +#: src/app/main/ui/workspace/plugins.cljs:327 +msgid "workspace.plugins.permissions.disclaimer" +msgstr "" +"Noter que ce plugiciel a été créé par une tierce partie. S'assurer d'y faire " +"confiance avant de permettre l'accès. La confidentialité des données est " +"importante pour nous. Merci de contacter le support en cas de question." + +#: src/app/main/ui/workspace/plugins.cljs:260 +msgid "workspace.plugins.permissions.library-read" +msgstr "Lire tes bibliothèques et atouts." + +#: src/app/main/ui/workspace/plugins.cljs:254 +msgid "workspace.plugins.permissions.library-write" +msgstr "Lire et modifier tes bibliothèques et atouts." + +#: src/app/main/ui/workspace/plugins.cljs:320 +msgid "workspace.plugins.permissions.title" +msgstr "LE PLUGICIEL « %s » DEMANDE D'ACCÉDER À :" + +#: src/app/main/ui/workspace/plugins.cljs:247 +msgid "workspace.plugins.permissions.user-read" +msgstr "Lire l'information de l'usager actuel." + +#: src/app/main/ui/workspace/plugins.cljs:211 +msgid "workspace.plugins.plugin-list-link" +msgstr "Liste des plugiciels" + +#: src/app/main/ui/workspace/plugins.cljs:88 +msgid "workspace.plugins.remove-plugin" +msgstr "Supprimer le plugiciel" + +#: src/app/main/ui/workspace/plugins.cljs:180 +msgid "workspace.plugins.search-placeholder" +msgstr "Inscrire l'URL d'un plugiciel" + +#, unused +msgid "workspace.plugins.success" +msgstr "Plugiciel chargé." + +#: src/app/main/ui/workspace/plugins.cljs:174 +msgid "workspace.plugins.title" +msgstr "Plugiciels" + +#: src/app/main/ui/workspace/plugins.cljs:440 +msgid "workspace.plugins.try-out.cancel" +msgstr "PAS MAINTENANT" + +#: src/app/main/ui/workspace/plugins.cljs:433 +msgid "workspace.plugins.try-out.message" +msgstr "" +"Tu veux jetter un coup d'œil ? Ça ouvrira un nouveau brouillon dans ton " +"équipe actuelle. (Autrement, tu peux accéder aux plugiciels installés dans " +"n'importe quel fichier.)" + +#: src/app/main/ui/workspace/plugins.cljs:429 +msgid "workspace.plugins.try-out.title" +msgstr "LE PLUGICIEL « %s » EST INSTALLÉ POUR TON USAGER!" + +#: src/app/main/ui/workspace/plugins.cljs:446 +msgid "workspace.plugins.try-out.try" +msgstr "ESSAYER LE PLUGICIEL" + +#: src/app/main/ui/workspace/context_menu.cljs:559 +msgid "workspace.shape.menu.add-flex" +msgstr "Ajouter une disposition flex" + +#: src/app/main/ui/workspace/context_menu.cljs:563 +msgid "workspace.shape.menu.add-grid" +msgstr "Ajouter une disposition en grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1248, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1272 +msgid "workspace.shape.menu.add-layout" +msgstr "Ajouter une disposition" + +#: src/app/main/ui/workspace/context_menu.cljs:612, src/app/main/ui/workspace/sidebar/assets/common.cljs:508, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1051, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1219, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1293 +msgid "workspace.shape.menu.add-variant" +msgstr "Créer un variant" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:512, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1073, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1221, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1307 +msgid "workspace.shape.menu.add-variant-property" +msgstr "Ajouter une propriété" + +#: src/app/main/ui/workspace/context_menu.cljs:282 +msgid "workspace.shape.menu.back" +msgstr "Envoyer à l'arrière-plan" + +#: src/app/main/ui/workspace/context_menu.cljs:279 +msgid "workspace.shape.menu.backward" +msgstr "Envoyer vers l'arrière" + +#: src/app/main/ui/workspace/context_menu.cljs:619, src/app/main/ui/workspace/sidebar/assets/components.cljs:633, src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "Combiner comme variants" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "Les composants doivent être sur la même page" + +#: src/app/main/ui/workspace/context_menu.cljs:200 +msgid "workspace.shape.menu.copy" +msgstr "Copier" + +#: src/app/main/ui/workspace/context_menu.cljs:218 +msgid "workspace.shape.menu.copy-css" +msgstr "Copier en CSS" + +#: src/app/main/ui/workspace/context_menu.cljs:220 +msgid "workspace.shape.menu.copy-css-nested" +msgstr "Copier en CSS (calques imbriqués)" + +#: src/app/main/ui/workspace/context_menu.cljs:203 +msgid "workspace.shape.menu.copy-link" +msgstr "Copier le lien" + +#: src/app/main/ui/workspace/context_menu.cljs:216 +msgid "workspace.shape.menu.copy-paste-as" +msgstr "Copier/coller en tant que ..." + +#: src/app/main/ui/workspace/context_menu.cljs:230 +msgid "workspace.shape.menu.copy-props" +msgstr "Copier les propriétés" + +#: src/app/main/ui/workspace/context_menu.cljs:222 +msgid "workspace.shape.menu.copy-svg" +msgstr "Copier en SVG" + +#: src/app/main/ui/workspace/context_menu.cljs:227 +msgid "workspace.shape.menu.copy-text" +msgstr "Copier en texte" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:484 +msgid "workspace.shape.menu.create-annotation" +msgstr "Créer une annotation" + +#: src/app/main/ui/workspace/context_menu.cljs:382 +msgid "workspace.shape.menu.create-artboard-from-selection" +msgstr "Entableauter la sélection" + +#: src/app/main/ui/workspace/context_menu.cljs:592 +msgid "workspace.shape.menu.create-component" +msgstr "Créer un composant" + +#: src/app/main/ui/workspace/context_menu.cljs:596 +msgid "workspace.shape.menu.create-multiple-components" +msgstr "Créer plusieurs composants" + +#: src/app/main/ui/workspace/context_menu.cljs:206 +msgid "workspace.shape.menu.cut" +msgstr "Couper" + +#: src/app/main/ui/workspace/context_menu.cljs:629, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1001, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1290 +msgid "workspace.shape.menu.delete" +msgstr "Supprimer" + +#: src/app/main/ui/workspace/context_menu.cljs:506 +msgid "workspace.shape.menu.delete-flow-start" +msgstr "Supprimer le début de parcours" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:489 +msgid "workspace.shape.menu.detach-instance" +msgstr "Détacher l'instance" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:488 +msgid "workspace.shape.menu.detach-instances-in-bulk" +msgstr "Détacher les instances" + +#: src/app/main/ui/workspace/context_menu.cljs:447, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:88 +msgid "workspace.shape.menu.difference" +msgstr "Différence" + +#: src/app/main/ui/workspace/context_menu.cljs:212 +msgid "workspace.shape.menu.duplicate" +msgstr "Dupliquer" + +#: src/app/main/ui/workspace/context_menu.cljs:432 +msgid "workspace.shape.menu.edit" +msgstr "Modifier" + +#: src/app/main/ui/workspace/context_menu.cljs:453, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:98 +msgid "workspace.shape.menu.exclude" +msgstr "Exclusion" + +#: src/app/main/ui/workspace/context_menu.cljs:437, src/app/main/ui/workspace/context_menu.cljs:460, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:104 +msgid "workspace.shape.menu.flatten" +msgstr "Aplatir" + +#: src/app/main/ui/workspace/context_menu.cljs:299 +msgid "workspace.shape.menu.flip-horizontal" +msgstr "Retourner horizontalement" + +#: src/app/main/ui/workspace/context_menu.cljs:295 +msgid "workspace.shape.menu.flip-vertical" +msgstr "Retourner verticalement" + +#: src/app/main/ui/workspace/context_menu.cljs:508 +msgid "workspace.shape.menu.flow-start" +msgstr "Début de parcours" + +#: src/app/main/ui/workspace/context_menu.cljs:273 +msgid "workspace.shape.menu.forward" +msgstr "Emmener vers l'avant" + +#: src/app/main/ui/workspace/context_menu.cljs:276 +msgid "workspace.shape.menu.front" +msgstr "Emmener à l'avant-plan" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/context_menu.cljs +#, unused +msgid "workspace.shape.menu.go-main" +msgstr "Aller au fichier du composant principal" + +#: src/app/main/ui/workspace/context_menu.cljs:368 +msgid "workspace.shape.menu.group" +msgstr "Grouper" + +#: src/app/main/ui/workspace/context_menu.cljs:477, src/app/main/ui/workspace/sidebar/layer_item.cljs:172 +msgid "workspace.shape.menu.hide" +msgstr "Masquer" + +#: src/app/main/ui/workspace/context_menu.cljs:706, src/app/main/ui/workspace/main_menu.cljs:448 +msgid "workspace.shape.menu.hide-ui" +msgstr "Afficher / cacher l'interface utilisateur" + +#: src/app/main/ui/workspace/context_menu.cljs:450, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:93 +msgid "workspace.shape.menu.intersection" +msgstr "Intersection" + +#: src/app/main/ui/workspace/context_menu.cljs:485, src/app/main/ui/workspace/sidebar/layer_item.cljs:180, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:268 +msgid "workspace.shape.menu.lock" +msgstr "Verrouiller" + +#: src/app/main/ui/workspace/context_menu.cljs:373 +msgid "workspace.shape.menu.mask" +msgstr "Utiliser comme masque" + +#: src/app/main/ui/workspace/context_menu.cljs:209, src/app/main/ui/workspace/context_menu.cljs:703 +msgid "workspace.shape.menu.paste" +msgstr "Coller" + +#: src/app/main/ui/workspace/context_menu.cljs:234 +msgid "workspace.shape.menu.paste-props" +msgstr "Coller les propriétés" + +#: src/app/main/ui/workspace/context_menu.cljs:443 +msgid "workspace.shape.menu.path" +msgstr "Opérations de chemin" + +#: src/app/main/ui/workspace/context_menu.cljs:549 +msgid "workspace.shape.menu.remove-flex" +msgstr "Supprimer la disposition flex" + +#: src/app/main/ui/workspace/context_menu.cljs:552 +msgid "workspace.shape.menu.remove-grid" +msgstr "Supprimer la disposition grille" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1266 +msgid "workspace.shape.menu.remove-layout" +msgstr "Supprimer la disposition" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1157 +msgid "workspace.shape.menu.remove-variant-property" +msgstr "Supprimer la propriété" + +#: src/app/main/ui/workspace/context_menu.cljs:329 +msgid "workspace.shape.menu.rename" +msgstr "Renommer" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:493 +msgid "workspace.shape.menu.reset-overrides" +msgstr "Annuler les modifications" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:499 +msgid "workspace.shape.menu.restore-main" +msgstr "Restaurer le composant principal" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:498 +msgid "workspace.shape.menu.restore-variant" +msgstr "Restaurer le variant" + +#: src/app/main/ui/workspace/context_menu.cljs:263 +msgid "workspace.shape.menu.select-layer" +msgstr "Sélectionner le calque" + +#: src/app/main/ui/workspace/context_menu.cljs:474, src/app/main/ui/workspace/sidebar/layer_item.cljs:171 +msgid "workspace.shape.menu.show" +msgstr "Afficher" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:481, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1217 +msgid "workspace.shape.menu.show-in-assets" +msgstr "Afficher dans le panneau d'atouts" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:502, src/app/main/ui/workspace/sidebar/assets/components.cljs:629 +msgid "workspace.shape.menu.show-main" +msgstr "Afficher le composant principal" + +#: src/app/main/ui/workspace/context_menu.cljs:314 +msgid "workspace.shape.menu.thumbnail-remove" +msgstr "Supprimer l'imagette" + +#: src/app/main/ui/workspace/context_menu.cljs:316 +msgid "workspace.shape.menu.thumbnail-set" +msgstr "Définir en tant qu'imagette" + +#: src/app/main/ui/workspace/context_menu.cljs:436 +#, unused +msgid "workspace.shape.menu.transform-to-path" +msgstr "Transformer en chemin" + +#: src/app/main/ui/workspace/context_menu.cljs:364 +msgid "workspace.shape.menu.ungroup" +msgstr "Dégroupper" + +#: src/app/main/ui/workspace/context_menu.cljs:444, src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:83 +msgid "workspace.shape.menu.union" +msgstr "Union" + +#: src/app/main/ui/workspace/context_menu.cljs:482, src/app/main/ui/workspace/sidebar/layer_item.cljs:179, src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:274 +msgid "workspace.shape.menu.unlock" +msgstr "Déverrouiller" + +#: src/app/main/ui/workspace/context_menu.cljs:378 +msgid "workspace.shape.menu.unmask" +msgstr "Retirer le masque" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/sidebar/options/menus/component.cljs, src/app/main/ui/workspace/context_menu.cljs, src/app/main/ui/workspace/context_menu.cljs +#, unused +msgid "workspace.shape.menu.update-components-in-bulk" +msgstr "Mettre à jour les composants" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:505 +msgid "workspace.shape.menu.update-main" +msgstr "Mettre à jour le composant principal" + +#: src/app/main/ui/components/tab_container.cljs:52, src/app/main/ui/workspace/sidebar.cljs:62 +msgid "workspace.sidebar.collapse" +msgstr "Réduire le panneau latéral" + +#: src/app/main/ui/workspace/sidebar.cljs:73, src/app/main/ui/workspace/sidebar.cljs:77 +msgid "workspace.sidebar.expand" +msgstr "Afficher le panneau latéral" + +#: src/app/main/ui/workspace/right_header.cljs:226 +msgid "workspace.sidebar.history" +msgstr "Historique" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:509, src/app/main/ui/workspace/sidebar.cljs:154, src/app/main/ui/workspace/sidebar.cljs:157, src/app/main/ui/workspace/sidebar.cljs:164 +msgid "workspace.sidebar.layers" +msgstr "Calques" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:313, src/app/main/ui/workspace/sidebar/layers.cljs:374 +msgid "workspace.sidebar.layers.components" +msgstr "Composants" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "Filtrer" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:310, src/app/main/ui/workspace/sidebar/layers.cljs:338 +msgid "workspace.sidebar.layers.frames" +msgstr "Tableaux" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:311, src/app/main/ui/workspace/sidebar/layers.cljs:350 +msgid "workspace.sidebar.layers.groups" +msgstr "Groupes" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:315, src/app/main/ui/workspace/sidebar/layers.cljs:398 +msgid "workspace.sidebar.layers.images" +msgstr "Images" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:312, src/app/main/ui/workspace/sidebar/layers.cljs:362 +msgid "workspace.sidebar.layers.masks" +msgstr "Masques" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:293 +msgid "workspace.sidebar.layers.search" +msgstr "Recherche de calques" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:316, src/app/main/ui/workspace/sidebar/layers.cljs:410 +msgid "workspace.sidebar.layers.shapes" +msgstr "Formes" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:314, src/app/main/ui/workspace/sidebar/layers.cljs:386 +msgid "workspace.sidebar.layers.texts" +msgstr "Textes" + +#: src/app/main/ui/inspect/attributes/svg.cljs:56, src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:101 +msgid "workspace.sidebar.options.svg-attrs.title" +msgstr "Attributs SVG importés" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:264 +msgid "workspace.sidebar.sitemap" +msgstr "Pages" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:274 +msgid "workspace.sidebar.sitemap.add-page" +msgstr "Ajouter une page" + +#: src/app/main/ui/workspace/left_header.cljs:98 +msgid "workspace.sitemap" +msgstr "Plan du site" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:86 +msgid "workspace.tokens.active-themes" +msgstr "%s thèmes actifs" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs +#, unused +msgid "workspace.tokens.add set" +msgstr "Ajouter un ensemble" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:151, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:347 +msgid "workspace.tokens.add-new-theme" +msgstr "Ajouter un nouveau thème" + +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:62 +msgid "workspace.tokens.add-set-to-group" +msgstr "Ajouter un ensemble à ce groupe" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:196, src/app/main/ui/workspace/tokens/management/group.cljs:156 +msgid "workspace.tokens.add-token" +msgstr "Ajouter un token : %s" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:137 +msgid "workspace.tokens.applied-to" +msgstr "Appliqué à" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:330 +msgid "workspace.tokens.axis" +msgstr "Axe" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:356 +msgid "workspace.tokens.back-to-themes" +msgstr "Retour à la liste des thèmes" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:89 +msgid "workspace.tokens.base-font-size" +msgstr "Taille de police de base" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:43 +msgid "workspace.tokens.base-font-size.error" +msgstr "" +"La taille de police de base doit être une valeur en pixels ou sans unité." + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:127 +#, unused +msgid "workspace.tokens.choose-file" +msgstr "Choisir un fichier" + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:132 +#, unused +msgid "workspace.tokens.choose-folder" +msgstr "Choisir un dossier" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:299, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:130 +msgid "workspace.tokens.color" +msgstr "Couleur" + +#: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 +msgid "workspace.tokens.composite-line-height-needs-font-size" +msgstr "" +"La hauteur de ligne dépend de la taille de police. Ajouter une taille de " +"police pour obtenir la valeur calculée." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 +msgid "workspace.tokens.create-new-theme" +msgstr "Crée le premier thème dès maintenant." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:96, src/app/main/ui/workspace/tokens/themes.cljs:44 +msgid "workspace.tokens.create-one" +msgstr "En créer un." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:235 +msgid "workspace.tokens.create-token" +msgstr "Créer un nouveau token %s" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:353 +msgid "workspace.tokens.delete" +msgstr "Supprimer le token" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:140 +msgid "workspace.tokens.delete-theme-title" +msgstr "Supprimer le thème" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:350 +msgid "workspace.tokens.duplicate" +msgstr "Dupliquer le token" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:240, src/app/main/data/workspace/tokens/library_edit.cljs:509 +msgid "workspace.tokens.duplicate-suffix" +msgstr "copier" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:337 +msgid "workspace.tokens.edit" +msgstr "Éditer le token" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:346 +msgid "workspace.tokens.edit-theme-title" +msgstr "Édition du thème" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:74 +msgid "workspace.tokens.edit-themes" +msgstr "Éditer les thèmes" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 +msgid "workspace.tokens.edit-token" +msgstr "Édition du token %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:41 +msgid "workspace.tokens.empty-input" +msgstr "La valeur du token ne peut être nulle" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 +msgid "workspace.tokens.enter-token-name" +msgstr "Entrer le nom du token %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:15 +msgid "workspace.tokens.error-parse" +msgstr "Erreur d'importation : impossible d'analyser le JSON." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:49 +msgid "workspace.tokens.export" +msgstr "Exporter" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:125 +msgid "workspace.tokens.export-tokens" +msgstr "Exporter les tokens" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:118 +msgid "workspace.tokens.export.multiple-files" +msgstr "Plusieurs fichiers" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:38 +msgid "workspace.tokens.export.no-tokens-themes-sets" +msgstr "Aucun token, thème ou ensemble à exporter." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:35 +msgid "workspace.tokens.export.preview" +msgstr "Aperçu :" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:116 +msgid "workspace.tokens.export.single-file" +msgstr "Un seul fichier" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "Taille de police ou {alias}" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "" +"Erreur de configuration de la graisse/style de la police. Ce style de police " +"n'existe pas pour la police courante" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:42, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:137 +msgid "workspace.tokens.font-weight-value-enter" +msgstr "Graisse de police (300, gras italique, etc.) ou un {alias}" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:240 +msgid "workspace.tokens.gaps" +msgstr "Gouttières" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +#, unused +msgid "workspace.tokens.generic-error" +msgstr "Erreur : " + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:93 +msgid "workspace.tokens.group-name" +msgstr "Nom du groupe" + +#: src/app/main/ui/workspace/tokens/sets.cljs +#, unused +msgid "workspace.tokens.grouping-set-alert" +msgstr "Le groupement des ensembles de tokens n'est pas encore supporté." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:233 +msgid "workspace.tokens.import-button-prefix" +msgstr "Importer %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 +msgid "workspace.tokens.import-error" +msgstr "Erreur d'importation :" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:273 +msgid "workspace.tokens.import-menu-folder-option" +msgstr "Dossier" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:271 +msgid "workspace.tokens.import-menu-json-option" +msgstr "Fichier JSON seul" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:272 +msgid "workspace.tokens.import-menu-zip-option" +msgstr "Fichier ZIP" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:241 +msgid "workspace.tokens.import-multiple-files" +msgstr "" +"En fichiers multiples. Le nom / chemin des fichiers seront les noms " +"d'ensembles." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:240 +msgid "workspace.tokens.import-single-file" +msgstr "" +"En un fichier JSON seul. Les clés de premier niveau doivent être les noms " +"des ensembles de tokens." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:237 +msgid "workspace.tokens.import-tokens" +msgstr "Importation de tokens" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:414, src/app/main/ui/workspace/tokens/sidebar.cljs:415 +#, unused +msgid "workspace.tokens.import-tooltip" +msgstr "" +"Importer un fichier JSON écrasera tous les tokens, ensembles et thèmes " +"actuels" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:247 +msgid "workspace.tokens.import-warning" +msgstr "Importer des tokens écrasera tous les tokens, ensembles et thèmes." + +#: src/app/main/ui/workspace/tokens/management.cljs:78 +msgid "workspace.tokens.inactive-set" +msgstr "Inactif" + +#: src/app/main/ui/workspace/tokens/management.cljs:70 +msgid "workspace.tokens.inactive-set-description" +msgstr "" +"Cet ensemble n'est pas actif. Change de thème ou active cet ensemble pour " +"voir les changements dans l'espace de travail" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:240, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:195 +msgid "workspace.tokens.individual-tokens" +msgstr "Tokens individuels" + +#: src/app/main/data/workspace/tokens/errors.cljs:49 +msgid "workspace.tokens.invalid-color" +msgstr "Valeur de couleur invalide : %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "" +"Valeur de token invalide :le token de référence doit être de type famille de " +"police de caractères" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"Valeur de graisse de police invalide : utiliser une valeur numérique (100-" +"950) ou des noms standards (mince, léger, régulier, gras, etc.), " +"optionnellement suivis par « italique »" + +#: src/app/main/data/workspace/tokens/errors.cljs:23 +msgid "workspace.tokens.invalid-json" +msgstr "Erreur d'importation : token invalide dans le JSON." + +#: src/app/main/data/workspace/tokens/errors.cljs:27 +msgid "workspace.tokens.invalid-json-token-name" +msgstr "Erreur d'importation : nom de token invalide dans le JSON." + +#: src/app/main/data/workspace/tokens/errors.cljs:28 +msgid "workspace.tokens.invalid-json-token-name-detail" +msgstr "" +"« %s » n'est pas un nom de token valide.\n" +"Les noms de tokens ne doivent contenir que des lettres et des chiffres " +"séparés par le caractère . et ne peuvent commencer par le symbole $." + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "" +"Type d'ombre invalide : seulement « innerShadow » et « dropShadow » sont " +"acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:81 +msgid "workspace.tokens.invalid-text-case-token-value" +msgstr "" +"Valeut de token invalide : seulement « none », « Uppercase », « Lowercase » " +"ou « Capitalize » sont acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:85 +msgid "workspace.tokens.invalid-text-decoration-token-value" +msgstr "" +"Valeur de token invalide : seulement « none », « underline » et « strike-" +"through » sont acceptés" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "Valeur invalide : doit référencer un token d'ombre composé." + +#: src/app/main/data/workspace/tokens/errors.cljs:97 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "Valeur invalide: doit référencer un token de typographie composé." + +#: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 +msgid "workspace.tokens.invalid-value" +msgstr "Valeur de token invalide : %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 +msgid "workspace.tokens.label.group" +msgstr "Groupe" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:207 +msgid "workspace.tokens.label.group-placeholder" +msgstr "Ajouter un groupe (ex. Mode)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:214 +msgid "workspace.tokens.label.theme" +msgstr "Thème" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:215 +msgid "workspace.tokens.label.theme-placeholder" +msgstr "Ajouter un thème (ex. Clair)" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "Interlettrage ou {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +msgid "workspace.tokens.line-height-value-enter" +msgstr "Hauteur de ligne (multiplicateur, px, %) ou {alias}" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:232 +msgid "workspace.tokens.margins" +msgstr "Marges" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:268 +msgid "workspace.tokens.max-size" +msgstr "Taille max." + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:262 +msgid "workspace.tokens.min-size" +msgstr "Taille min." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Référence manquante" + +#: src/app/main/data/workspace/tokens/errors.cljs:57 +msgid "workspace.tokens.missing-references" +msgstr "Références de token manquantes : " + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 +msgid "workspace.tokens.more-options" +msgstr "Clic droit pour voir les options" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 +msgid "workspace.tokens.no-active-sets" +msgstr "Aucun ensemble actif" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:91 +msgid "workspace.tokens.no-active-theme" +msgstr "Aucun thème actif" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:72 +msgid "workspace.tokens.no-permisions-set" +msgstr "Tu dois être un éditeur pour activer / désactiver des ensembles" + +#: src/app/main/ui/workspace/tokens/themes.cljs:54 +msgid "workspace.tokens.no-permission-themes" +msgstr "Tu dois être un éditeur pour utiliser les thèmes" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +#, unused +msgid "workspace.tokens.no-references-found" +msgstr "Aucune référence trouvée" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "" +"Ce token n'est pas utilisé dans le design. Il n'est pas nécessaire de mettre " +"à jour sa référence." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:485 +msgid "workspace.tokens.no-sets-create" +msgstr "Aucun ensemble n'est défini. Crée-en un pour les assigner à un thème." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:93, src/app/main/ui/workspace/tokens/sets/lists.cljs:99 +msgid "workspace.tokens.no-sets-yet" +msgstr "Aucun ensemble n'existe." + +#: src/app/main/ui/workspace/tokens/themes.cljs:40 +msgid "workspace.tokens.no-themes" +msgstr "Aucun thème n'existe." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:53 +msgid "workspace.tokens.no-themes-currently" +msgstr "Aucun thème n'existe actuellement." + +#: src/app/main/data/workspace/tokens/errors.cljs:19 +msgid "workspace.tokens.no-token-files-found" +msgstr "Aucun token, ensemble ou thème n'existe dans ce fichier." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Ne pas remapper" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 +msgid "workspace.tokens.num-active-sets" +msgstr "%s ensembles actifs" + +#: src/app/main/data/workspace/tokens/errors.cljs:53 +msgid "workspace.tokens.number-too-large" +msgstr "Valeur de token invalide. La valeur calculée est trop grande : %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 +msgid "workspace.tokens.opacity-range" +msgstr "L'opacité doit être entre 0% et 100% ou entre 0 et 1 (ex. 50% ou 0.5)." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 +msgid "workspace.tokens.original-value" +msgstr "Valeur d'origine : %s" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:216 +msgid "workspace.tokens.paddings" +msgstr "Marges intérieures" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:292 +msgid "workspace.tokens.radius" +msgstr "Rayon" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:129 +msgid "workspace.tokens.ref-not-valid" +msgstr "La référence est invalide ou n'existe dans aucun ensemble actif" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "Entrer un alias de token typographique" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Entrer un alias de token d'ombre" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +#, unused +msgid "workspace.tokens.reference-error" +msgstr "Erreurs de référence : " + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Remapper les tokens" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Remapper tous les tokens qui utilisent `%s` à `%s` ?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Cette action mettra à jour tous les calques et références qui utilisent " +"l'ancien nom du token." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Cette action peut durer longtemps." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "Remappage de références de token..." + +#: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 +msgid "workspace.tokens.resolved-value" +msgstr "Valeur calculée : %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:251 +msgid "workspace.tokens.save-theme" +msgstr "Sauvegarder le thème" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:204, src/app/main/ui/workspace/tokens/sets/lists.cljs:309 +msgid "workspace.tokens.select-set" +msgstr "Sélectionner l'ensemble." + +#: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 +msgid "workspace.tokens.self-reference" +msgstr "Le token s'auto-référence" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 +msgid "workspace.tokens.set-edit-placeholder" +msgstr "Entrer un nom (utiliser « / » pour les groupes)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:361 +msgid "workspace.tokens.set-selection-theme" +msgstr "Définir quels ensembles de tokens feront partie de ce thème :" + +#: src/app/main/ui/workspace/tokens/token_pill.cljs:47 +#, unused +msgid "workspace.tokens.set.not-active" +msgstr "L'ensemble de tokens n'est pas actif" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:129 +msgid "workspace.tokens.sets-hint" +msgstr "Éditer le thème et gérer les ensembles" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:91 +msgid "workspace.tokens.setting-description" +msgstr "" +"Configuration de la taille de police de base, qui définit la valeur de 1rem :" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:84 +msgid "workspace.tokens.settings" +msgstr "Paramètres des tokens" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Ajouter une ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:161, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:162, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:165 +msgid "workspace.tokens.shadow-blur" +msgstr "Flou" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "Le flou d'ombre doit être égal ou supérieur à 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +#, unused +msgid "workspace.tokens.shadow-color" +msgstr "Couleur" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:114 +msgid "workspace.tokens.shadow-inset" +msgstr "Position de l'ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Supprimer l'ombre" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "Étalement" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "L'étalement d'ombre doit être égal ou supérieur à 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +#, unused +msgid "workspace.tokens.shadow-title" +msgstr "Ombres" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "La valeur de flou ne peut être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "La valeur d'étalement ne peut être négative" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:256 +msgid "workspace.tokens.size" +msgstr "Taille" + +#: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 +msgid "workspace.tokens.stroke-width-range" +msgstr "L'épaisseur de trait doit être égale ou supérieure à 0." + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 +msgid "workspace.tokens.text-case-value-enter" +msgstr "none | uppercase | lowercase | capitalize ou {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 +msgid "workspace.tokens.text-decoration-value-enter" +msgstr "none | underline | strike-through ou {alias}" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 +#, unused +msgid "workspace.tokens.theme-name" +msgstr "Thème %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +#, unused +msgid "workspace.tokens.theme-name-already-exists" +msgstr "Un thème portant ce nom existe déjà" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "Désactiver" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "Activer" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:85 +msgid "workspace.tokens.themes-description" +msgstr "" +"Géstion des thèmes, activation / désactivation et configurer les ensembles " +"actifs." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:49, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:83 +msgid "workspace.tokens.themes-list" +msgstr "Liste des thèmes" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 +msgid "workspace.tokens.token-description" +msgstr "Description" + +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:122, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:246 +msgid "workspace.tokens.token-font-family-select" +msgstr "Sélectionner une famille de polices de caractères" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +msgid "workspace.tokens.token-font-family-value" +msgstr "Famille de polices" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "Famille de police ou liste de polices séparées par une virgule (,)" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:83, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:112, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:240 +msgid "workspace.tokens.token-name" +msgstr "Nom" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "Un token existe déjà au chemin : %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "Le nom doit être au moins 1 caractère" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:267, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:222 +msgid "workspace.tokens.token-name-validation-error" +msgstr "" +" n'est pas un nom de token valide.\n" +"Les noms de token ne doivent contenir que des lettres et des chiffres " +"séparés par des . et doivent pas commencer par le symbole $." + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs:259 +#, unused +msgid "workspace.tokens.token-not-resolved" +msgstr "Impossible de résoudre la référence de token portant le nom : %s" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:267 +msgid "workspace.tokens.token-value" +msgstr "Valeur" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:266, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:129 +msgid "workspace.tokens.token-value-enter" +msgstr "Entrer une valeur ou un alias avec {alias}" + +#: src/app/main/ui/workspace/tokens/management.cljs:67 +msgid "workspace.tokens.tokens-section-title" +msgstr "TOKENS - %s" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:122 +msgid "workspace.tokens.tools" +msgstr "Outils" + +#: src/app/main/data/workspace/tokens/import_export.cljs:46 +msgid "workspace.tokens.unknown-token-type-message" +msgstr "L'importation a réussi. Certains tokens n'ont pas été inclus." + +#: src/app/main/data/workspace/tokens/import_export.cljs:48 +msgid "workspace.tokens.unknown-token-type-section" +msgstr "Le type « %s » n'est pas supporté (%s)\n" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 +msgid "workspace.tokens.use-reference" +msgstr "Utiliser une référence" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:132 +msgid "workspace.tokens.value-not-valid" +msgstr "La valeur n'est pas valide" + +#: src/app/main/data/workspace/tokens/errors.cljs:69 +msgid "workspace.tokens.value-with-percent" +msgstr "Valeur invalide : % n'est pas permis." + +#: src/app/main/data/workspace/tokens/errors.cljs:65 +msgid "workspace.tokens.value-with-units" +msgstr "Valeur invalide : les unités ne sont pas permises." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "Renommer ce token brisera toute référence à son ancien nom" + +#: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 +msgid "workspace.toolbar.assets" +msgstr "Atouts" + +#: src/app/main/ui/workspace/palette.cljs:184 +msgid "workspace.toolbar.color-palette" +msgstr "Palette de couleurs (%s)" + +#: src/app/main/ui/workspace/right_header.cljs:217 +msgid "workspace.toolbar.comments" +msgstr "Commentaires (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:195 +msgid "workspace.toolbar.curve" +msgstr "Courbe (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Outils de débogage" + +#: src/app/main/ui/workspace/top_toolbar.cljs:172 +msgid "workspace.toolbar.ellipse" +msgstr "Ellipse (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:130 +msgid "workspace.toolbar.frame" +msgstr "Tableau (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:129 +msgid "workspace.toolbar.frame-first-time" +msgstr "Créer un tableau. Cliquer et glisser pour définir sa taille. (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:60 +msgid "workspace.toolbar.image" +msgstr "Image (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:143 +msgid "workspace.toolbar.move" +msgstr "Déplacer (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:205 +msgid "workspace.toolbar.path" +msgstr "Chemin (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:216 +msgid "workspace.toolbar.plugins" +msgstr "Plugiciels (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:162 +msgid "workspace.toolbar.rect" +msgstr "Rectangle (%s)" + +#: src/app/main/ui/workspace/left_toolbar.cljs +#, unused +msgid "workspace.toolbar.shortcuts" +msgstr "Raccourcis (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:182 +msgid "workspace.toolbar.text" +msgstr "Texte (%s)" + +#: src/app/main/ui/workspace/palette.cljs:190 +msgid "workspace.toolbar.text-palette" +msgstr "Typographies (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:235, src/app/main/ui/workspace/top_toolbar.cljs:236 +msgid "workspace.toolbar.toggle-toolbar" +msgstr "Basculer la barre d'outils" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:41 +msgid "workspace.top-bar.read-only.done" +msgstr "Terminé" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:37 +#, markdown +msgid "workspace.top-bar.view-only" +msgstr "**Inspection de code** (Lecture seule)" + +#: src/app/main/ui/workspace/sidebar/history.cljs:333 +msgid "workspace.undo.empty" +msgstr "Il n'y a aucun changement à l'historique" + +#: src/app/main/ui/workspace/sidebar/history.cljs:147 +msgid "workspace.undo.entry.delete" +msgstr "%s supprimé" + +#: src/app/main/ui/workspace/sidebar/history.cljs:146 +msgid "workspace.undo.entry.modify" +msgstr "%s modifié" + +#: src/app/main/ui/workspace/sidebar/history.cljs:148 +msgid "workspace.undo.entry.move" +msgstr "Objets déplacés" + +#: src/app/main/ui/workspace/sidebar/history.cljs:111 +msgid "workspace.undo.entry.multiple.circle" +msgstr "cercles" + +#: src/app/main/ui/workspace/sidebar/history.cljs:112 +msgid "workspace.undo.entry.multiple.color" +msgstr "couleurs" + +#: src/app/main/ui/workspace/sidebar/history.cljs:113 +msgid "workspace.undo.entry.multiple.component" +msgstr "composants" + +#: src/app/main/ui/workspace/sidebar/history.cljs:114 +msgid "workspace.undo.entry.multiple.curve" +msgstr "courbes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:115 +msgid "workspace.undo.entry.multiple.frame" +msgstr "tableau" + +#: src/app/main/ui/workspace/sidebar/history.cljs:116 +msgid "workspace.undo.entry.multiple.group" +msgstr "groupes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:117 +msgid "workspace.undo.entry.multiple.media" +msgstr "atouts graphiques" + +#: src/app/main/ui/workspace/sidebar/history.cljs:118 +msgid "workspace.undo.entry.multiple.multiple" +msgstr "objets" + +#: src/app/main/ui/workspace/sidebar/history.cljs:119 +msgid "workspace.undo.entry.multiple.page" +msgstr "pages" + +#: src/app/main/ui/workspace/sidebar/history.cljs:120 +msgid "workspace.undo.entry.multiple.path" +msgstr "chemins" + +#: src/app/main/ui/workspace/sidebar/history.cljs:121 +msgid "workspace.undo.entry.multiple.rect" +msgstr "rectangles" + +#: src/app/main/ui/workspace/sidebar/history.cljs:122 +msgid "workspace.undo.entry.multiple.shape" +msgstr "formes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:123 +msgid "workspace.undo.entry.multiple.text" +msgstr "textes" + +#: src/app/main/ui/workspace/sidebar/history.cljs:124 +msgid "workspace.undo.entry.multiple.typography" +msgstr "typographies" + +#: src/app/main/ui/workspace/sidebar/history.cljs:145 +msgid "workspace.undo.entry.new" +msgstr "Nouveau %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:125 +msgid "workspace.undo.entry.single.circle" +msgstr "cercle" + +#: src/app/main/ui/workspace/sidebar/history.cljs:126 +msgid "workspace.undo.entry.single.color" +msgstr "couleur" + +#: src/app/main/ui/workspace/sidebar/history.cljs:127 +msgid "workspace.undo.entry.single.component" +msgstr "composant" + +#: src/app/main/ui/workspace/sidebar/history.cljs:128 +msgid "workspace.undo.entry.single.curve" +msgstr "courbe" + +#: src/app/main/ui/workspace/sidebar/history.cljs:129 +msgid "workspace.undo.entry.single.frame" +msgstr "tableau" + +#: src/app/main/ui/workspace/sidebar/history.cljs:130 +msgid "workspace.undo.entry.single.group" +msgstr "groupe" + +#: src/app/main/ui/workspace/sidebar/history.cljs:131 +msgid "workspace.undo.entry.single.image" +msgstr "image" + +#: src/app/main/ui/workspace/sidebar/history.cljs:132 +msgid "workspace.undo.entry.single.media" +msgstr "atout graphique" + +#: src/app/main/ui/workspace/sidebar/history.cljs:133 +msgid "workspace.undo.entry.single.multiple" +msgstr "objet" + +#: src/app/main/ui/workspace/sidebar/history.cljs:134 +msgid "workspace.undo.entry.single.page" +msgstr "page" + +#: src/app/main/ui/workspace/sidebar/history.cljs:135 +msgid "workspace.undo.entry.single.path" +msgstr "chemin" + +#: src/app/main/ui/workspace/sidebar/history.cljs:136 +msgid "workspace.undo.entry.single.rect" +msgstr "rectangle" + +#: src/app/main/ui/workspace/sidebar/history.cljs:137 +msgid "workspace.undo.entry.single.shape" +msgstr "forme" + +#: src/app/main/ui/workspace/sidebar/history.cljs:138 +msgid "workspace.undo.entry.single.text" +msgstr "texte" + +#: src/app/main/ui/workspace/sidebar/history.cljs:139 +msgid "workspace.undo.entry.single.typography" +msgstr "typographie" + +#: src/app/main/ui/workspace/sidebar/history.cljs:149 +msgid "workspace.undo.entry.unknown" +msgstr "Opération sur %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:335 +#, unused +msgid "workspace.undo.title" +msgstr "Historique" + +#: src/app/main/data/workspace/libraries.cljs:1247, src/app/main/ui/workspace/sidebar/versions.cljs:85 +msgid "workspace.updates.dismiss" +msgstr "Ignorer" + +#: src/app/main/data/workspace/libraries.cljs:1245 +msgid "workspace.updates.more-info" +msgstr "Plus d'info" + +#: src/app/main/data/workspace/libraries.cljs:1243 +msgid "workspace.updates.there-are-updates" +msgstr "Mises à jour dans les bibliothèques partagées" + +#: src/app/main/data/workspace/libraries.cljs:1249 +msgid "workspace.updates.update" +msgstr "Mettre à jour" + +#: src/app/main/ui/ds/product/milestone_group.cljs:73 +msgid "workspace.versions.autosaved.entry" +msgstr "%s versions auto-sauvegardées" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:260 +msgid "workspace.versions.autosaved.version" +msgstr "Auto-sauvegardé %s" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:278 +msgid "workspace.versions.button.pin" +msgstr "Épingler cette version" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:273 +msgid "workspace.versions.button.restore" +msgstr "Restaurer cette version" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:396, src/app/main/ui/workspace/sidebar/versions.cljs:398 +msgid "workspace.versions.button.save" +msgstr "Enregistrer une version" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:405 +msgid "workspace.versions.empty" +msgstr "Aucune version" + +#: src/app/main/ui/ds/product/milestone_group.cljs:67 +msgid "workspace.versions.expand-snapshot" +msgstr "Afficher les versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:369 +msgid "workspace.versions.filter.all" +msgstr "Toutes les versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:383 +msgid "workspace.versions.filter.label" +msgstr "Filtrer les versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:370 +msgid "workspace.versions.filter.mine" +msgstr "Mes versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:374 +msgid "workspace.versions.filter.user" +msgstr "Versions de %s" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:391 +msgid "workspace.versions.loading" +msgstr "Chargement..." + +#, unused +msgid "workspace.versions.locked-by-other" +msgstr "Cette version a été verouillée par %s et ne peut être modifiée" + +#, unused +msgid "workspace.versions.locked-by-you" +msgstr "Cette version a été verouillée par toi" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:83 +msgid "workspace.versions.restore-warning" +msgstr "Restaurer cette version?" + +#, unused +msgid "workspace.versions.snapshot-menu" +msgstr "Ouvrir le menu des versions" + +#: src/app/main/ui/workspace/sidebar.cljs:257 +msgid "workspace.versions.tab.actions" +msgstr "Actions" + +#: src/app/main/ui/workspace/sidebar.cljs:255 +msgid "workspace.versions.tab.history" +msgstr "Historique" + +#, unused +msgid "workspace.versions.tooltip.locked-version" +msgstr "Version verrouillée - seule le créateur peut la modifier" + +#: src/app/main/ui/ds/product/milestone.cljs:84, src/app/main/ui/ds/product/milestone_group.cljs:86 +msgid "workspace.versions.version-menu" +msgstr "Ouvrir le menu des versions" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:60 +#, markdown +msgid "workspace.versions.warning.subtext" +msgstr "" +"Si tu aimerais augmenter cette limite, nous écrire au [support@penpot.app]" +"(%s)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:431 +msgid "workspace.versions.warning.text" +msgstr "Les versions auto-sauvegardées seront conservées pendant %s jours." + +#, unused +msgid "workspace.viewport.click-to-close-path" +msgstr "Cliquer pour fermer le chemin" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1156 +msgid "workspace.shape.menu.remove-variant-property.last-property" +msgstr "Le variant doit avoir au moins une propriété" diff --git a/frontend/translations/ha.po b/frontend/translations/ha.po index ef5bec5a9c..19e377aa3d 100644 --- a/frontend/translations/ha.po +++ b/frontend/translations/ha.po @@ -2269,7 +2269,7 @@ msgstr "sabunta sashe a babbar taska" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "akwai sabon yayi, fatan za a sabunta fage" +msgstr "akwai sabon yayi." #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" diff --git a/frontend/translations/he.po b/frontend/translations/he.po index d7fe28a169..6f6d5cfe57 100644 --- a/frontend/translations/he.po +++ b/frontend/translations/he.po @@ -1,16 +1,16 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: Hebrew " -"\n" +"PO-Revision-Date: 2026-02-20 03:09+0000\n" +"Last-Translator: Yaron Shahrabani \n" +"Language-Team: Hebrew \n" "Language: he\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=4; plural=(n == 1) ? 0 : ((n == 2) ? 1 : ((n > 10 && " "n % 10 == 0) ? 2 : 3));\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -194,7 +194,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "…מיתוג, איורים, חומרים שיווקיים ועוד." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "האסימון הזה לא קיים או שנמחק." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -409,7 +409,7 @@ msgstr "הוספת קובץ" #: src/app/main/ui/dashboard/file_menu.cljs:322, src/app/main/ui/workspace/main_menu.cljs:650 msgid "dashboard.add-shared" -msgstr "הוספת ספריה משותפת" +msgstr "הוספת ספרייה משותפת" #: src/app/main/ui/settings/profile.cljs:75 msgid "dashboard.change-email" @@ -1182,7 +1182,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "פתיחת רשימת אסימונים" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "ניתוק אסימון" #: src/app/main/data/auth.cljs:339 @@ -1898,7 +1898,6 @@ msgid "inspect.tabs.styles.active-themes" msgstr "ערכות עיצוב פעילות" #: src/app/main/ui/inspect/styles/style_box.cljs:68 -#, fuzzy msgid "inspect.tabs.styles.copy-shorthand" msgstr "העתקת קיצור CSS ללוח הגזירים" @@ -3465,10 +3464,6 @@ msgstr "כדי לגשת למיזם הזה, אפשר לבקש מבעלי הצוו msgid "notifications.by-code.maintenance" msgstr "הפסקת תחזוקה: המערכת תושבת לעבודת תחזוקה קצרה תוך 5 דקות." -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "יש גרסה חדשה, נא לרענן את העמוד" - #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" msgstr "ההזמנה נמחקה בהצלחה" @@ -4777,15 +4772,14 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "תודה על הבחירה בתוכנית %s של Penpot!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "מאחלים לך הנאה מהתוכנית שלך!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "התוכנית שלך היא %s!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" msgstr "תמכת בנו עם התוכנית הזאת מאז: %s" @@ -6448,19 +6442,19 @@ msgstr "אפשרויות מתקדמות" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 msgid "workspace.options.layout-item.layout-item-max-h" -msgstr "גובה מר.‏" +msgstr "גובה מרבי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 msgid "workspace.options.layout-item.layout-item-max-w" -msgstr "רוחב מר.‏" +msgstr "רוחב מרבי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 msgid "workspace.options.layout-item.layout-item-min-h" -msgstr "גובה מז.‏" +msgstr "גובה מזערי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 msgid "workspace.options.layout-item.layout-item-min-w" -msgstr "רוחב מז.‏" +msgstr "רוחב מזערי" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused @@ -7520,7 +7514,7 @@ msgid "workspace.tokens.color" msgstr "צבע" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "גובה השורה תלוי בגודל הגופן. יש להוסיף גודל גופן כדי לקבל את הערך הפתור." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 @@ -7568,7 +7562,7 @@ msgid "workspace.tokens.edit-token" msgstr "עריכת אסימון %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "ערך האסימון לא יכול להישאר ריק" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7576,7 +7570,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "נא למלא את שם האסימון %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "שגיאת ייבוא: לא ניתן לפענח JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7638,7 +7632,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "ייבוא %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "שגיאת ייבוא:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7689,57 +7683,57 @@ msgid "workspace.tokens.individual-tokens" msgstr "להשתמש באסימונים עצמאיים" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "ערך צבע שגוי: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "ערך אסימון שגוי: אפשר להפנות לאסימון font-family (משפחת גופנים) בלבד" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "ערך משקל גופן שגוי: יש להשתמש בערכים מספריים (100-‏950) או שמות תקניים " "(thin,‏ light,‏ regular,‏ bold ועוד), אפשר גם לצרף בסוף ‚Italic’ (נטוי) " "במקרה הצורך" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "שגיאת ייבוא: נתוני אסימון שגויים ב־JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "שגיאת ייבוא: שם אסימון שגוי ב־JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "„%s” אינו שם תקף לאסימון.\n" "שמות האסימונים יכולים להכיל אותיות וספרות מופרדים בתווי . ואסור שיתחילו " "בדולר ($)." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "סוג הצללה שגוי: רק ‚innerShadow’ או ‚dropShadow’ מורשים" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "ערך אסימון שגוי: רק none,‏ Uppercase,‏ Lowercase או Capitalize מורשים" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "ערך אסימון שגוי: מותר רק none,‏ underline ו־strike-through" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "ערך שגוי: יש להפנות לאסימון הצללה מורכבת." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "ערך שגוי: חייב להפנות לאסימון טיפוגרפיה מרוכב." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "ערך אסימון שגוי: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7779,7 +7773,7 @@ msgid "workspace.tokens.min-size" msgstr "גודל מזערי" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "חסרות הפניות אסימונים: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7819,7 +7813,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "אין לך ערכות עיצוב עדיין." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "לא נמצאו אסימונים, סדרות או ערכות עיצוב בקובץ הזה." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7827,15 +7821,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s סדרות פעילות" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "ערך אסימון שגוי. הערך הפתור גדול מדי: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "שקיפות צריכה להיות בין 0 ל־100% או 0 ו־1 (כלומר 50% או 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "ערך מקורי: %s" @@ -7865,7 +7858,6 @@ msgid "workspace.tokens.reference-error" msgstr "שגיאות הפניה: " #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "ערך פתור: %s" @@ -7878,7 +7870,7 @@ msgid "workspace.tokens.select-set" msgstr "בחירה ערכה." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "לאסימון יש הפניה עצמית" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7915,7 +7907,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "טשטוש" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "טשטוש הצל חייב להיות גדול או שווה ל־0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -7936,7 +7928,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "התפרסות" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "התפרסות ההצללה חייב להיות גדולה או שווה ל־0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -7957,7 +7949,7 @@ msgid "workspace.tokens.size" msgstr "גודל" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "עובי הקו חייב להיות גדול או שווה ל־0." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 @@ -7981,7 +7973,6 @@ msgid "workspace.tokens.themes-list" msgstr "רשימת ערכות עיצוב" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "תיאור" @@ -8001,10 +7992,6 @@ msgstr "משפחת גופנים או רשימת גופנים מופרדת בפס msgid "workspace.tokens.token-name" msgstr "שם" -#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 -msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "כבר קיים אסימון בנתיב: %s" - #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" msgstr "אורך השם חייב להיות תו אחד לפחות" @@ -8037,13 +8024,13 @@ msgstr "אסימונים - %s" msgid "workspace.tokens.tools" msgstr "כלים" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "הייבוא הצליח. חלק מהאסימונים לא נכללו." +msgstr "הייבוא הצליח, אך חלק מהאסימונים דולגו כי הם משתמשים בערכי $type שאינם נתמכים. הרחב את הפרטים כדי לראות אילו אסימונים הושפעו." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "הסוג ‚%s’ לא נתמך (%s)\n" +msgstr "הסוג ‚%s’ לא נתמך (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8054,11 +8041,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "הערך לא תקף" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "ערך שגוי: אסור %." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "ערך שגוי: אסור יחידות." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8388,3 +8375,323 @@ msgstr "גרסאות שנשמרו אוטומטית תישמרנה למשך %s י #, unused msgid "workspace.viewport.click-to-close-path" msgstr "לחיצה תסגור את הנתיב" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "יצירת ארגון חדש" + +#: src/app/main/ui/dashboard/deleted.cljs:262 +msgid "dashboard.delete-all-forever-confirmation.description" +msgstr "למחוק את כל המיזמים והקבצים שלך לצמיתות? זאת פעולה בלתי הפיכה." + +#: src/app/main/ui/dashboard/file_menu.cljs:221 +msgid "dashboard.delete-file-forever-confirmation.description" +msgstr "למחוק את %s לצמיתות? זאת פעולה בלתי הפיכה." + +#: src/app/main/data/dashboard.cljs:778 +msgid "dashboard.delete-files-success-notification" +msgstr "%s קבצים נמחקו בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +msgid "dashboard.delete-forever-confirmation.title" +msgstr "מחיקה לצמיתות" + +#: src/app/main/ui/dashboard/deleted.cljs:85 +msgid "dashboard.delete-project-button" +msgstr "מחיקת מיזם" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"למחוק את המיזם %s לצמיתות? הפעולה הזאת תמחק לצמיתות את כל הקבצים שבו. זאת " +"פעולה בלתי הפיכה." + +#: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 +msgid "dashboard.delete-success-notification" +msgstr "%s נמחק בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:327 +msgid "dashboard.deleted.empty-state-description" +msgstr "האשפה שלך ריקה. קבצים ומיזמים שנמחקו יופיעו כאן." + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "%s יימחק" + +#, unused +msgid "dashboard.errors.error-on-delete-file" +msgstr "אירעה שגיאה במחיקת הקובץ %s." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "אירעה שגיאה במחיקת הקבצים." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "אירעה שגיאה במחיקת המיזם %s." + +#: src/app/main/data/dashboard.cljs:909, src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "אירעה שגיאה בשחזור הקובץ %s." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "אירעה שגיאה בשחזור הקבצים." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "אירעה שגיאה בשחזור המיזם %s עם הקבצים שלו." + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "מחיקת קובץ" +msgstr[1] "מחיקת קבצים" +msgstr[2] "מחיקת קבצים" +msgstr[3] "מחיקת קבצים" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "שחזור קובץ" +msgstr[1] "שחזור קבצים" +msgstr[2] "שחזור קבצים" +msgstr[3] "שחזור קבצים" + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "קבצים נמחקים…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "קבצים משוחזרים…" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "המחיקה איטית להחריד" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "השחזור איטי להחריד" + +#: src/app/main/ui/dashboard/deleted.cljs:274 +msgid "dashboard.restore-all-confirmation.description" +msgstr "" +"הפעולה הזאת תשחזר את כל המיזמים והקבצים שלך. זאת פעולה שיכולה לארוך זמן מה." + +#: src/app/main/ui/dashboard/deleted.cljs:273 +msgid "dashboard.restore-all-confirmation.title" +msgstr "שחזור כל המיזמים והקבצים" + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "לשחזר הכול" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "%s קבצים שוחזרו בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "שחזור מיזם" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "גובה קבוע" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "רוחב קבוע" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "גובה 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "רוחב 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "למטה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "פנימה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "שמאלה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "החוצה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "ימינה" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "למעלה" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "אופקי" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "אנכי" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "מסנן" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "הפניה חסרה" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +#, unused +msgid "workspace.tokens.no-references-found" +msgstr "לא נמצאו הפניות" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "האסימון (token) הזה לא בשימוש בעיצוב שלך, לא צריך מיפוי מחדש." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "לא למפות מחדש" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "מיפוי אסימונים מחדש" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "הפעולה הזאת תשחזר את המיזם %s ואת כל הקבצים שבו." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "שחזור מיזם" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s שוחזר בהצלחה." + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "קבצים שנמחקו יישארו באשפה למשך" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s ימים. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "לאחר מכן, הם יימחקו לצמיתות." + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "" +"אם התחרטת, אפשר לשחזר אותם או למחוק אותם לצמיתות מהתפריט של כל אחד מהקבצים." + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "שגיאה מפתיעה: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL הפסיק לעבוד. נא לרענן את העמוד כדי לאפס אותו" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "אופס! הקשר לוח הציור אבד" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "הורדת קבצים" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "נמחקה" + +#: src/app/main/ui/dashboard/deleted.cljs:208 +msgid "labels.recent" +msgstr "אחרונות" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "ריענון העמוד" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "עריכה במרחב העבודה" + +#: src/app/main/ui/workspace/colorpicker.cljs:434 +msgid "workspace.colorpicker.get-color" +msgstr "משיכת צבע" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "כלי ניפוי שגיאות" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "הצגת אפשרויות גבול מ־4 צדדים" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "התאמת תוכן (אופקית)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "התאמת תוכן (אנכית)" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "למפות את כל האסימונים שמשתמשים ב־`%s` ל־`%s`?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"הפעולה הזאת תשנה את כל השכבות וההפניות שמשתמשות בשם הישן של האסימון (token)." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "הפעולה הזאת עלולה לארוך זמן מה." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "הפניות האסימון (token) ממופות מחדש…" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "ערך הטשטוש לא יכול להיות שלילי" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "ערך הפריסה לא יכול להיות שלילי" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "כיבוי" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "הפעלה" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "שינוי השם של האסימון (token) הזה יפגום בהפניות לשם הישן שלו" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "כלי ניפוי שגיאות" diff --git a/frontend/translations/hi.po b/frontend/translations/hi.po index 78d73a4605..a13ef70427 100644 --- a/frontend/translations/hi.po +++ b/frontend/translations/hi.po @@ -200,7 +200,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "...ब्रांडिंग, चित्रण, मार्केटिंग सामग्री आदि।" #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "यह token मौजूद नहीं है या हटा दिया गया है।" #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1253,7 +1253,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "token सूची खोलें" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "token अलग करें" #: src/app/main/data/auth.cljs:339 @@ -3578,10 +3578,6 @@ msgstr "इस परियोजना तक पहुँचने के ल msgid "notifications.by-code.maintenance" msgstr "रखरखाव विराम: हम 5 मिनट के भीतर एक छोटे रखरखाव के लिए बंद रहेंगे।" -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "एक नया संस्करण उपलब्ध है, कृपया पृष्ठ को रिफ्रेश करें" - #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" msgstr "आमंत्रण सफलतापूर्वक हटा दिया गया" @@ -4908,11 +4904,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "पेनपोट %s योजना चुनने के लिए धन्यवाद!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "अपनी योजना का आनंद लें!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "आप %s हैं!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7655,7 +7651,7 @@ msgid "workspace.tokens.color" msgstr "रंग" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "पंक्ति की ऊँचाई फ़ॉन्ट आकार पर निर्भर करती है। हल किया गया मान प्राप्त करने " "के लिए फ़ॉन्ट आकार जोड़ें।" @@ -7705,7 +7701,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s token संपादित करें" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "टोकन मान रिक्त नहीं हो सकता" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7713,7 +7709,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "%s टोकन नाम दर्ज करें" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "आयात त्रुटि: JSON को पार्स नहीं किया जा सका।" #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7777,7 +7773,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "%s आयात करें" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "आयात त्रुटि:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7828,60 +7824,60 @@ msgid "workspace.tokens.individual-tokens" msgstr "व्यक्तिगत टोकन का प्रयोग करें" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "अमान्य रंग मान: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Invalid token value: आप केवल फ़ॉन्ट-परिवार token का संदर्भ ले सकते हैं" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "अमान्य फ़ॉन्ट भार मान: संख्यात्मक मान (100-950) या मानक नाम (पतला, हल्का, " "नियमित, बोल्ड, आदि) का उपयोग करें, वैकल्पिक रूप से उसके बाद 'इटैलिक' लिखें" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "आयात त्रुटि: JSON में अमान्य टोकन डेटा।" #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "आयात त्रुटि: JSON में अमान्य टोकन नाम।" #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" एक मान्य टोकन नाम नहीं है।\n" "टोकन नामों में केवल अक्षर और अंक होने चाहिए, जो डॉट (.) से अलग किए गए हों, " "और $ चिह्न से शुरू नहीं होने चाहिए।" #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "अमान्य छाया प्रकार: केवल 'innerShadow' या 'dropShadow' स्वीकार किए जाते हैं" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "अमान्य token मान: केवल कोई नहीं, अपरकेस, लोअरकेस या कैपिटलाइज़ स्वीकार किए " "जाते हैं" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "अमान्य token मान: केवल कोई नहीं, रेखांकित और स्ट्राइक-थ्रू स्वीकार किए जाते " "हैं" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "अमान्य मूल्य: एक समग्र छाया टोकन का संदर्भ देना चाहिए।।" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "अमान्य मान: एक संयुक्त टाइपोग्राफी token का संदर्भ होना चाहिए।" #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "अमान्य टोकन मान: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7921,7 +7917,7 @@ msgid "workspace.tokens.min-size" msgstr "न्यूनतम. आकार" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "गुम टोकन संदर्भ: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7961,7 +7957,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "आपके पास वर्तमान में कोई थीम नहीं है।" #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "इस फ़ाइल में कोई टोकन, सेट या थीम नहीं मिला।" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7969,11 +7965,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s सक्रिय सेट" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "अमान्य टोकन मान. हल किया गया मान बहुत बड़ा है: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "अपारदर्शिता 0 और 100% या 0 और 1 (जैसे 50% या 0.5) के बीच होनी चाहिए।" #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -8020,7 +8016,7 @@ msgid "workspace.tokens.select-set" msgstr "सेट का चयन करें।" #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "टोकन में स्व-संदर्भ होता है" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8061,7 +8057,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "धुंधला" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "छाया धुंधलापन 0 से अधिक या उसके बराबर होना चाहिए।" #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8082,7 +8078,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "फैलाना" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "छाया प्रसार 0 से अधिक या उसके बराबर होना चाहिए।" #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8103,7 +8099,7 @@ msgid "workspace.tokens.size" msgstr "आकार" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "स्ट्रोक की चौड़ाई 0 से बड़ी या उसके बराबर होनी चाहिए।" #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8155,10 +8151,6 @@ msgstr "फ़ॉन्ट परिवार या अल्पविराम msgid "workspace.tokens.token-name" msgstr "नाम" -#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 -msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "पथ पर एक token पहले से मौजूद है: %s" - #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" msgstr "नाम कम से कम 1 अक्षर का होना चाहिए" @@ -8191,13 +8183,13 @@ msgstr "टोकन - %s" msgid "workspace.tokens.tools" msgstr "औजार" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "आयात सफल रहा। कुछ टोकन शामिल नहीं किए गए।" +msgstr "आयात सफल रहा, लेकिन कुछ टोकन छोड़ दिए गए क्योंकि वे असमर्थित $type मानों का उपयोग करते हैं। प्रभावित टोकन देखने के लिए विवरण विस्तृत करें।" -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "टाइप '%s' समर्थित नहीं है (%s)\n" +msgstr "टाइप '%s' समर्थित नहीं है (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8208,11 +8200,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "मान मान्य नहीं है" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "अमान्य मान: % की अनुमति नहीं है।" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "अमान्य मान: इकाइयाँ अनुमति नहीं हैं।" #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/hr.po b/frontend/translations/hr.po index c9e39a10a3..3420ac275f 100644 --- a/frontend/translations/hr.po +++ b/frontend/translations/hr.po @@ -2913,7 +2913,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Dostupna je nova verzija, molimo osvježite stranicu" +msgstr "Dostupna je nova verzija." #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" diff --git a/frontend/translations/id.po b/frontend/translations/id.po index 199ff13314..3c1d278095 100644 --- a/frontend/translations/id.po +++ b/frontend/translations/id.po @@ -3088,7 +3088,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Versi baru sudah tersedia, silakan muat ulang laman" +msgstr "Versi baru sudah tersedia." #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" @@ -6620,7 +6620,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Masukkan nama token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Kesalahan Pengimporan: Tidak dapat mengurai JSON." #: src/app/main/ui/workspace/tokens/management/context_menu.cljs:240 @@ -6642,7 +6642,7 @@ msgid "workspace.tokens.grouping-set-alert" msgstr "Pengelompokan Set Token belum didukung." #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Kesalahan Pengimporan:" #: src/app/main/ui/workspace/tokens/sidebar.cljs:414, src/app/main/ui/workspace/tokens/sidebar.cljs:415 @@ -6651,15 +6651,15 @@ msgid "workspace.tokens.import-tooltip" msgstr "Mengimpor berkas JSON akan menimpa semua token, set, dan tema Anda saat ini" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Nilai warna tidak valid: %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Kesalahan pengimporan: Data token tidak valid dalam JSON." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Nilai token tidak valid: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -6691,7 +6691,7 @@ msgid "workspace.tokens.min-size" msgstr "Ukuran minimal" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Referensi token hilang: " #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 @@ -6731,11 +6731,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s set aktif" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Nilai token tidak valid. Nilai terurai terlalu besar: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opasitas harus antara 0 dan 100% atau 0 dan 1 (mis. 50% atau 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -6774,7 +6774,7 @@ msgid "workspace.tokens.select-set" msgstr "Pilih set." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token memiliki referensi diri" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 diff --git a/frontend/translations/ig.po b/frontend/translations/ig.po index 3519dd29cd..348a6881ad 100644 --- a/frontend/translations/ig.po +++ b/frontend/translations/ig.po @@ -1935,7 +1935,7 @@ msgstr "Kagbuo" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "A new version is available, please refresh the page" +msgstr "A new version is available." #: src/app/main/ui/settings/delete_account.cljs:24 msgid "notifications.profile-deletion-not-allowed" diff --git a/frontend/translations/it.po b/frontend/translations/it.po index efe561e12d..dbede0245a 100644 --- a/frontend/translations/it.po +++ b/frontend/translations/it.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" +"PO-Revision-Date: 2026-02-17 10:09+0000\n" "Last-Translator: Nicola Bortoletto \n" -"Language-Team: Italian " -"\n" +"Language-Team: Italian \n" "Language: it\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -203,7 +203,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "…branding, illustrazione, materiali di marketing, etc." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Questo token non esiste o è stato eliminato." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1350,7 +1350,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Apri elenco token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Scollega token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -3686,7 +3686,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Una nuova versione è disponibile, si prega di ricaricare la pagina" +msgstr "Una nuova versione è disponibile." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -5012,11 +5012,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Grazie per aver scelto il piano Penpot %s!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Goditi il tuo piano!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Sei %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7840,7 +7840,7 @@ msgid "workspace.tokens.color" msgstr "Colore" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "L'interlinea dipende dalla dimensione del carattere. Aggiungi una " "dimensione carattere per ottenere il valore risolto." @@ -7890,7 +7890,7 @@ msgid "workspace.tokens.edit-token" msgstr "Modifica token %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Il valore del token non può essere vuoto" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7898,7 +7898,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Inserisci il nome del token %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Errore di importazione: Impossibile analizzare il JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7962,7 +7962,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importa %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Errore di importazione:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8019,65 +8019,65 @@ msgid "workspace.tokens.individual-tokens" msgstr "Utilizza token individuali" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Valore colore invalido: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "" "Valore del token non valido: puoi fare riferimento solo a un token " "font-family" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Valore del peso del font non valido: usa valori numerici (100-950) o nomi " "standard (thin, light, regular, bold, ecc.) eventualmente seguiti da " "'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Errore di importazione: Dati del token non validi nel JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Errore di importazione: nome token non valido nel JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" non è un nome token valido\n" "I nomi dei token devono contenere solo lettere e cifre, separate dal " "carattere . e non devono iniziare con il simbolo $." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "" "Tipologia ombra non valida: sono consentite solo 'innerShadow' o " "'dropShadow'" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Valore del token non valido: sono accettati solo none, Uppercase, Lowercase " "o Capitalize" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Valore del token non valido: sono accettati solo none, underline e " "strike-through" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Valore non valido: deve fare riferimento a un token ombra composito." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Valore non valido: deve fare riferimento a un token tipografico composito." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valore token non valido: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8117,7 +8117,7 @@ msgid "workspace.tokens.min-size" msgstr "Dimensione min" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Riferimenti al token mancanti: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8150,7 +8150,7 @@ msgstr "Nessun riferimento trovato" msgid "workspace.tokens.no-remap-needed" msgstr "" "Questo token al momento non è utilizzato nel tuo progetto, quindi non è " -"necessario alcun rimappaggio." +"necessaria alcuna riassegnazione." #: src/app/main/ui/workspace/tokens/sets/lists.cljs:485 msgid "workspace.tokens.no-sets-create" @@ -8169,7 +8169,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Al momento non hai temi." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Nessun token, set o tema trovato in questo file." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -8177,11 +8177,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "% set attivi" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valore token non valido. Il valore risolto è troppo grande: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "L'opacità deve essere compresa tra 0 e 100% o tra 0 e 1 (ad esempio 50% o " "0.5)." @@ -8231,7 +8231,7 @@ msgid "workspace.tokens.select-set" msgstr "Seleziona set." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Il token ha un riferimento a se stesso" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8270,7 +8270,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Sfocatura" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "La sfocatura dell'ombra deve essere maggiore o uguale a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8291,7 +8291,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Diffusione" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "La diffusione dell'ombra deve essere maggiore o uguale a 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8321,7 +8321,7 @@ msgid "workspace.tokens.size" msgstr "Dimensione" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "La larghezza della traccia deve essere maggiore o uguale a 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8383,10 +8383,6 @@ msgstr "Famiglia di caratteri o elenco di caratteri separati da virgola (,)" msgid "workspace.tokens.token-name" msgstr "Nome" -#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 -msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "Un token con questo nome esiste già nel percorso: %s" - #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" msgstr "Il nome deve essere di almeno 1 carattere" @@ -8419,15 +8415,13 @@ msgstr "TOKEN - %s" msgid "workspace.tokens.tools" msgstr "Strumenti" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "" -"L’importazione è stata completata con successo. Alcuni token non sono stati " -"inclusi." +msgstr "L’importazione è stata completata con successo, ma alcuni token sono stati ignorati perché utilizzano valori $type non supportati. Espandi i dettagli per vedere quali token sono stati interessati." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Il tipo '%s' non è supportato (%s)\n" +msgstr "Il tipo '%s' non è supportato (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8438,11 +8432,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Il valore non è valido" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Valore non valido: % non è consentito." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valore non valido: le unità non sono consentite." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8803,3 +8797,67 @@ msgstr "Clicca per chiudere il tracciato" #~ msgid "onboarding.slide.1.desc1" #~ msgstr "Crea interazioni complete per imitare al meglio il prodotto finale." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Riassegna token" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Non riassegnare" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Riassegnare tutti i token che usano `%s` a `%s`?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Questo cambierà tutti i livelli e i riferimenti che utilizzano il vecchio " +"nome del token." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Questa azione può richiedere un po' di tempo." + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Crea nuova organizzazione" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Errore inaspettato: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL ha smesso di funzionare. Ricarica la pagina per ripristinarlo" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oops! Il contesto della canvas è stato perso" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Ricarica pagina" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Strumenti di debug" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Riferimento mancante" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Inserisci un alias per il token ombra" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "" +"Rinominare questo token interromperà ogni riferimento al suo nome precedente" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Strumenti di debug" diff --git a/frontend/translations/ko.po b/frontend/translations/ko.po index f590f97179..f7d4881b6f 100644 --- a/frontend/translations/ko.po +++ b/frontend/translations/ko.po @@ -2,8 +2,8 @@ msgid "" msgstr "" "PO-Revision-Date: 2026-01-30 05:01+0000\n" "Last-Translator: Dogyeong \n" -"Language-Team: Korean " -"\n" +"Language-Team: Korean \n" "Language: ko\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" @@ -11,60 +11,64 @@ msgstr "" "Plural-Forms: nplurals=1; plural=0;\n" "X-Generator: Weblate 5.16-dev\n" -#: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 +#: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, +#: src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" msgstr "이미 계정이 있으신가요?" -#: src/app/main/ui/auth/recovery_request.cljs:113, src/app/main/ui/auth/register.cljs:238 +#: src/app/main/ui/auth/recovery_request.cljs:113, +#: src/app/main/ui/auth/register.cljs:238 msgid "auth.check-mail" msgstr "이메일을 확인하세요" #: src/app/main/ui/auth/register.cljs:277 -#, unused msgid "auth.check-your-email" -msgstr "이메일에 포함된 링크를 클릭하여 계정을 인증하고 펜팟의 사용을 시작하십시오." +msgstr "" +"이메일에 포함된 링크를 클릭하여 계정을 인증하고 Penpot의 사용을 시작하세요." #: src/app/main/ui/auth/recovery.cljs:67 msgid "auth.confirm-password" -msgstr "비밀번호 확인하기" +msgstr "비밀번호 확인" #: src/app/main/ui/auth/register.cljs:227 msgid "auth.create-demo-account" -msgstr "데모 계정을 생성하세요" +msgstr "데모 계정 생성" #: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs -#, unused msgid "auth.create-demo-profile" -msgstr "그냥 해볼까요?" +msgstr "체험해 보고 싶으신가요?" #: src/app/main/ui/auth/login.cljs:42 msgid "auth.demo-warning" -msgstr "데모 서비스입니다. 실제 작업에 사용하지 마십시오. 생성된 프로젝트는 주기적으로 삭제될 것입니다." +msgstr "" +"이것은 데모 서비스이며, 절대 실제 작업에 사용하지 마십시오. 프로젝트는 주기적" +"으로 삭제됩니다." #: src/app/main/ui/auth/login.cljs:198, src/app/main/ui/viewer/login.cljs:86 msgid "auth.forgot-password" -msgstr "비밀번호를 잊어버리셨나요?" +msgstr "비밀번호를 잊으셨나요?" -#: src/app/main/ui/auth/register.cljs:160, src/app/main/ui/auth/register.cljs:328 +#: src/app/main/ui/auth/register.cljs:160, +#: src/app/main/ui/auth/register.cljs:328 msgid "auth.fullname" msgstr "이름 (성명)" #: src/app/main/ui/auth/login.cljs:271 msgid "auth.login-account-title" -msgstr "내 계정에 로그인하기" +msgstr "내 계정에 로그인" -#: src/app/main/ui/auth/register.cljs:219, src/app/main/ui/static.cljs:161, src/app/main/ui/viewer/login.cljs:103 +#: src/app/main/ui/auth/register.cljs:219, src/app/main/ui/static.cljs:161, +#: src/app/main/ui/viewer/login.cljs:103 msgid "auth.login-here" msgstr "여기서 로그인하세요." #: src/app/main/ui/auth/login.cljs:195 -#, unused msgid "auth.login-submit" msgstr "로그인" #: src/app/main/ui/auth/login.cljs:274 msgid "auth.login-tagline" -msgstr "펜팟은 디자인과 코딩의 협업을 위한 무료 오픈소스 디자인 도구입니다" +msgstr "Penpot은 디자인과 코드 협업을 위한 무료 오픈소스 디자인 도구입니다" #: src/app/main/ui/auth/login.cljs:231 msgid "auth.login-with-github-submit" @@ -92,19 +96,19 @@ msgstr "새 비밀번호를 입력하세요" #: src/app/main/ui/auth/recovery.cljs:36 msgid "auth.notifications.password-changed-successfully" -msgstr "비밀번호가 성공적으로 변경되었어요" +msgstr "비밀번호가 성공적으로 변경되었습니다" #: src/app/main/ui/auth/recovery_request.cljs:50 msgid "auth.notifications.profile-not-verified" -msgstr "프로필이 검증되지 않았어요. 계속 하려면 검증절차를 완료해주세요." +msgstr "프로필이 인증되지 않았습니다. 계속하기 전에 프로필을 인증하세요." #: src/app/main/ui/auth/recovery_request.cljs:33 msgid "auth.notifications.recovery-token-sent" -msgstr "비밀번호 복구를 위한 링크를 메일함으로 보냈어요." +msgstr "비밀번호 복구 링크가 메일함으로 전송되었습니다." #: src/app/main/ui/auth/verify_token.cljs:49 msgid "auth.notifications.team-invitation-accepted" -msgstr "팀에 성공적으로 합류했어요" +msgstr "팀에 성공적으로 참가했습니다" #: src/app/main/ui/auth/login.cljs:188, src/app/main/ui/auth/register.cljs:174 msgid "auth.password" @@ -112,19 +116,19 @@ msgstr "비밀번호" #: src/app/main/ui/auth/register.cljs:173 msgid "auth.password-length-hint" -msgstr "최소 8개의 문자" +msgstr "최소 8자 이상" #: src/app/main/ui/auth/register.cljs:261 msgid "auth.privacy-policy" -msgstr "개인 정보 정책" +msgstr "개인정보 처리방침" #: src/app/main/ui/auth/recovery_request.cljs:82 msgid "auth.recovery-request-submit" -msgstr "비밀번호 복구하기" +msgstr "비밀번호 복구" #: src/app/main/ui/auth/recovery_request.cljs:95 msgid "auth.recovery-request-subtitle" -msgstr "이용지침을 메일로 전달해드릴거에요" +msgstr "안내 사항이 담긴 이메일을 보내드리겠습니다" #: src/app/main/ui/auth/recovery_request.cljs:94 msgid "auth.recovery-request-title" @@ -132,66 +136,72 @@ msgstr "비밀번호를 잊으셨나요?" #: src/app/main/ui/auth/recovery.cljs:71 msgid "auth.recovery-submit" -msgstr "비밀번호를 바꾸세요" +msgstr "비밀번호 변경" -#: src/app/main/ui/auth/login.cljs:287, src/app/main/ui/static.cljs:144, src/app/main/ui/viewer/login.cljs:89 +#: src/app/main/ui/auth/login.cljs:287, src/app/main/ui/static.cljs:144, +#: src/app/main/ui/viewer/login.cljs:89 msgid "auth.register" -msgstr "아직 계정이 없으신가요?" +msgstr "계정이 없으신가요?" #: src/app/main/ui/auth/register.cljs:351 msgid "auth.register-account-tagline" -msgstr "대시보드와 이메일 에서 당신을 어떻게 호칭할지 저희에게 알려주세요." +msgstr "대시보드와 이메일에 표시될 이름을 입력해주세요." #: src/app/main/ui/auth/register.cljs:350 msgid "auth.register-account-title" msgstr "당신의 이름" -#: src/app/main/ui/auth/login.cljs:291, src/app/main/ui/auth/register.cljs:185, src/app/main/ui/auth/register.cljs:337, src/app/main/ui/static.cljs:148, src/app/main/ui/viewer/login.cljs:93 +#: src/app/main/ui/auth/login.cljs:291, src/app/main/ui/auth/register.cljs:185, +#: src/app/main/ui/auth/register.cljs:337, src/app/main/ui/static.cljs:148, +#: src/app/main/ui/viewer/login.cljs:93 msgid "auth.register-submit" -msgstr "계정을 생성하세요" +msgstr "계정 생성" #: src/app/main/ui/auth/register.cljs:124 -#, unused msgid "auth.register-tagline" -msgstr "펜팟 무료 계정과 함께라면, 무제한으로 팀을 만들고 다른 디자이너 및 개발자와 원하는 만큼 프로젝트에서 협업할 수 있습니다. " +msgstr "" +"무료 Penpot 계정으로 팀을 무제한 생성하고, 원하는 만큼 많은 프로젝트에서 다" +"른 디자이너 및 개발자와 협업할 수 있습니다. " #: src/app/main/ui/auth/register.cljs:206 msgid "auth.register-title" -msgstr "계정을 생성하세요" +msgstr "계정 생성" #: src/app/main/ui/auth.cljs -#, unused msgid "auth.sidebar-tagline" -msgstr "디자인과 프로토타이핑을 위한 오픈소스 솔루션." +msgstr "디자인 및 프로토타이핑을 위한 오픈소스 솔루션." #: src/app/main/ui/auth/register.cljs:51 -#, markdown msgid "auth.terms-and-privacy-agreement" -msgstr "[서비스 약관](%s) 및 [개인정보 처리방침](%s)에 동의합니다." +msgstr "[서비스 이용약관](%s) 및 [개인정보 처리방침](%s)에 동의합니다." -#: src/app/main/ui/auth/register.cljs:253, src/app/main/ui/dashboard/sidebar.cljs:979, src/app/main/ui/workspace/main_menu.cljs:184 +#: src/app/main/ui/auth/register.cljs:253, +#: src/app/main/ui/dashboard/sidebar.cljs:979, +#: src/app/main/ui/workspace/main_menu.cljs:184 msgid "auth.terms-of-service" -msgstr "서비스 정책" +msgstr "서비스 이용약관" -#, unused msgid "auth.terms-privacy-agreement" -msgstr "새로운 계정을 생성하시면, 사용자는 펜팟의 서비스 정책과 개인 정보 정책에 동의하는 것으로 간주됩니다." +msgstr "" +"새 계정을 만들면 당사의 이용약관 및 개인정보 처리방침에 동의하게 됩니다." #: src/app/main/ui/auth/register.cljs:239 msgid "auth.verification-email-sent" -msgstr "검증 메일을 ~에 보냈어요" +msgstr "다음 이메일로 인증 메일을 보냈습니다:" -#: src/app/main/ui/auth/login.cljs:179, src/app/main/ui/auth/recovery_request.cljs:77, src/app/main/ui/auth/register.cljs:167 +#: src/app/main/ui/auth/login.cljs:179, +#: src/app/main/ui/auth/recovery_request.cljs:77, +#: src/app/main/ui/auth/register.cljs:167 msgid "auth.work-email" -msgstr "작업용 이메일" +msgstr "업무 이메일" #: src/app/main/ui/onboarding/questions.cljs -#, unused msgid "branding-illustrations-marketing-pieces" msgstr "...브랜딩, 일러스트레이션, 마케팅 자료 등." -#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 +msgid "options.deleted-token" msgstr "이 토큰은 존재하지 않거나 삭제되었습니다." #: src/app/main/ui/comments.cljs:530 @@ -200,76 +210,80 @@ msgstr "@%s 사용자를 찾을 수 없습니다" #: src/app/main/ui/workspace/libraries.cljs:323 msgid "common.publish" -msgstr "발행하기" +msgstr "게시하기" -#: src/app/main/ui/viewer/share_link.cljs:304, src/app/main/ui/viewer/share_link.cljs:315 +#: src/app/main/ui/viewer/share_link.cljs:304, +#: src/app/main/ui/viewer/share_link.cljs:315 msgid "common.share-link.all-users" -msgstr "모든 펜팟 유저들" +msgstr "모든 Penpot 사용자" #: src/app/main/ui/viewer/share_link.cljs:204 msgid "common.share-link.confirm-deletion-link-description" -msgstr "정말로 링크를 제거하고 싶으세요? 제거하시면, 더이상 아무도 이용할 수 없어요" +msgstr "이 링크를 삭제하시겠습니까? 삭제하면 더 이상 누구도 접근할 수 없습니다" -#: src/app/main/ui/viewer/share_link.cljs:259, src/app/main/ui/viewer/share_link.cljs:289 +#: src/app/main/ui/viewer/share_link.cljs:259, +#: src/app/main/ui/viewer/share_link.cljs:289 msgid "common.share-link.current-tag" msgstr "(현재)" -#: src/app/main/ui/viewer/share_link.cljs:211, src/app/main/ui/viewer/share_link.cljs:216 +#: src/app/main/ui/viewer/share_link.cljs:211, +#: src/app/main/ui/viewer/share_link.cljs:216 msgid "common.share-link.destroy-link" -msgstr "링크 제거하기" +msgstr "링크 삭제" #: src/app/main/ui/viewer/share_link.cljs:221 msgid "common.share-link.get-link" -msgstr "링크 얻기" +msgstr "링크 가져오기" #: src/app/main/ui/viewer/share_link.cljs:142 msgid "common.share-link.link-copied-success" -msgstr "링크를 성공적으로 복사했어요" +msgstr "링크가 성공적으로 복사되었습니다" #: src/app/main/ui/viewer/share_link.cljs:231 msgid "common.share-link.manage-ops" -msgstr "권한을 관리하세요" +msgstr "권한 관리" #: src/app/main/ui/viewer/share_link.cljs:277 msgid "common.share-link.page-shared" msgid_plural "common.share-link.page-shared" -msgstr[0] "%s 페이지가 공유되었습니다" +msgstr[0] "%s개의 페이지가 공유되었습니다" #: src/app/main/ui/viewer/share_link.cljs:298 msgid "common.share-link.permissions-can-comment" -msgstr "코멘트를 달 수 있어요" +msgstr "댓글 작성 가능" #: src/app/main/ui/viewer/share_link.cljs:309 msgid "common.share-link.permissions-can-inspect" -msgstr "코드를 검사할 수 있어요" +msgstr "코드 검사 가능" #: src/app/main/ui/viewer/share_link.cljs:199 msgid "common.share-link.permissions-hint" -msgstr "링크를 가진 누구나 접근할 수 있어요" +msgstr "링크가 있는 누구나 접근 가능합니다" #: src/app/main/ui/viewer/share_link.cljs:241 msgid "common.share-link.permissions-pages" -msgstr "페이지가 공유됐어요" +msgstr "공유된 페이지" #: src/app/main/ui/viewer/share_link.cljs:189 msgid "common.share-link.placeholder" -msgstr "공유할 수 있는 링크는 여기 나타날거에요" +msgstr "공유 가능한 링크가 여기에 표시됩니다" -#: src/app/main/ui/viewer/share_link.cljs:303, src/app/main/ui/viewer/share_link.cljs:314 +#: src/app/main/ui/viewer/share_link.cljs:303, +#: src/app/main/ui/viewer/share_link.cljs:314 msgid "common.share-link.team-members" -msgstr "오직 팀원들을 위해" +msgstr "팀 구성원만" #: src/app/main/ui/viewer/share_link.cljs:176 msgid "common.share-link.title" -msgstr "프로토타입을 공유해요" +msgstr "프로토타입 공유" #: src/app/main/ui/viewer/share_link.cljs:269 msgid "common.share-link.view-all" -msgstr "모두 선택해요" +msgstr "모두 선택" #: src/app/main/ui/workspace/libraries.cljs:320 msgid "common.unpublish" -msgstr "발행취소하기" +msgstr "게시 취소" #: src/app/main/ui/dashboard/projects.cljs:93 msgid "dasboard.team-hero.management" @@ -277,45 +291,41 @@ msgstr "팀 관리" #: src/app/main/ui/dashboard/projects.cljs:92 msgid "dasboard.team-hero.text" -msgstr "펜팟은 팀을 위한 도구입니다. 팀원들을 초대하여 프로젝트 및 파일 단위로 협업하십시오" +msgstr "" +"Penpot은 팀을 위한 도구입니다. 프로젝트와 파일에서 함께 작업할 수 있도록 구성" +"원을 초대하세요" #: src/app/main/ui/dashboard/projects.cljs:90 msgid "dasboard.team-hero.title" -msgstr "팀을 이뤄요!" +msgstr "팀을 구성하세요!" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.tutorial-hero.info" -msgstr "본 실습용 튜토리얼을 통해 펜팟의 기본 기능에 대하여 재미있게 학습하십시오." +msgstr "Penpot의 기본 기능을 실습 튜토리얼로 재미있게 배워보세요." #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.tutorial-hero.start" -msgstr "튜토리얼을 시작하세요" +msgstr "튜토리얼 시작" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.tutorial-hero.title" -msgstr "실습용 튜토리얼" +msgstr "실습 튜토리얼" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.walkthrough-hero.info" -msgstr "펜팟을 둘러보고 주요 기능에 대한 정보를 습득하십시오." +msgstr "Penpot을 둘러보고 주요 기능을 살펴보세요." #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.walkthrough-hero.start" -msgstr "투어를 시작해요" +msgstr "투어 시작" #: src/app/main/ui/dashboard/projects.cljs -#, unused msgid "dasboard.walkthrough-hero.title" msgstr "인터페이스 둘러보기" #: src/app/main/ui/dashboard/file_menu.cljs:208 msgid "dashboard-restore-file-confirmation.description" -msgstr "%s 파일을 복원하려 합니다." +msgstr "%s 파일이 복원됩니다." #: src/app/main/ui/dashboard/file_menu.cljs:207 msgid "dashboard-restore-file-confirmation.title" @@ -323,23 +333,23 @@ msgstr "파일 복원" #: src/app/main/ui/settings/access_tokens.cljs:103 msgid "dashboard.access-tokens.copied-success" -msgstr "복사된 토큰" +msgstr "토큰 복사됨" #: src/app/main/ui/settings/access_tokens.cljs:189 msgid "dashboard.access-tokens.create" -msgstr "새로운 토큰 생성하기" +msgstr "새 토큰 생성" #: src/app/main/ui/settings/access_tokens.cljs:64 msgid "dashboard.access-tokens.create.success" -msgstr "엑세스 토큰이 성공적으로 생성되었습니다." +msgstr "액세스 토큰이 성공적으로 생성되었습니다." #: src/app/main/ui/settings/access_tokens.cljs:286 msgid "dashboard.access-tokens.empty.add-one" -msgstr "\"새로운 토큰 생성하기\" 버튼을 눌러 토큰을 생성하십시오." +msgstr "\"새 토큰 생성\" 버튼을 눌러 토큰을 생성하세요." #: src/app/main/ui/settings/access_tokens.cljs:285 msgid "dashboard.access-tokens.empty.no-access-tokens" -msgstr "현재 가지고 있는 토큰이 없습니다." +msgstr "아직 생성된 토큰이 없습니다." #: src/app/main/ui/settings/access_tokens.cljs:135 msgid "dashboard.access-tokens.expiration-180-days" @@ -359,73 +369,78 @@ msgstr "90일" #: src/app/main/ui/settings/access_tokens.cljs:131 msgid "dashboard.access-tokens.expiration-never" -msgstr "기한 없음" +msgstr "만료 없음" #: src/app/main/ui/settings/access_tokens.cljs:268 msgid "dashboard.access-tokens.expired-on" -msgstr "%s에 만료되었습니다" +msgstr "%s에 만료됨" #: src/app/main/ui/settings/access_tokens.cljs:269 msgid "dashboard.access-tokens.expires-on" -msgstr "%s에 만료됩니다" +msgstr "%s에 만료 예정" #: src/app/main/ui/settings/access_tokens.cljs:267 msgid "dashboard.access-tokens.no-expiration" -msgstr "만료 기한 없음" +msgstr "만료일 없음" #: src/app/main/ui/settings/access_tokens.cljs:184 msgid "dashboard.access-tokens.personal" -msgstr "개인용 엑세스 토큰" +msgstr "개인용 액세스 토큰" #: src/app/main/ui/settings/access_tokens.cljs:185 msgid "dashboard.access-tokens.personal.description" msgstr "" -"개인용 엑세스 토큰은 펜팟의 로그인/암호 인증 시스템의 대안으로 사용되며, 어플리케이션의 펜팟 내부 API 엑세스를 위해 사용될 수 " -"있습니다" +"개인용 엑세스 토큰은 로그인/비밀번호 기반 인증을 대신할 수 있는 인증 수단이" +"고, 이를 통해 애플리케이션이 내부 Penpot API에 접근할 수 있습니다" #: src/app/main/ui/settings/access_tokens.cljs:142 msgid "dashboard.access-tokens.token-will-expire" -msgstr "토큰은 %s에 만료 예정입니다" +msgstr "해당 토큰은 %s에 만료됩니다" #: src/app/main/ui/settings/access_tokens.cljs:143 msgid "dashboard.access-tokens.token-will-not-expire" -msgstr "토큰의 만료 기한이 없습니다" +msgstr "해당 토큰은 만료일이 없습니다" #: src/app/main/ui/dashboard/placeholder.cljs:41 msgid "dashboard.add-file" msgstr "파일 추가" -#: src/app/main/ui/dashboard/file_menu.cljs:322, src/app/main/ui/workspace/main_menu.cljs:650 +#: src/app/main/ui/dashboard/file_menu.cljs:322, +#: src/app/main/ui/workspace/main_menu.cljs:650 msgid "dashboard.add-shared" -msgstr "공유 라이브러리로 추가하기" +msgstr "공유 라이브러리로 추가" #: src/app/main/ui/settings/profile.cljs:75 msgid "dashboard.change-email" -msgstr "이메일을 변경해요" +msgstr "이메일 변경" #: src/app/main/ui/dashboard/deleted.cljs:313 msgid "dashboard.clear-trash-button" msgstr "휴지통 비우기" -#: src/app/main/data/dashboard.cljs:330, src/app/main/data/dashboard.cljs:565, src/app/main/data/workspace/pages.cljs:198 +#: src/app/main/data/dashboard.cljs:330, src/app/main/data/dashboard.cljs:565, +#: src/app/main/data/workspace/pages.cljs:198 msgid "dashboard.copy-suffix" msgstr "(복사)" #: src/app/main/ui/dashboard/sidebar.cljs:340 msgid "dashboard.create-new-team" -msgstr "새 팀을 생성해요" +msgstr "새 팀 생성" #: src/app/main/ui/workspace/main_menu.cljs:661 msgid "dashboard.create-version-menu" msgstr "이 버전 고정" -#: src/app/main/ui/components/context_menu_a11y.cljs:300, src/app/main/ui/dashboard/sidebar.cljs:638 +#: src/app/main/ui/components/context_menu_a11y.cljs:300, +#: src/app/main/ui/dashboard/sidebar.cljs:638 msgid "dashboard.default-team-name" -msgstr "당신의 펜팟" +msgstr "내 Penpot" #: src/app/main/ui/dashboard/deleted.cljs:262 msgid "dashboard.delete-all-forever-confirmation.description" -msgstr "삭제된 모든 프로젝트와 파일을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다." +msgstr "" +"삭제된 모든 프로젝트와 파일을 영구적으로 삭제하시겠습니까? 이 작업은 되돌릴 " +"수 없습니다." #: src/app/main/ui/dashboard/file_menu.cljs:221 msgid "dashboard.delete-file-forever-confirmation.description" @@ -433,50 +448,57 @@ msgstr "%s 파일을 영구적으로 삭제하시겠습니까? 이 작업은 되 #: src/app/main/data/dashboard.cljs:778 msgid "dashboard.delete-files-success-notification" -msgstr "%s 파일이 성공적으로 삭제되었습니다." +msgstr "%s개 파일이 성공적으로 삭제되었습니다." -#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +#: src/app/main/ui/dashboard/deleted.cljs:51, +#: src/app/main/ui/dashboard/deleted.cljs:53, +#: src/app/main/ui/dashboard/deleted.cljs:261, +#: src/app/main/ui/dashboard/deleted.cljs:263, +#: src/app/main/ui/dashboard/file_menu.cljs:220, +#: src/app/main/ui/dashboard/file_menu.cljs:222 msgid "dashboard.delete-forever-confirmation.title" -msgstr "영구적으로 삭제" +msgstr "영구 삭제" #: src/app/main/ui/dashboard/deleted.cljs:85 msgid "dashboard.delete-project-button" -msgstr "프로젝트 제거" +msgstr "프로젝트 삭제" #: src/app/main/ui/dashboard/deleted.cljs:52 msgid "dashboard.delete-project-forever-confirmation.description" msgstr "" -"%s 프로젝트를 영구적으로 삭제하시겠습니까? 이 프로젝트와 안에 포함된 모든 파일이 영구적으로 삭제됩니다. 이 작업은 되돌릴 수 " -"없습니다." +"%s 프로젝트를 영구적으로 삭제하시겠습니까? 해당 프로젝트와 그 안에 포함된 모" +"든 파일이 함께 영구 삭제됩니다. 이 작업은 되돌릴 수 없습니다." #: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 msgid "dashboard.delete-success-notification" -msgstr "%s가 영구적으로 삭제되었습니다." +msgstr "%s가 성공적으로 삭제되었습니다." #: src/app/main/ui/dashboard/sidebar.cljs:495 msgid "dashboard.delete-team" -msgstr "팀을 해체해요" +msgstr "팀 삭제" #: src/app/main/ui/dashboard/deleted.cljs:327 msgid "dashboard.deleted.empty-state-description" -msgstr "휴지통이 비어 있습니다. 삭제된 파일과 프로젝트가 여기에 표시됩니다." +msgstr "휴지통이 비어있습니다. 삭제된 파일 및 프로젝트가 여기에 표시됩니다." -#: src/app/main/ui/dashboard/file_menu.cljs:328, src/app/main/ui/workspace/main_menu.cljs:690 +#: src/app/main/ui/dashboard/file_menu.cljs:328, +#: src/app/main/ui/workspace/main_menu.cljs:690 msgid "dashboard.download-binary-file" -msgstr "펜팟 파일(.penpot)을 다운로드해요" +msgstr "Penpot 파일(.penpot) 다운로드" -#: src/app/main/ui/dashboard/file_menu.cljs:321, src/app/main/ui/workspace/main_menu.cljs:712 -#, unused +#: src/app/main/ui/dashboard/file_menu.cljs:321, +#: src/app/main/ui/workspace/main_menu.cljs:712 msgid "dashboard.download-standard-file" -msgstr "표준 파일(.svg + .json)을 다운로드해요" +msgstr "표준 파일(.svg + .json) 다운로드" -#: src/app/main/ui/dashboard/file_menu.cljs:304, src/app/main/ui/dashboard/project_menu.cljs:92 +#: src/app/main/ui/dashboard/file_menu.cljs:304, +#: src/app/main/ui/dashboard/project_menu.cljs:92 msgid "dashboard.duplicate" -msgstr "복제해요" +msgstr "복제" #: src/app/main/ui/dashboard/file_menu.cljs:271 msgid "dashboard.duplicate-multi" -msgstr "%파일을 복제해요" +msgstr "%s개 파일 복제" #: src/app/main/ui/dashboard/placeholder.cljs:111 msgid "dashboard.empty-placeholder-libraries-title" @@ -484,43 +506,46 @@ msgstr "아직 라이브러리가 없습니다." #: src/app/main/ui/dashboard/file_menu.cljs:280 msgid "dashboard.export-binary-multi" -msgstr "%s 펜팟 파일 (.penpot) 다운로드 하기" +msgstr "%s Penpot 파일 (.penpot) 다운로드" #: src/app/main/ui/workspace/main_menu.cljs:698 msgid "dashboard.export-frames" -msgstr "대지를 PDF로 내보내요" +msgstr "보드를 PDF로 내보내기" #: src/app/main/ui/exports/assets.cljs:201 msgid "dashboard.export-frames.title" -msgstr "PDF로 내보내요" +msgstr "PDF로 내보내기" #: src/app/main/ui/workspace/main_menu.cljs:679 msgid "dashboard.export-shapes" -msgstr "내보내요" +msgstr "내보내기" #: src/app/main/ui/dashboard/sidebar.cljs:858 msgid "dashboard.no-projects-placeholder" msgstr "고정된 프로젝트가 여기에 표시됩니다" -#: src/app/main/ui/dashboard/deleted.cljs:62, src/app/main/ui/dashboard/projects.cljs:57 +#: src/app/main/ui/dashboard/deleted.cljs:62, +#: src/app/main/ui/dashboard/projects.cljs:57 msgid "dashboard.projects-title" msgstr "프로젝트" #: src/app/main/ui/dashboard/deleted.cljs:274 msgid "dashboard.restore-all-confirmation.description" -msgstr "모든 프로젝트와 파일을 복원하려 합니다. 시간이 다소 소요될 수 있습니다." +msgstr "" +"모든 프로젝트와 파일을 복원하려 합니다. 시간이 다소 소요될 수 있습니다." #: src/app/main/ui/dashboard/deleted.cljs:273 msgid "dashboard.restore-all-confirmation.title" -msgstr "모든 파일과 프로젝트 복원" +msgstr "모든 프로젝트 및 파일 복원" -#: src/app/main/ui/dashboard/sidebar.cljs:259, src/app/main/ui/dashboard/sidebar.cljs:260 +#: src/app/main/ui/dashboard/sidebar.cljs:259, +#: src/app/main/ui/dashboard/sidebar.cljs:260 msgid "dashboard.search-placeholder" msgstr "검색…" #: src/app/main/ui/dashboard/search.cljs:72 msgid "dashboard.searching-for" -msgstr "“%s” 찾는 중…" +msgstr "\"%s\" 검색 중…" #: src/app/main/ui/dashboard/team.cljs:1344 msgid "dashboard.team-projects" @@ -540,21 +565,24 @@ msgstr "복구 토큰이 유효하지 않습니다." #: src/app/main/ui/inspect/attributes/blur.cljs:26 msgid "inspect.attributes.blur" -msgstr "흐림" +msgstr "블러" #: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:126 msgid "inspect.attributes.blur.value" msgstr "값" -#: src/app/main/ui/inspect/attributes/common.cljs:93, src/app/main/ui/inspect/right_sidebar.cljs:98 +#: src/app/main/ui/inspect/attributes/common.cljs:93, +#: src/app/main/ui/inspect/right_sidebar.cljs:98 msgid "inspect.attributes.color.hex" msgstr "HEX" -#: src/app/main/ui/inspect/attributes/common.cljs:95, src/app/main/ui/inspect/right_sidebar.cljs:102 +#: src/app/main/ui/inspect/attributes/common.cljs:95, +#: src/app/main/ui/inspect/right_sidebar.cljs:102 msgid "inspect.attributes.color.hsla" msgstr "HSLA" -#: src/app/main/ui/inspect/attributes/common.cljs:94, src/app/main/ui/inspect/right_sidebar.cljs:100 +#: src/app/main/ui/inspect/attributes/common.cljs:94, +#: src/app/main/ui/inspect/right_sidebar.cljs:100 msgid "inspect.attributes.color.rgba" msgstr "RGBA" @@ -562,126 +590,116 @@ msgstr "RGBA" msgid "inspect.attributes.fill" msgstr "채우기" -#: src/app/main/ui/inspect/attributes/common.cljs:78, src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:126 +#: src/app/main/ui/inspect/attributes/common.cljs:78, +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:126 msgid "inspect.attributes.image.download" -msgstr "소스 이미지 다운로드" +msgstr "원본 이미지 다운로드" #: src/app/main/ui/inspect/attributes/image.cljs:39 -#, unused msgid "inspect.attributes.image.height" msgstr "높이" #: src/app/main/ui/inspect/attributes/image.cljs:32 -#, unused msgid "inspect.attributes.image.width" -msgstr "폭" +msgstr "너비" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout" msgstr "레이아웃" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.height" msgstr "높이" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.left" msgstr "왼쪽" -#: src/app/main/ui/inspect/attributes/layout.cljs, src/app/main/ui/inspect/attributes/layout.cljs -#, unused +#: src/app/main/ui/inspect/attributes/layout.cljs, +#: src/app/main/ui/inspect/attributes/layout.cljs msgid "inspect.attributes.layout.radius" msgstr "반지름" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.rotation" msgstr "회전" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.top" -msgstr "위" +msgstr "위쪽" #: src/app/main/ui/inspect/attributes/layout.cljs -#, unused msgid "inspect.attributes.layout.width" -msgstr "폭" +msgstr "너비" #: src/app/main/ui/inspect/attributes/shadow.cljs:65 msgid "inspect.attributes.shadow" msgstr "그림자" -#: src/app/main/ui/inspect/attributes/geometry.cljs:46, src/app/main/ui/inspect/styles/style_box.cljs:22 +#: src/app/main/ui/inspect/attributes/geometry.cljs:46, +#: src/app/main/ui/inspect/styles/style_box.cljs:22 msgid "inspect.attributes.size" -msgstr "사이즈와 위치" +msgstr "크기 및 위치" #: src/app/main/ui/inspect/attributes/stroke.cljs:90 msgid "inspect.attributes.stroke" msgstr "선" -#, permanent, unused msgid "inspect.attributes.stroke.alignment.center" msgstr "중앙" -#, permanent, unused msgid "inspect.attributes.stroke.alignment.inner" msgstr "안쪽" -#, permanent, unused msgid "inspect.attributes.stroke.alignment.outer" msgstr "바깥쪽" -#, unused msgid "inspect.attributes.stroke.style.dotted" msgstr "점선" -#, unused msgid "inspect.attributes.stroke.style.mixed" msgstr "혼합" -#, unused msgid "inspect.attributes.stroke.style.solid" -msgstr "단색" +msgstr "실선" #: src/app/main/ui/inspect/attributes/stroke.cljs -#, unused msgid "inspect.attributes.stroke.width" -msgstr "폭" +msgstr "두께" -#: src/app/main/ui/inspect/attributes/text.cljs:53, src/app/main/ui/inspect/attributes/text.cljs:159 +#: src/app/main/ui/inspect/attributes/text.cljs:53, +#: src/app/main/ui/inspect/attributes/text.cljs:159 msgid "inspect.attributes.typography" msgstr "타이포그래피" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:308 msgid "inspect.attributes.typography.font-family" -msgstr "폰트 패밀리" +msgstr "글꼴 모음" -#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 msgid "inspect.attributes.typography.font-size" -msgstr "폰트 사이즈" +msgstr "글꼴 크기" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:343 msgid "inspect.attributes.typography.font-style" -msgstr "폰트 스타일" +msgstr "글꼴 스타일" #: src/app/main/ui/inspect/attributes/text.cljs:113 msgid "inspect.attributes.typography.text-decoration.underline" msgstr "밑줄" #: src/app/main/ui/inspect/attributes/text.cljs:153 -#, unused msgid "inspect.attributes.typography.text-transform" -msgstr "텍스트 변형" +msgstr "텍스트 변환" -#: src/app/main/ui/inspect/attributes/text.cljs:123, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:441 +#: src/app/main/ui/inspect/attributes/text.cljs:123, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:441 msgid "inspect.attributes.typography.text-transform.lowercase" msgstr "소문자" -#: src/app/main/ui/inspect/attributes/text.cljs:126, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:433 +#: src/app/main/ui/inspect/attributes/text.cljs:126, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:433 msgid "inspect.attributes.typography.text-transform.uppercase" msgstr "대문자" @@ -695,7 +713,7 @@ msgstr "컴포넌트" #: src/app/main/ui/inspect/right_sidebar.cljs:145 msgid "inspect.tabs.code.selected.curve" -msgstr "커브" +msgstr "곡선" #: src/app/main/ui/inspect/right_sidebar.cljs:146 msgid "inspect.tabs.code.selected.frame" @@ -715,7 +733,7 @@ msgstr "마스크" #: src/app/main/ui/inspect/right_sidebar.cljs:150 msgid "inspect.tabs.code.selected.path" -msgstr "패스" +msgstr "경로" #: src/app/main/ui/inspect/right_sidebar.cljs:151 msgid "inspect.tabs.code.selected.rect" @@ -735,152 +753,236 @@ msgstr "단축키" #: src/app/main/data/common.cljs:90, src/app/main/ui/dashboard/import.cljs:530 msgid "labels.accept" -msgstr "허가" +msgstr "수락" #: src/app/main/ui/dashboard/team.cljs:1223 msgid "labels.active" -msgstr "활성화" +msgstr "활성" #: src/app/main/ui/dashboard/fonts.cljs:186 msgid "labels.add-custom-font" msgstr "커스텀 폰트 추가" -#: src/app/main/ui/dashboard/team.cljs:134, src/app/main/ui/dashboard/team.cljs:320, src/app/main/ui/dashboard/team.cljs:565, src/app/main/ui/dashboard/team.cljs:595, src/app/main/ui/onboarding/team_choice.cljs:58 +#: src/app/main/ui/dashboard/team.cljs:134, +#: src/app/main/ui/dashboard/team.cljs:320, +#: src/app/main/ui/dashboard/team.cljs:565, +#: src/app/main/ui/dashboard/team.cljs:595, +#: src/app/main/ui/onboarding/team_choice.cljs:58 msgid "labels.admin" -msgstr "관리자" +msgstr "관리자(Admin)" -#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:92, src/app/main/ui/workspace/tokens/management/context_menu.cljs:129, src/app/main/ui/workspace/tokens/management/token_pill.cljs:117 +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:92, +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:129, +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:117 msgid "labels.all" msgstr "전체" #: src/app/main/ui/auth/register.cljs:257 msgid "labels.and" -msgstr "그리고" +msgstr "및" #: src/app/main/ui/onboarding/team_choice.cljs:186 -#, unused msgid "labels.back" msgstr "뒤로" #: src/app/main/ui/static.cljs:296 msgid "labels.bad-gateway.main-message" -msgstr "잘못된 경로" +msgstr "게이트웨이 오류가 발생했습니다" -#: src/app/main/data/common.cljs:119, src/app/main/ui/dashboard/change_owner.cljs:64, src/app/main/ui/dashboard/import.cljs:515, src/app/main/ui/dashboard/team.cljs:780, src/app/main/ui/dashboard/team.cljs:1122, src/app/main/ui/delete_shared.cljs:38, src/app/main/ui/exports/assets.cljs:163, src/app/main/ui/exports/files.cljs:168, src/app/main/ui/settings/access_tokens.cljs:175, src/app/main/ui/viewer/share_link.cljs:208, src/app/main/ui/workspace/sidebar/assets/groups.cljs:159, src/app/main/ui/workspace/tokens/export/modal.cljs:44, src/app/main/ui/workspace/tokens/import/modal.cljs:269, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:297, src/app/main/ui/workspace/tokens/settings/menu.cljs:105, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:245 +#: src/app/main/data/common.cljs:119, +#: src/app/main/ui/dashboard/change_owner.cljs:64, +#: src/app/main/ui/dashboard/import.cljs:515, +#: src/app/main/ui/dashboard/team.cljs:780, +#: src/app/main/ui/dashboard/team.cljs:1122, +#: src/app/main/ui/delete_shared.cljs:38, +#: src/app/main/ui/exports/assets.cljs:163, +#: src/app/main/ui/exports/files.cljs:168, +#: src/app/main/ui/settings/access_tokens.cljs:175, +#: src/app/main/ui/viewer/share_link.cljs:208, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:159, +#: src/app/main/ui/workspace/tokens/export/modal.cljs:44, +#: src/app/main/ui/workspace/tokens/import/modal.cljs:269, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:297, +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:105, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:245 msgid "labels.cancel" msgstr "취소" -#: src/app/main/data/common.cljs:96, src/app/main/ui/dashboard/comments.cljs:103, src/app/main/ui/dashboard/projects.cljs:101, src/app/main/ui/delete_shared.cljs:105, src/app/main/ui/ds/product/panel_title.cljs:32, src/app/main/ui/exports/files.cljs:186, src/app/main/ui/settings/access_tokens.cljs:170, src/app/main/ui/settings/subscription.cljs:353, src/app/main/ui/viewer/login.cljs:71, src/app/main/ui/viewer/share_link.cljs:179, src/app/main/ui/workspace/libraries.cljs:643, src/app/main/ui/workspace/sidebar/layers.cljs:301, src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:43, src/app/main/ui/workspace/sidebar.cljs:264, src/app/main/ui/workspace/tokens/export.cljs:25, src/app/main/ui/workspace/tokens/import.cljs:19, src/app/main/ui/workspace/tokens/management/forms/modals.cljs:99, src/app/main/ui/workspace/tokens/remapping_modal.cljs:79, src/app/main/ui/workspace/tokens/settings/menu.cljs:78, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:62, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:147, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:461 +#: src/app/main/data/common.cljs:96, +#: src/app/main/ui/dashboard/comments.cljs:103, +#: src/app/main/ui/dashboard/projects.cljs:101, +#: src/app/main/ui/delete_shared.cljs:105, +#: src/app/main/ui/ds/product/panel_title.cljs:32, +#: src/app/main/ui/exports/files.cljs:186, +#: src/app/main/ui/settings/access_tokens.cljs:170, +#: src/app/main/ui/settings/subscription.cljs:353, +#: src/app/main/ui/viewer/login.cljs:71, +#: src/app/main/ui/viewer/share_link.cljs:179, +#: src/app/main/ui/workspace/libraries.cljs:643, +#: src/app/main/ui/workspace/sidebar/layers.cljs:301, +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:43, +#: src/app/main/ui/workspace/sidebar.cljs:264, +#: src/app/main/ui/workspace/tokens/export.cljs:25, +#: src/app/main/ui/workspace/tokens/import.cljs:19, +#: src/app/main/ui/workspace/tokens/management/forms/modals.cljs:99, +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:79, +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:78, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:62, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:147, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:461 msgid "labels.close" msgstr "닫기" -#: src/app/main/ui/inspect/right_sidebar.cljs:112, src/app/main/ui/inspect/right_sidebar.cljs:117 +#: src/app/main/ui/inspect/right_sidebar.cljs:112, +#: src/app/main/ui/inspect/right_sidebar.cljs:117 msgid "labels.code" msgstr "코드" -#: src/app/main/ui/viewer/comments.cljs:70, src/app/main/ui/workspace/comments.cljs:128 +#: src/app/main/ui/viewer/comments.cljs:70, +#: src/app/main/ui/workspace/comments.cljs:128 msgid "labels.comments" -msgstr "코멘트" +msgstr "댓글" -#: src/app/main/ui/dashboard/sidebar.cljs:935, src/app/main/ui/workspace/main_menu.cljs:144 +#: src/app/main/ui/dashboard/sidebar.cljs:935, +#: src/app/main/ui/workspace/main_menu.cljs:144 msgid "labels.community" msgstr "커뮤니티" #: src/app/main/ui/settings/password.cljs:93 msgid "labels.confirm-password" -msgstr "비밀번호 확인하기" +msgstr "비밀번호 확인" -#: src/app/main/ui/auth/login.cljs:204, src/app/main/ui/dashboard/deleted.cljs:43, src/app/main/ui/dashboard/deleted.cljs:275, src/app/main/ui/dashboard/file_menu.cljs:209, src/app/main/ui/dashboard/import.cljs:521, src/app/main/ui/dashboard/team.cljs:787, src/app/main/ui/exports/files.cljs:173, src/app/main/ui/onboarding/newsletter.cljs:106, src/app/main/ui/settings/subscription.cljs:279, src/app/main/ui/settings/subscription.cljs:313 +#: src/app/main/ui/auth/login.cljs:204, +#: src/app/main/ui/dashboard/deleted.cljs:43, +#: src/app/main/ui/dashboard/deleted.cljs:275, +#: src/app/main/ui/dashboard/file_menu.cljs:209, +#: src/app/main/ui/dashboard/import.cljs:521, +#: src/app/main/ui/dashboard/team.cljs:787, +#: src/app/main/ui/exports/files.cljs:173, +#: src/app/main/ui/onboarding/newsletter.cljs:106, +#: src/app/main/ui/settings/subscription.cljs:279, +#: src/app/main/ui/settings/subscription.cljs:313 msgid "labels.continue" msgstr "계속하기" #: src/app/main/ui/dashboard/team.cljs:650 msgid "labels.copy-invitation-link" -msgstr "링크 복사하기" +msgstr "링크 복사" -#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:203 +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:203 msgid "labels.create" -msgstr "생성하기" +msgstr "생성" -#: src/app/main/ui/dashboard/team_form.cljs:100, src/app/main/ui/dashboard/team_form.cljs:120 +#: src/app/main/ui/dashboard/team_form.cljs:100, +#: src/app/main/ui/dashboard/team_form.cljs:120 msgid "labels.create-team" -msgstr "새로운 팀 만들기" +msgstr "새 팀 생성" #: src/app/main/ui/dashboard/team_form.cljs:112 msgid "labels.create-team.placeholder" -msgstr "새로운 팀명 입력하세요" +msgstr "새 팀 이름을 입력하세요" -#, unused msgid "labels.custom-fonts" -msgstr "커스텀 폰트" +msgstr "사용자 지정 글꼴" #: src/app/main/ui/settings/sidebar.cljs:84 msgid "labels.dashboard" msgstr "대시보드" -#: src/app/main/ui/dashboard/file_menu.cljs:336, src/app/main/ui/dashboard/fonts.cljs:267, src/app/main/ui/dashboard/fonts.cljs:343, src/app/main/ui/dashboard/fonts.cljs:357, src/app/main/ui/dashboard/project_menu.cljs:115, src/app/main/ui/dashboard/team.cljs:1158, src/app/main/ui/settings/access_tokens.cljs:196, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:223, src/app/main/ui/workspace/sidebar/versions.cljs:216, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:290, src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:82, src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, src/app/main/ui/workspace/tokens/themes/create_modal.cljs:381 +#: src/app/main/ui/dashboard/file_menu.cljs:336, +#: src/app/main/ui/dashboard/fonts.cljs:267, +#: src/app/main/ui/dashboard/fonts.cljs:343, +#: src/app/main/ui/dashboard/fonts.cljs:357, +#: src/app/main/ui/dashboard/project_menu.cljs:115, +#: src/app/main/ui/dashboard/team.cljs:1158, +#: src/app/main/ui/settings/access_tokens.cljs:196, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:223, +#: src/app/main/ui/workspace/sidebar/versions.cljs:216, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:290, +#: src/app/main/ui/workspace/tokens/management/node_context_menu.cljs:82, +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:66, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:381 msgid "labels.delete" -msgstr "삭제하기" +msgstr "삭제" #: src/app/main/ui/comments.cljs:997 msgid "labels.delete-comment" -msgstr "코멘트 삭제하기" +msgstr "댓글 삭제" #: src/app/main/ui/comments.cljs:919 msgid "labels.delete-comment-thread" -msgstr "스레드 제거하기" +msgstr "스레드 삭제" #: src/app/main/ui/dashboard/team.cljs:941 msgid "labels.delete-invitation" -msgstr "초대장 제거하기" +msgstr "초대 삭제" -#: src/app/main/ui/dashboard/file_menu.cljs:30, src/app/main/ui/dashboard/files.cljs:80, src/app/main/ui/dashboard/files.cljs:179, src/app/main/ui/dashboard/projects.cljs:229, src/app/main/ui/dashboard/projects.cljs:233, src/app/main/ui/dashboard/sidebar.cljs:820 +#: src/app/main/ui/dashboard/file_menu.cljs:30, +#: src/app/main/ui/dashboard/files.cljs:80, +#: src/app/main/ui/dashboard/files.cljs:179, +#: src/app/main/ui/dashboard/projects.cljs:229, +#: src/app/main/ui/dashboard/projects.cljs:233, +#: src/app/main/ui/dashboard/sidebar.cljs:820 msgid "labels.drafts" msgstr "초안" -#: src/app/main/ui/comments.cljs:993, src/app/main/ui/dashboard/fonts.cljs:264, src/app/main/ui/dashboard/team.cljs:1156, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:218, src/app/main/ui/workspace/tokens/themes.cljs:52 +#: src/app/main/ui/comments.cljs:993, src/app/main/ui/dashboard/fonts.cljs:264, +#: src/app/main/ui/dashboard/team.cljs:1156, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:218, +#: src/app/main/ui/workspace/tokens/themes.cljs:52 msgid "labels.edit" msgstr "편집" -#, unused msgid "labels.edit-file" msgstr "파일 편집" -#: src/app/main/ui/dashboard/team.cljs:132, src/app/main/ui/dashboard/team.cljs:317, src/app/main/ui/dashboard/team.cljs:566, src/app/main/ui/dashboard/team.cljs:599, src/app/main/ui/onboarding/team_choice.cljs:57 +#: src/app/main/ui/dashboard/team.cljs:132, +#: src/app/main/ui/dashboard/team.cljs:317, +#: src/app/main/ui/dashboard/team.cljs:566, +#: src/app/main/ui/dashboard/team.cljs:599, +#: src/app/main/ui/onboarding/team_choice.cljs:57 msgid "labels.editor" msgstr "작성자" #: src/app/main/ui/dashboard/team.cljs:668 msgid "labels.expired-invitation" -msgstr "기한이 만료된" +msgstr "만료됨" -#: src/app/main/ui/exports/assets.cljs:172, src/app/main/ui/workspace/tokens/sidebar.cljs:134 +#: src/app/main/ui/exports/assets.cljs:172, +#: src/app/main/ui/workspace/tokens/sidebar.cljs:134 msgid "labels.export" msgstr "내보내기" #: src/app/main/ui/dashboard/fonts.cljs:432 msgid "labels.font-family" -msgstr "폰트 패밀리" +msgstr "글꼴 모음" -#, unused msgid "labels.font-providers" -msgstr "폰트 공급자" +msgstr "글꼴 제공자" #: src/app/main/ui/dashboard/fonts.cljs:433 msgid "labels.font-variants" msgstr "스타일" -#: src/app/main/ui/dashboard/fonts.cljs:61, src/app/main/ui/dashboard/sidebar.cljs:833 +#: src/app/main/ui/dashboard/fonts.cljs:61, +#: src/app/main/ui/dashboard/sidebar.cljs:833 msgid "labels.fonts" -msgstr "폰트" +msgstr "글꼴" -#: src/app/main/ui/auth/recovery_request.cljs:104, src/app/main/ui/auth/register.cljs:359, src/app/main/ui/static.cljs:175, src/app/main/ui/viewer/login.cljs:113 +#: src/app/main/ui/auth/recovery_request.cljs:104, +#: src/app/main/ui/auth/register.cljs:359, src/app/main/ui/static.cljs:175, +#: src/app/main/ui/viewer/login.cljs:113 msgid "labels.go-back" msgstr "뒤로 가기" -#: src/app/main/ui/dashboard/sidebar.cljs:887, src/app/main/ui/workspace/main_menu.cljs:136, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1317, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1345, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1533 +#: src/app/main/ui/dashboard/sidebar.cljs:887, +#: src/app/main/ui/workspace/main_menu.cljs:136, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1317, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1345, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1533 msgid "labels.help-center" -msgstr "고객센터" +msgstr "도움말 센터" #: src/app/main/ui/dashboard/team.cljs:1224 msgid "labels.inactive" @@ -900,9 +1002,10 @@ msgstr "프로젝트" #: src/app/main/ui/dashboard/deleted.cljs:208 msgid "labels.recent" -msgstr "최근" +msgstr "최근 항목" -#: src/app/main/ui/workspace/sidebar/layers.cljs:420, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:787 +#: src/app/main/ui/workspace/sidebar/layers.cljs:420, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:787 msgid "labels.search" msgstr "검색" @@ -910,33 +1013,41 @@ msgstr "검색" msgid "labels.view-only" msgstr "보기 전용" -#: src/app/main/ui/dashboard/team.cljs:131, src/app/main/ui/dashboard/team.cljs:314, src/app/main/ui/dashboard/team.cljs:567, src/app/main/ui/dashboard/team.cljs:603, src/app/main/ui/onboarding/team_choice.cljs:56 +#: src/app/main/ui/dashboard/team.cljs:131, +#: src/app/main/ui/dashboard/team.cljs:314, +#: src/app/main/ui/dashboard/team.cljs:567, +#: src/app/main/ui/dashboard/team.cljs:603, +#: src/app/main/ui/onboarding/team_choice.cljs:56 msgid "labels.viewer" msgstr "뷰어" -#: src/app/main/ui/dashboard/sidebar.cljs:459, src/app/main/ui/dashboard/team.cljs:103, src/app/main/ui/dashboard/team.cljs:113, src/app/main/ui/dashboard/team.cljs:1134 +#: src/app/main/ui/dashboard/sidebar.cljs:459, +#: src/app/main/ui/dashboard/team.cljs:103, +#: src/app/main/ui/dashboard/team.cljs:113, +#: src/app/main/ui/dashboard/team.cljs:1134 msgid "labels.webhooks" msgstr "웹훅" #: src/app/main/ui/comments.cljs:838 msgid "labels.write-new-comment" -msgstr "새 코멘트 쓰기" +msgstr "새 댓글 작성" -#: src/app/main/data/media.cljs:51, src/app/main/data/workspace/media.cljs:228, src/app/main/data/workspace/media.cljs:443 +#: src/app/main/data/media.cljs:51, src/app/main/data/workspace/media.cljs:228, +#: src/app/main/data/workspace/media.cljs:443 msgid "media.loading" -msgstr "이미지 로딩중…" +msgstr "이미지 로드 중…" #: src/app/main/data/common.cljs:120 msgid "modals.add-shared-confirm.accept" -msgstr "공유된 라이브러리로 추가" +msgstr "공유 라이브러리로 추가" #: src/app/main/data/common.cljs:117 msgid "modals.add-shared-confirm.message" -msgstr " " +msgstr "\"%s\"를 공유 라이브러리로 추가" #: src/app/main/ui/settings/change_email.cljs:109 msgid "modals.change-email.confirm-email" -msgstr "새 이메일 인증하기" +msgstr "새 이메일 확인" #: src/app/main/ui/settings/change_email.cljs:102 msgid "modals.change-email.new-email" @@ -944,83 +1055,86 @@ msgstr "새 이메일" #: src/app/main/ui/settings/change_email.cljs:117 msgid "modals.change-email.submit" -msgstr "이메일 변경하기" +msgstr "이메일 변경" #: src/app/main/ui/settings/change_email.cljs:90 msgid "modals.change-email.title" -msgstr "이메일을 변경하세요" +msgstr "이메일 주소 변경" #: src/app/main/ui/dashboard/team.cljs:1127 msgid "modals.create-webhook.submit-label" -msgstr "웹훅 만들기" +msgstr "웹훅 생성" #: src/app/main/ui/dashboard/team.cljs:1092 msgid "modals.create-webhook.title" -msgstr "웹훅 생성하기" +msgstr "웹훅 생성" #: src/app/main/ui/comments.cljs:889 msgid "modals.delete-comment-thread.accept" -msgstr "대회 지우기" +msgstr "대화 삭제" #: src/app/main/ui/comments.cljs:887 msgid "modals.delete-comment-thread.title" -msgstr "대화 지우기" +msgstr "대화 삭제" #: src/app/main/ui/dashboard/file_menu.cljs:125 msgid "modals.delete-file-confirm.accept" -msgstr "파일 지우기" +msgstr "파일 삭제" #: src/app/main/ui/dashboard/file_menu.cljs:124 msgid "modals.delete-file-confirm.message" -msgstr "이 파일을 정말로 지우시겠습니까?" +msgstr "이 파일을 삭제하시겠습니까?" #: src/app/main/ui/dashboard/file_menu.cljs:123 msgid "modals.delete-file-confirm.title" -msgstr "파일 삭제중" +msgstr "파일 삭제 중" #: src/app/main/ui/dashboard/file_menu.cljs:119 msgid "modals.delete-file-multi-confirm.accept" -msgstr "여러 파일 지우기" +msgstr "여러 파일 삭제" #: src/app/main/ui/dashboard/fonts.cljs:355 msgid "modals.delete-font-variant.title" -msgstr "폰트 스타일 지우는 중" +msgstr "글꼴 스타일 지우는 중" #: src/app/main/ui/dashboard/fonts.cljs:341 msgid "modals.delete-font.title" -msgstr "폰트 지우는 중" +msgstr "글꼴 삭제 중" -#: src/app/main/ui/workspace/context_menu.cljs:675, src/app/main/ui/workspace/sidebar/sitemap.cljs:95 +#: src/app/main/ui/workspace/context_menu.cljs:675, +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:95 msgid "modals.delete-page.body" -msgstr "정말로 해당 페이지를 지우시겠습니까?" +msgstr "이 페이지를 삭제하시겠습니까?" -#: src/app/main/ui/workspace/context_menu.cljs:674, src/app/main/ui/workspace/sidebar/sitemap.cljs:94 +#: src/app/main/ui/workspace/context_menu.cljs:674, +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:94 msgid "modals.delete-page.title" msgstr "페이지 삭제" #: src/app/main/ui/dashboard/project_menu.cljs:73 msgid "modals.delete-project-confirm.accept" -msgstr "프로젝트 제거" +msgstr "프로젝트 삭제" #: src/app/main/ui/dashboard/project_menu.cljs:72 msgid "modals.delete-project-confirm.message" -msgstr "정말로 해당 프로젝트를 지우시겠습니까?" +msgstr "이 프로젝트를 삭제하시겠습니까?" #: src/app/main/ui/dashboard/project_menu.cljs:71 msgid "modals.delete-project-confirm.title" -msgstr "프로젝트 제거" +msgstr "프로젝트 삭제" -#: src/app/main/ui/settings/options.cljs:27, src/app/main/ui/settings/profile.cljs:30 +#: src/app/main/ui/settings/options.cljs:27, +#: src/app/main/ui/settings/profile.cljs:30 msgid "notifications.profile-saved" msgstr "프로필이 성공적으로 저장되었습니다!" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:115 msgid "shortcuts.flip-horizontal" -msgstr "가로로 뒤집기" +msgstr "좌우 반전" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:116 msgid "shortcuts.flip-vertical" -msgstr "세로로 뒤집기" +msgstr "상하 반전" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:119 msgid "shortcuts.go-to-drafts" @@ -1028,16 +1142,8019 @@ msgstr "초안으로 가기" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:120 msgid "shortcuts.go-to-libs" -msgstr "공유된 라이브러리로 가기" +msgstr "공유 라이브러리로 이동" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:121 msgid "shortcuts.go-to-search" -msgstr "찾기" +msgstr "검색" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:122 msgid "shortcuts.group" -msgstr "그룹" +msgstr "그룹화" #: src/app/main/ui/workspace/sidebar/shortcuts.cljs:123 msgid "shortcuts.h-distribute" -msgstr "가로로 분배하기" +msgstr "수평 간격 동일하게" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 +msgid "color-row.token-color-row.deleted-token" +msgstr "해당 token이 존재하지 않거나 삭제되었습니다." + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 +msgid "color-token.empty-state" +msgstr "" +"사용 가능한 색상 token이 없습니다. 활성 세트/테마를 확인하거나 새 token을 추" +"가하세요." + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "새 조직 생성" + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "%s에 삭제될 예정" + +#: src/app/main/ui/dashboard/files.cljs:203, +#: src/app/main/ui/dashboard/projects.cljs:290 +msgid "dashboard.empty-placeholder-drafts-subtitle" +msgstr "프로젝트 구성원이 초안을 생성하면 이곳에 표시됩니다." + +#: src/app/main/ui/dashboard/files.cljs:198, +#: src/app/main/ui/dashboard/projects.cljs:285 +msgid "dashboard.empty-placeholder-drafts-title" +msgstr "아직 초안이 없습니다." + +#: src/app/main/ui/dashboard/deleted.cljs:176, +#: src/app/main/ui/dashboard/files.cljs:204, +#: src/app/main/ui/dashboard/projects.cljs:291 +msgid "dashboard.empty-placeholder-files-subtitle" +msgstr "프로젝트 구성원이 파일을 생성하면 이곳에 표시됩니다." + +#: src/app/main/ui/dashboard/deleted.cljs:173, +#: src/app/main/ui/dashboard/files.cljs:199, +#: src/app/main/ui/dashboard/projects.cljs:286 +msgid "dashboard.empty-placeholder-files-title" +msgstr "아직 파일이 없습니다." + +#: src/app/main/ui/dashboard/placeholder.cljs:118 +msgid "dashboard.empty-placeholder-libraries" +msgstr "" +"프로젝트에 추가된 라이브러리가 여기에 표시됩니다. 파일을 공유해보거나, " +"[Libraries & templates](https://penpot.app/libraries-templates)에서 추가해보" +"세요." + +#: src/app/main/ui/dashboard/placeholder.cljs +msgid "dashboard.empty-placeholder-libraries-subtitle" +msgstr "" +"프로젝트에 추가된 라이브러리가 여기에 표시됩니다. 파일을 공유하거나 라이브러리 " +"및 템플릿에서 추가해 보세요." + +#: src/app/main/ui/dashboard/placeholder.cljs:114 +msgid "dashboard.empty-placeholder-libraries-subtitle-viewer-role" +msgstr "프로젝트에 추가된 라이브러리가 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/placeholder.cljs:59 +msgid "dashboard.empty-project.add-library" +msgstr "라이브러리 또는 템플릿 추가" + +#: src/app/main/ui/dashboard/placeholder.cljs:43, +#: src/app/main/ui/dashboard/placeholder.cljs:134 +msgid "dashboard.empty-project.create" +msgstr "새 파일 생성" + +#: src/app/main/ui/dashboard/placeholder.cljs:61 +msgid "dashboard.empty-project.explore" +msgstr "몇 가지를 둘러보고 추가해보세요" + +#: src/app/main/ui/dashboard/placeholder.cljs:57 +msgid "dashboard.empty-project.go-to-libraries" +msgstr "Libraries and Templates로 이동" + +#: src/app/main/ui/dashboard/placeholder.cljs:49, +#: src/app/main/ui/dashboard/placeholder.cljs:51 +msgid "dashboard.empty-project.import" +msgstr "파일 가져오기" + +#: src/app/main/ui/dashboard/placeholder.cljs:53 +msgid "dashboard.empty-project.import-penpot" +msgstr ".penpot 파일 가져오기" + +#: src/app/main/ui/dashboard/placeholder.cljs:45 +msgid "dashboard.empty-project.start" +msgstr "놀라운 것들을 만들기 시작하세요" + +msgid "dashboard.errors.error-on-delete-file" +msgstr "%s 파일 삭제 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "파일을 삭제하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "%s 프로젝트를 삭제하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:909, +#: src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "%s 파일을 복원하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "파일을 복원하는 중 오류가 발생했습니다." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "%s 프로젝트와 해당 파일을 복원하는 중 오류가 발생했습니다." + +msgid "dashboard.export-multi" +msgstr "Penpot 파일 %s개 내보내기" + +#: src/app/main/ui/exports/assets.cljs:108 +msgid "dashboard.export-multiple.selected" +msgstr "전체 %s개 중 %s개 요소가 선택됨" + +#: src/app/main/ui/exports/assets.cljs:179 +msgid "dashboard.export-shapes.how-to" +msgstr "" +"디자인 속성(오른쪽 사이드바 하단)에서 요소에 내보내기 설정을 추가할 수 있습니" +"다." + +#: src/app/main/ui/exports/assets.cljs:183 +msgid "dashboard.export-shapes.how-to-link" +msgstr "Penpot에서 내보내기 설정 방법 안내." + +#: src/app/main/ui/exports/assets.cljs:178 +msgid "dashboard.export-shapes.no-elements" +msgstr "내보내기 설정이 지정된 요소가 없습니다." + +#: src/app/main/ui/exports/assets.cljs:189 +msgid "dashboard.export-shapes.title" +msgstr "선택 영역 내보내기" + +#: src/app/main/ui/dashboard/file_menu.cljs:262 +msgid "dashboard.export-standard-multi" +msgstr "표준 파일 %s개(.svg + .json) 다운로드" + +#: src/app/main/ui/exports/files.cljs:155 +msgid "dashboard.export.explain" +msgstr "" +"다운로드하려는 하나 이상의 파일이 공유 라이브러리를 사용 중입니다. 해당 에셋*" +"을 어떻게 처리하시겠습니까?" + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "파일 삭제" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "파일 복원" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:322 +msgid "dashboard.fonts.deleted-placeholder" +msgstr "누락된 글꼴" + +#: src/app/main/ui/dashboard/fonts.cljs:218 +msgid "dashboard.fonts.dismiss-all" +msgstr "모두 무시" + +#: src/app/main/ui/dashboard/fonts.cljs:455 +msgid "dashboard.fonts.empty-placeholder" +msgstr "업로드한 사용자 지정 글꼴이 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/fonts.cljs:458 +msgid "dashboard.fonts.empty-placeholder-viewer" +msgstr "아직 사용자 지정 폰트가 없습니다." + +#: src/app/main/ui/dashboard/fonts.cljs:459 +msgid "dashboard.fonts.empty-placeholder-viewer-sub" +msgstr "프로젝트 구성원이 사용자 지정 폰트를 업로드하면 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/fonts.cljs:206 +msgid "dashboard.fonts.fonts-added" +msgid_plural "dashboard.fonts.fonts-added" +msgstr[0] "글꼴 %s개 추가됨" + +#: src/app/main/ui/dashboard/fonts.cljs:181 +msgid "dashboard.fonts.hero-text1" +msgstr "" +"여기에 업로드하는 모든 웹 글꼴은 이 팀의 파일 텍스트 속성에서 사용할 수 있는 " +"글꼴 모음 목록에 추가됩니다. 동일한 글꼴 모음 이름을 가진 글꼴들은**하나의 글" +"꼴 모음**으로 그룹화됩니다. 지원 형식: **TTF, OTF, WOFF** (하나만 필요)." + +#: src/app/main/ui/dashboard/fonts.cljs:194 +msgid "dashboard.fonts.hero-text2" +msgstr "" +"Penpot에서 사용하기 위해서는 본인이 소유하거나 적법한 사용 라이선스를 보유한 " +"글꼴만 업로드해야 합니다. 관련 내용은 [Penpot의 서비스 이용약관](%s)의 콘텐" +"츠 권리 섹션에서 확인할 수 있습니다. 추가로 [글꼴 라이선스 안내](https://" +"www.typography.com/faq)를 참고하시기 바랍니다." + +#: src/app/main/ui/dashboard/fonts.cljs:214 +msgid "dashboard.fonts.upload-all" +msgstr "모두 업로드" + +#: src/app/main/ui/dashboard/fonts.cljs:199 +msgid "dashboard.fonts.warning-text" +msgstr "" +"운영 체제별 수직 메트릭과 관련된 폰트 문제를 감지했습니다. 확인을 위해 [이 서" +"비스](https://vertical-metrics.netlify.app/)와 같은 수직 메트릭 서비스를 사용" +"할 수 있습니다. 또한, 웹 폰트 생성 및 오류 수정을 위해 [Transfonter](https://" +"transfonter.org/) 사용을 권장합니다. " + +#: src/app/main/ui/dashboard/import.cljs:464, +#: src/app/main/ui/dashboard/project_menu.cljs:109 +msgid "dashboard.import" +msgstr "Penpot 파일 가져오기" + +#: src/app/main/ui/dashboard/import.cljs:293, src/app/worker/import.cljs:121, +#: src/app/worker/import.cljs:124 +msgid "dashboard.import.analyze-error" +msgstr "이런! 이 파일을 가져올 수 없습니다" + +msgid "dashboard.import.analyze-error.components-v2" +msgstr "" +"컴포넌트 v2가 활성화된 파일이지만, 이 팀은 아직 이를 지원하지 않습니다." + +#: src/app/main/ui/dashboard.cljs:259 +msgid "dashboard.import.bad-url" +msgstr "가져오기 실패. 템플릿 URL이 올바르지 않습니다" + +#: src/app/main/ui/dashboard.cljs:241 +msgid "dashboard.import.error" +msgstr "가져오기 실패. 다시 시도해주세요" + +#: src/app/main/ui/dashboard/import.cljs:292 +msgid "dashboard.import.import-error" +msgstr "파일을 가져오는 중 문제가 발생했습니다. 파일이 누락되었습니다." + +#: src/app/main/ui/dashboard/import.cljs:485 +msgid "dashboard.import.import-error.disclaimer" +msgstr "일부 파일이 누락되었습니다" + +#: src/app/main/ui/dashboard/import.cljs:489 +msgid "dashboard.import.import-error.message1" +msgstr "다음 파일에 오류가 있습니다:" + +#: src/app/main/ui/dashboard/import.cljs:494 +msgid "dashboard.import.import-error.message2" +msgstr "오류가 있는 파일은 업로드되지 않습니다." + +#: src/app/main/ui/dashboard/import.cljs:479 +msgid "dashboard.import.import-message" +msgid_plural "dashboard.import.import-message" +msgstr[0] "파일 %s개를 성공적으로 가져왔습니다." + +#: src/app/main/ui/dashboard/import.cljs:474 +msgid "dashboard.import.import-warning" +msgstr "일부 파일에 포함된 유효하지 않은 객체가 제거되었습니다." + +#: src/app/main/ui/dashboard.cljs:260 +msgid "dashboard.import.no-perms" +msgstr "이 팀으로 가져올 권한이 없습니다" + +#: src/app/main/ui/dashboard/import.cljs:128 +msgid "dashboard.import.progress.process-colors" +msgstr "컬러 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:137, +#: src/app/main/ui/dashboard/import.cljs:140 +msgid "dashboard.import.progress.process-components" +msgstr "컴포넌트 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:134 +msgid "dashboard.import.progress.process-media" +msgstr "미디어 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:125 +msgid "dashboard.import.progress.process-page" +msgstr "페이지 처리 중: %s" + +#: src/app/main/ui/dashboard/import.cljs:131 +msgid "dashboard.import.progress.process-typographies" +msgstr "타이포그래피 처리 중" + +#: src/app/main/ui/dashboard/import.cljs:119 +msgid "dashboard.import.progress.upload-data" +msgstr "서버로 데이터 업로드 중 (%s/%s)" + +#: src/app/main/ui/dashboard/import.cljs:122 +msgid "dashboard.import.progress.upload-media" +msgstr "파일 업로드 중: %s" + +#: src/app/main/ui/dashboard/team.cljs:765 +msgid "dashboard.invitation-modal.delete" +msgstr "다음에 대한 초대를 삭제합니다:" + +#: src/app/main/ui/dashboard/team.cljs:766 +msgid "dashboard.invitation-modal.resend" +msgstr "다음에 대한 초대를 재전송합니다:" + +#: src/app/main/ui/dashboard/team.cljs:756 +msgid "dashboard.invitation-modal.title.delete-invitations" +msgstr "초대 삭제" + +#: src/app/main/ui/dashboard/team.cljs:757 +msgid "dashboard.invitation-modal.title.resend-invitations" +msgstr "초대 재전송" + +#: src/app/main/ui/dashboard/team.cljs:122, +#: src/app/main/ui/dashboard/team.cljs:744 +msgid "dashboard.invite-profile" +msgstr "사람 초대" + +#: src/app/main/ui/dashboard/sidebar.cljs:477, +#: src/app/main/ui/dashboard/sidebar.cljs:484, +#: src/app/main/ui/dashboard/sidebar.cljs:489, +#: src/app/main/ui/dashboard/team.cljs:351 +msgid "dashboard.leave-team" +msgstr "팀 나가기" + +#: src/app/main/ui/dashboard/templates.cljs:84, +#: src/app/main/ui/dashboard/templates.cljs:169 +msgid "dashboard.libraries-and-templates" +msgstr "라이브러리 및 템플릿" + +#: src/app/main/ui/dashboard/templates.cljs:267 +msgid "dashboard.libraries-and-templates.description" +msgstr "프로젝트에 추가할 수 있는 라이브러리와 템플릿입니다" + +#: src/app/main/ui/dashboard/templates.cljs:170 +msgid "dashboard.libraries-and-templates.explore" +msgstr "더 많은 항목을 둘러보고 기여하는 방법을 알아보세요" + +#: src/app/main/ui/dashboard/import.cljs:365, +#: src/app/main/ui/workspace/libraries.cljs:145 +msgid "dashboard.libraries-and-templates.import-error" +msgstr "템플릿을 가져오는 중 문제가 발생했습니다. 템플릿이 누락되었습니다." + +#: src/app/main/ui/dashboard/libraries.cljs:69 +msgid "dashboard.libraries-title" +msgstr "라이브러리" + +#: src/app/main/ui/dashboard/placeholder.cljs:143 +msgid "dashboard.loading-files" +msgstr "파일 로드 중…" + +#: src/app/main/ui/dashboard/fonts.cljs:449 +msgid "dashboard.loading-fonts" +msgstr "폰트 로드 중…" + +#: src/app/main/data/comments.cljs:473 +msgid "dashboard.mark-all-as-read.success" +msgstr "모든 알림을 읽음으로 표시했습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:312, +#: src/app/main/ui/dashboard/project_menu.cljs:101 +msgid "dashboard.move-to" +msgstr "다음으로 이동" + +#: src/app/main/ui/dashboard/file_menu.cljs:276 +msgid "dashboard.move-to-multi" +msgstr "%s개 파일을 다음으로 이동" + +#: src/app/main/ui/dashboard/file_menu.cljs:248 +msgid "dashboard.move-to-other-team" +msgstr "다른 팀으로 이동" + +#: src/app/main/ui/dashboard/files.cljs:107, +#: src/app/main/ui/dashboard/projects.cljs:257, +#: src/app/main/ui/dashboard/projects.cljs:258 +msgid "dashboard.new-file" +msgstr "+ 새 파일" + +#: src/app/main/data/dashboard.cljs:536, src/app/main/data/dashboard.cljs:648 +msgid "dashboard.new-file-prefix" +msgstr "새 파일" + +#: src/app/main/ui/dashboard/projects.cljs:62 +msgid "dashboard.new-project" +msgstr "+ 새 프로젝트" + +#: src/app/main/data/dashboard.cljs:289, src/app/main/data/dashboard.cljs:651 +msgid "dashboard.new-project-prefix" +msgstr "새 프로젝트" + +#: src/app/main/ui/dashboard/search.cljs:77 +msgid "dashboard.no-matches-for" +msgstr "\"%s\"에 대한 검색 결과가 없습니다" + +#: src/app/main/ui/dashboard/comments.cljs:91 +msgid "dashboard.notifications" +msgstr "알림" + +#: src/app/main/ui/auth/verify_token.cljs:34 +msgid "dashboard.notifications.email-changed-successfully" +msgstr "이메일 주소가 성공적으로 업데이트되었습니다" + +#: src/app/main/ui/auth/verify_token.cljs:28 +msgid "dashboard.notifications.email-verified-successfully" +msgstr "이메일 주소가 성공적으로 인증되었습니다" + +#: src/app/main/data/profile.cljs:280 +msgid "dashboard.notifications.notifications-saved" +msgstr "알림 설정이 업데이트되었습니다" + +#: src/app/main/ui/settings/password.cljs:38 +msgid "dashboard.notifications.password-saved" +msgstr "비밀번호가 성공적으로 저장되었습니다!" + +#: src/app/main/ui/dashboard/comments.cljs:45 +msgid "dashboard.notifications.view" +msgstr "알림 보기" + +#: src/app/main/ui/dashboard/team.cljs:1340 +msgid "dashboard.num-of-members" +msgstr "멤버 %s명" + +#: src/app/main/ui/dashboard/file_menu.cljs:295 +msgid "dashboard.open-in-new-tab" +msgstr "새 탭에서 파일 열기" + +#: src/app/main/ui/dashboard/deleted.cljs:157, +#: src/app/main/ui/dashboard/deleted.cljs:158, +#: src/app/main/ui/dashboard/files.cljs:120, +#: src/app/main/ui/dashboard/grid.cljs:442, +#: src/app/main/ui/dashboard/projects.cljs:266, +#: src/app/main/ui/dashboard/projects.cljs:267 +msgid "dashboard.options" +msgstr "옵션" + +#: src/app/main/ui/dashboard/team.cljs:949 +msgid "dashboard.order-invitations-by-role" +msgstr "역할순 정렬" + +#: src/app/main/ui/dashboard/team.cljs:958 +msgid "dashboard.order-invitations-by-status" +msgstr "상태순 정렬" + +#: src/app/main/ui/settings/password.cljs:96, +#: src/app/main/ui/settings/password.cljs:109 +msgid "dashboard.password-change" +msgstr "비밀번호 변경" + +#: src/app/main/data/common.cljs:192 +msgid "dashboard.permissions-change.admin" +msgstr "이제 이 팀의 관리자(Admin)입니다." + +#: src/app/main/data/common.cljs:191 +msgid "dashboard.permissions-change.editor" +msgstr "이제 이 팀의 에디터(Editor)입니다." + +#: src/app/main/data/common.cljs:193 +msgid "dashboard.permissions-change.owner" +msgstr "이제 이 팀의 소유자(Owner)입니다." + +#: src/app/main/data/common.cljs:190 +msgid "dashboard.permissions-change.viewer" +msgstr "이제 이 팀의 뷰어(Viewer)입니다." + +#: src/app/main/ui/dashboard/pin_button.cljs:23, +#: src/app/main/ui/dashboard/project_menu.cljs:96 +msgid "dashboard.pin-unpin" +msgstr "고정/고정 해제" + +#: src/app/main/ui/dashboard.cljs:223 +msgid "dashboard.plugins.bad-url" +msgstr "플러그인 URL이 올바르지 않습니다" + +#: src/app/main/ui/dashboard.cljs:221 +msgid "dashboard.plugins.parse-error" +msgstr "플러그인 매니페스트를 구문 분석할 수 없습니다" + +#: src/app/main/ui/dashboard.cljs:184 +msgid "dashboard.plugins.try-plugin" +msgstr "플러그인 체험하기: " + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "파일 삭제 중…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "파일 복원 중…" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "삭제가 예상보다 느립니다" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "복원이 예상보다 느립니다" + +#: src/app/main/ui/settings/profile.cljs:86 +msgid "dashboard.remove-account" +msgstr "계정을 삭제하시겠습니까?" + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "dashboard.remove-shared" +msgstr "공유 라이브러리에서 제거" + +#: src/app/main/data/common.cljs:225 +msgid "dashboard.removed-from-team" +msgstr "더 이상 \"%s\" 팀의 멤버가 아닙니다." + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "모두 복원" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "%s개 파일이 성공적으로 복원되었습니다." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "프로젝트 복원" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "%s 프로젝트와 그 안에 포함된 모든 파일을 복원하려고 합니다." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "프로젝트 복원" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, +#: src/app/main/data/dashboard.cljs:939, +#: src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s이(가) 성공적으로 복원되었습니다." + +#: src/app/main/ui/settings/profile.cljs:78 +msgid "dashboard.save-settings" +msgstr "설정 저장" + +#: src/app/main/ui/settings/options.cljs:58 +msgid "dashboard.select-ui-language" +msgstr "UI 언어 선택" + +#: src/app/main/ui/settings/options.cljs:65 +msgid "dashboard.select-ui-theme" +msgstr "테마 선택" + +#: src/app/main/ui/settings/options.cljs:68 +msgid "dashboard.select-ui-theme.dark" +msgstr "Penpot 다크 (기본)" + +#: src/app/main/ui/settings/options.cljs:69 +msgid "dashboard.select-ui-theme.light" +msgstr "Penpot 라이트" + +#: src/app/main/ui/settings/options.cljs:70 +msgid "dashboard.select-ui-theme.system" +msgstr "시스템 테마" + +#: src/app/main/ui/settings/notifications.cljs:57 +msgid "dashboard.settings.notifications.dashboard-comments.all" +msgstr "모든 댓글, 멘션 및 답글" + +#: src/app/main/ui/settings/notifications.cljs:59 +msgid "dashboard.settings.notifications.dashboard-comments.none" +msgstr "없음" + +#: src/app/main/ui/settings/notifications.cljs:58 +msgid "dashboard.settings.notifications.dashboard-comments.partial" +msgstr "멘션 및 답글만" + +#: src/app/main/ui/settings/notifications.cljs:54 +msgid "dashboard.settings.notifications.dashboard-comments.title" +msgstr "파일 댓글" + +#: src/app/main/ui/settings/notifications.cljs:53 +msgid "dashboard.settings.notifications.dashboard.title" +msgstr "대시보드 알림" + +#: src/app/main/ui/settings/notifications.cljs:67 +msgid "dashboard.settings.notifications.email-comments.all" +msgstr "모든 댓글, 멘션 및 답글" + +#: src/app/main/ui/settings/notifications.cljs:69 +msgid "dashboard.settings.notifications.email-comments.none" +msgstr "없음" + +#: src/app/main/ui/settings/notifications.cljs:68 +msgid "dashboard.settings.notifications.email-comments.partial" +msgstr "멘션 및 답글만" + +#: src/app/main/ui/settings/notifications.cljs:64 +msgid "dashboard.settings.notifications.email-comments.title" +msgstr "파일 댓글" + +#: src/app/main/ui/settings/notifications.cljs:76 +msgid "dashboard.settings.notifications.email-invites.all" +msgstr "모든 종류의 초대 및 요청" + +#: src/app/main/ui/settings/notifications.cljs:79 +msgid "dashboard.settings.notifications.email-invites.none" +msgstr "없음" + +#: src/app/main/ui/settings/notifications.cljs:73 +msgid "dashboard.settings.notifications.email-invites.title" +msgstr "초대 및 요청" + +#: src/app/main/ui/settings/notifications.cljs:63 +msgid "dashboard.settings.notifications.email.title" +msgstr "이메일 알림" + +#: src/app/main/ui/settings/notifications.cljs:84 +msgid "dashboard.settings.notifications.submit" +msgstr "설정 업데이트" + +#: src/app/main/ui/settings/notifications.cljs:52 +msgid "dashboard.settings.notifications.title" +msgstr "알림" + +#: src/app/main/ui/dashboard/projects.cljs:309 +msgid "dashboard.show-all-files" +msgstr "모든 파일 보기" + +#: src/app/main/ui/workspace/main_menu.cljs:668 +msgid "dashboard.show-version-history" +msgstr "버전 히스토리" + +#: src/app/main/ui/dashboard/file_menu.cljs:98 +msgid "dashboard.success-delete-file" +msgid_plural "dashboard.success-delete-file" +msgstr[0] "파일이 성공적으로 삭제되었습니다" + +#: src/app/main/ui/dashboard/project_menu.cljs:63 +msgid "dashboard.success-delete-project" +msgstr "프로젝트가 성공적으로 삭제되었습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:93 +msgid "dashboard.success-duplicate-file" +msgid_plural "dashboard.success-delete-file" +msgstr[0] "파일이 성공적으로 복사되었습니다" + +#: src/app/main/ui/dashboard/project_menu.cljs:35 +msgid "dashboard.success-duplicate-project" +msgstr "프로젝트가 성공적으로 복제되었습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:132, +#: src/app/main/ui/dashboard/grid.cljs:634, +#: src/app/main/ui/dashboard/sidebar.cljs:166 +msgid "dashboard.success-move-file" +msgstr "파일이 성공적으로 이동되었습니다" + +#: src/app/main/ui/dashboard/file_menu.cljs:131 +msgid "dashboard.success-move-files" +msgstr "파일들이 성공적으로 이동되었습니다" + +#: src/app/main/ui/dashboard/project_menu.cljs:57 +msgid "dashboard.success-move-project" +msgstr "프로젝트가 성공적으로 이동되었습니다" + +#: src/app/main/ui/dashboard/team.cljs:1323 +msgid "dashboard.team-info" +msgstr "팀 정보" + +#: src/app/main/ui/dashboard/team.cljs:1329 +msgid "dashboard.team-members" +msgstr "팀 구성원" + +#: src/app/main/ui/dashboard/templates.cljs:134 +msgid "dashboard.template.add-to-project" +msgstr "프로젝트에 추가" + +#: src/app/main/ui/settings/options.cljs:63 +msgid "dashboard.theme-change" +msgstr "UI 테마" + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "삭제된 파일은 휴지통에" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s일 동안 보관됩니다. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "그 이후에는 영구적으로 삭제됩니다." + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "필요한 경우, 각 파일 메뉴에서 복원하거나 영구 삭제할 수 있습니다." + +#: src/app/main/ui/dashboard/file_menu.cljs:319, +#: src/app/main/ui/workspace/main_menu.cljs:642 +msgid "dashboard.unpublish-shared" +msgstr "라이브러리 게시 취소" + +#: src/app/main/ui/settings/options.cljs:74 +msgid "dashboard.update-settings" +msgstr "설정 업데이트" + +#: src/app/main/ui/dashboard/sidebar.cljs:1071 +msgid "dashboard.upgrade-plan.no-limits" +msgstr "창의력에 한계를 두지 마세요" + +#: src/app/main/ui/dashboard/sidebar.cljs:1069 +msgid "dashboard.upgrade-plan.penpot-free" +msgstr "Penpot 무료 버전" + +#: src/app/main/ui/dashboard/team.cljs:1115 +msgid "dashboard.webhooks.active" +msgstr "활성화 상태" + +#: src/app/main/ui/dashboard/team.cljs:1116 +msgid "dashboard.webhooks.active.explain" +msgstr "이 훅이 실행되면 이벤트 상세 정보가 전달됩니다" + +#: src/app/main/ui/dashboard/team.cljs:1160 +msgid "dashboard.webhooks.cant-edit" +msgstr "본인이 생성한 웹훅만 삭제하거나 수정할 수 있습니다." + +#: src/app/main/ui/dashboard/team.cljs:1106 +msgid "dashboard.webhooks.content-type" +msgstr "콘텐츠 유형" + +#: src/app/main/ui/dashboard/team.cljs:1139 +msgid "dashboard.webhooks.create" +msgstr "웹훅 생성" + +#: src/app/main/ui/dashboard/team.cljs:1031 +msgid "dashboard.webhooks.create.success" +msgstr "웹훅이 성공적으로 생성되었습니다." + +#: src/app/main/ui/dashboard/team.cljs:1136 +msgid "dashboard.webhooks.description" +msgstr "" +"웹훅은 Penpot에서 특정 이벤트가 발생했을 때 다른 웹사이트나 앱이 알림을 받을 " +"수 있는 간단한 방법입니다. 제공하신 각 URL로 POST 요청을 보냅니다." + +#: src/app/main/ui/dashboard/team.cljs:1265 +msgid "dashboard.webhooks.empty.add-one" +msgstr "새 웹훅을 추가하려면 \"웹훅 추가\" 버튼을 누르세요." + +#: src/app/main/ui/dashboard/team.cljs:1264 +msgid "dashboard.webhooks.empty.no-webhooks" +msgstr "아직 생성된 웹훅이 없습니다." + +msgid "dashboard.webhooks.update.success" +msgstr "웹훅이 성공적으로 업데이트되었습니다." + +#: src/app/main/ui/settings.cljs:34 +msgid "dashboard.your-account-title" +msgstr "내 계정" + +#: src/app/main/ui/settings/profile.cljs:70 +msgid "dashboard.your-email" +msgstr "이메일" + +#: src/app/main/ui/settings/profile.cljs:62 +msgid "dashboard.your-name" +msgstr "이름" + +#: src/app/main/ui/dashboard/file_menu.cljs:40, +#: src/app/main/ui/dashboard/fonts.cljs:42, +#: src/app/main/ui/dashboard/libraries.cljs:56, +#: src/app/main/ui/dashboard/projects.cljs:355, +#: src/app/main/ui/dashboard/search.cljs:48, +#: src/app/main/ui/dashboard/sidebar.cljs:312, +#: src/app/main/ui/dashboard/team.cljs:537, +#: src/app/main/ui/dashboard/team.cljs:983, +#: src/app/main/ui/dashboard/team.cljs:1251, +#: src/app/main/ui/dashboard/team.cljs:1298 +msgid "dashboard.your-penpot" +msgstr "내 Penpot" + +#: src/app/main/ui/alert.cljs:35 +msgid "ds.alert-ok" +msgstr "확인" + +#: src/app/main/ui/alert.cljs:34, src/app/main/ui/alert.cljs:37 +msgid "ds.alert-title" +msgstr "주의" + +#: src/app/main/ui/confirm.cljs:86 +msgid "ds.component-subtitle" +msgstr "업데이트할 컴포넌트:" + +#: src/app/main/ui/workspace/plugins.cljs:340, +#: src/app/main/ui/workspace/plugins.cljs:394 +msgid "ds.confirm-allow" +msgstr "허용" + +#: src/app/main/ui/comments.cljs:674, src/app/main/ui/confirm.cljs:37, +#: src/app/main/ui/settings/subscription.cljs:273, +#: src/app/main/ui/settings/subscription.cljs:306, +#: src/app/main/ui/workspace/plugins.cljs:334, +#: src/app/main/ui/workspace/plugins.cljs:388 +msgid "ds.confirm-cancel" +msgstr "취소" + +#: src/app/main/ui/confirm.cljs:38, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:157, +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:102 +msgid "ds.confirm-ok" +msgstr "확인" + +#: src/app/main/ui/confirm.cljs:36, src/app/main/ui/confirm.cljs:40 +msgid "ds.confirm-title" +msgstr "정말 진행하시겠습니까?" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:98 +msgid "ds.inputs.numeric-input.no-applicable-tokens" +msgstr "활성 세트나 테마에 적용 가능한 token이 없습니다." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:99 +msgid "ds.inputs.numeric-input.no-matches" +msgstr "일치하는 항목이 없습니다." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:652, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:141 +msgid "ds.inputs.numeric-input.open-token-list-dropdown" +msgstr "token 목록 열기" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 +msgid "ds.inputs.token-field.detach-token" +msgstr "token 해제" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 +msgid "ds.inputs.token-field.no-active-token-option" +msgstr "이 token은 활성 세트에 없거나 유효하지 않은 값을 가지고 있습니다." + +#: src/app/main/data/auth.cljs:339 +msgid "errors.auth-provider-not-allowed" +msgstr "이 프로필에 허용되지 않는 인증 제공자입니다" + +#: src/app/main/data/auth.cljs:189 +msgid "errors.auth-provider-not-configured" +msgstr "인증 제공자가 구성되지 않았습니다." + +#: src/app/main/errors.cljs:126 +msgid "errors.auth.unable-to-login" +msgstr "인증되지 않았거나 세션이 만료된 것 같습니다." + +#: src/app/main/data/fonts.cljs:206, src/app/main/ui/dashboard/fonts.cljs:120 +msgid "errors.bad-font" +msgstr "%s 글꼴을 로드할 수 없습니다" + +#: src/app/main/data/fonts.cljs:205 +msgid "errors.bad-font-plural" +msgstr "%s 글꼴들을 로드할 수 없습니다" + +#: src/app/main/data/workspace/media.cljs:204 +msgid "errors.cannot-upload" +msgstr "미디어 파일을 업로드할 수 없습니다." + +#: src/app/main/ui/comments.cljs:719, src/app/main/ui/comments.cljs:749, +#: src/app/main/ui/comments.cljs:846 +msgid "errors.character-limit-exceeded" +msgstr "글자 수 제한 초과" + +#: src/app/main/data/workspace/clipboard.cljs:481 +msgid "errors.clipboard-not-implemented" +msgstr "브라우저에서 이 작업을 지원하지 않습니다" + +#: src/app/main/errors.cljs:235 +msgid "errors.comment-error" +msgstr "댓글 처리 중 오류가 발생했습니다" + +#: src/app/main/errors.cljs:302 +msgid "errors.deprecated" +msgstr "" +"죄송합니다! 이 파일은 더 이상 지원되지 않는 이전 버전의 Penpot 에셋을 사용하" +"고 있어 열 수 없습니다." + +#: src/app/main/errors.cljs:305 +msgid "errors.deprecated.contact.after" +msgstr "문의해 주시면 도와드리겠습니다." + +#: src/app/main/errors.cljs:303 +msgid "errors.deprecated.contact.before" +msgstr "Penpot은 더 이상 이 유형의 에셋을 지원하지 않지만," + +#: src/app/main/errors.cljs:304 +msgid "errors.deprecated.contact.text" +msgstr "고객 지원팀에 문의" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:338 +msgid "errors.drop-token-set-parent-to-child" +msgstr "상위 세트를 하위 세트의 경로로 옮길 수 없습니다." + +#: src/app/main/ui/auth/verify_token.cljs:84, +#: src/app/main/ui/settings/change_email.cljs:29 +msgid "errors.email-already-exists" +msgstr "이미 사용 중인 이메일입니다" + +#: src/app/main/ui/auth/verify_token.cljs:89 +msgid "errors.email-already-validated" +msgstr "이미 인증된 이메일입니다." + +#: src/app/main/ui/auth/register.cljs:105, +#: src/app/main/ui/settings/password.cljs:29 +msgid "errors.email-as-password" +msgstr "이메일을 비밀번호로 사용할 수 없습니다" + +#: src/app/main/ui/auth/register.cljs:89 +msgid "errors.email-does-not-match-invitation" +msgstr "초대받은 이메일과 일치하지 않습니다." + +#: src/app/main/data/auth.cljs:341, src/app/main/ui/auth/register.cljs:95 +msgid "errors.email-domain-not-allowed" +msgstr "허용되지 않는 도메인입니다" + +#: src/app/main/ui/auth/recovery_request.cljs:57, +#: src/app/main/ui/auth/register.cljs:98, +#: src/app/main/ui/auth/register.cljs:101, +#: src/app/main/ui/dashboard/team.cljs:627, +#: src/app/main/ui/settings/change_email.cljs:37 +msgid "errors.email-has-permanent-bounces" +msgstr "«%s» 이메일은 지속적인 반송(Bounce) 리포트가 발생하고 있습니다." + +#: src/app/main/ui/dashboard/team.cljs:196, +#: src/app/main/ui/dashboard/team.cljs:858, +#: src/app/main/ui/onboarding/team_choice.cljs:110 +msgid "errors.email-spam-or-permanent-bounces" +msgstr "«%s» 이메일은 스팸으로 신고되었거나 지속적으로 반송되고 있습니다." + +#: src/app/main/errors.cljs:279 +msgid "errors.feature-mismatch" +msgstr "" +"'%s' 기능이 활성화된 파일을 열려고 하지만, 현재 Penpot 버전에서 지원하지 않거" +"나 비활성화되어 있습니다." + +#: src/app/main/errors.cljs:283, src/app/main/errors.cljs:297 +msgid "errors.feature-not-supported" +msgstr "'%s' 기능은 지원되지 않습니다." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:296, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:240 +msgid "errors.field-max-length" +msgstr "최대 %s자까지 입력 가능합니다." + +msgid "errors.field-min-length" +msgstr "최소 1자 이상 입력해야 합니다." + +#: src/app/util/forms.cljs:66 +msgid "errors.field-missing" +msgstr "필수 입력 항목입니다" + +#: src/app/main/ui/settings/team-form.cljs, src/app/main/ui/auth/register.cljs, +#: src/app/main/ui/dashboard/team_form.cljs, +#: src/app/main/ui/onboarding/team_choice.cljs, +#: src/app/main/ui/settings/access_tokens.cljs, +#: src/app/main/ui/settings/feedback.cljs, +#: src/app/main/ui/settings/profile.cljs, +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "errors.field-not-all-whitespace" +msgstr "이름은 공백 외에 다른 문자를 포함해야 합니다." + +#: src/app/main/errors.cljs:275 +msgid "errors.file-feature-mismatch" +msgstr "" +"활성화된 기능과 열려는 파일의 기능이 일치하지 않습니다. 파일을 열기 전에 " +"'%s'에 대한 마이그레이션이 필요합니다." + +#: src/app/main/data/auth.cljs:347, src/app/main/ui/auth/login.cljs:104, +#: src/app/main/ui/auth/register.cljs:110, +#: src/app/main/ui/auth/register.cljs:304, +#: src/app/main/ui/auth/verify_token.cljs:94, +#: src/app/main/ui/dashboard/team.cljs:199, +#: src/app/main/ui/dashboard/team.cljs:861, +#: src/app/main/ui/onboarding/team_choice.cljs:113, +#: src/app/main/ui/settings/access_tokens.cljs:79, +#: src/app/main/ui/settings/feedback.cljs:84 +msgid "errors.generic" +msgstr "문제가 발생했습니다." + +#: src/app/main/errors.cljs:200 +msgid "errors.internal-assertion-error" +msgstr "내부 검증 오류(Internal Assertion Error)" + +#: src/app/main/errors.cljs:214 +msgid "errors.internal-worker-error" +msgstr "웹 워커에서 문제가 발생했습니다." + +#: src/app/main/ui/components/color_input.cljs:51 +msgid "errors.invalid-color" +msgstr "유효하지 않은 컬러" + +#: src/app/util/forms.cljs:35, src/app/util/forms.cljs:89 +msgid "errors.invalid-data" +msgstr "유효하지 않은 데이터" + +#: src/app/main/ui/auth/register.cljs, src/app/main/ui/auth/login.cljs, +#: src/app/main/ui/auth/recovery_request.cljs +msgid "errors.invalid-email" +msgstr "올바른 이메일 주소를 입력해주세요" + +#: src/app/main/ui/settings/change_email.cljs:62 +msgid "errors.invalid-email-confirmation" +msgstr "확인용 이메일이 일치하지 않습니다" + +#: src/app/util/forms.cljs +msgid "errors.invalid-text" +msgstr "유효하지 않은 텍스트" + +#: src/app/main/ui/static.cljs:74 +msgid "errors.invite-invalid" +msgstr "유효하지 않은 초대" + +#: src/app/main/ui/static.cljs:75 +msgid "errors.invite-invalid.info" +msgstr "이 초대는 취소되었거나 만료되었을 수 있습니다." + +#: src/app/main/ui/auth/login.cljs:89 +msgid "errors.ldap-disabled" +msgstr "LDAP 인증이 비활성화되었습니다." + +#: src/app/main/errors.cljs:291, src/app/main/ui/dashboard/team.cljs:191, +#: src/app/main/ui/onboarding/team_choice.cljs:105 +msgid "errors.max-quota-reached" +msgstr "'%s' 할당량에 도달했습니다. 고객 지원팀에 문의하세요." + +#: src/app/main/ui/dashboard/team.cljs:187, +#: src/app/main/ui/dashboard/team.cljs:849, +#: src/app/main/ui/onboarding/team_choice.cljs:101 +msgid "errors.maximum-invitations-by-request-reached" +msgstr "한 번의 요청으로 초대할 수 있는 최대 이메일 수(%s개)에 도달했습니다" + +#: src/app/main/data/workspace/media.cljs:190 +msgid "errors.media-too-large" +msgstr "이미지 크기가 너무 커서 삽입할 수 없습니다." + +#: src/app/main/data/media.cljs:70, src/app/main/data/workspace/media.cljs:193 +msgid "errors.media-type-mismatch" +msgstr "이미지 내용이 파일 확장자와 일치하지 않는 것 같습니다." + +#: src/app/main/data/media.cljs:67, src/app/main/data/workspace/media.cljs:178, +#: src/app/main/data/workspace/media.cljs:181, +#: src/app/main/data/workspace/media.cljs:184, +#: src/app/main/data/workspace/media.cljs:187 +msgid "errors.media-type-not-allowed" +msgstr "유효한 이미지가 아닌 것 같습니다." + +#: src/app/main/ui/dashboard/team.cljs:622 +msgid "errors.member-is-muted" +msgstr "" +"초대하려는 프로필의 이메일 수신이 거부되었습니다(스팸 신고 또는 높은 반송률)." + +#: src/app/main/errors.cljs:265 +msgid "errors.migration-in-progress" +msgstr "마이그레이션 진행 중" + +#: src/app/main/errors.cljs:174 +msgid "errors.only-creator-can-lock" +msgstr "버전 생성자만 잠글 수 있습니다" + +#: src/app/main/errors.cljs:182 +msgid "errors.only-creator-can-unlock" +msgstr "버전 생성자만 잠금 해제할 수 있습니다" + +#: src/app/main/ui/settings/password.cljs +msgid "errors.password-invalid-confirmation" +msgstr "확인용 비밀번호가 일치하지 않습니다" + +#: src/app/main/ui/settings/password.cljs +msgid "errors.password-too-short" +msgstr "비밀번호는 최소 8자 이상이어야 합니다" + +#: src/app/main/errors.cljs:155 +msgid "errors.paste-data-validation" +msgstr "클립보드에 유효하지 않은 데이터가 있습니다" + +#: src/app/main/data/auth.cljs:337, src/app/main/ui/auth/login.cljs:85, +#: src/app/main/ui/auth/login.cljs:93 +msgid "errors.profile-blocked" +msgstr "차단된 프로필입니다" + +#: src/app/main/ui/auth/recovery_request.cljs:53, +#: src/app/main/ui/dashboard/team.cljs:182, +#: src/app/main/ui/dashboard/team.cljs:618, +#: src/app/main/ui/dashboard/team.cljs:844, +#: src/app/main/ui/onboarding/team_choice.cljs:97, +#: src/app/main/ui/settings/change_email.cljs:33 +msgid "errors.profile-is-muted" +msgstr "" +"사용자 프로필의 이메일 수신이 거부되었습니다(스팸 신고 또는 높은 반송률)." + +#: src/app/main/data/auth.cljs:335, src/app/main/ui/auth/register.cljs:92 +msgid "errors.registration-disabled" +msgstr "현재 회원가입이 비활성화되어 있습니다." + +#: src/app/main/errors.cljs:226 +msgid "errors.svg-parser.invalid-svg" +msgstr "SVG가 유효하지 않거나 형식이 잘못되었습니다" + +#: src/app/main/errors.cljs:270 +msgid "errors.team-feature-mismatch" +msgstr "호환되지 않는 기능 '%s'이(가) 감지되었습니다" + +#: src/app/main/ui/dashboard/sidebar.cljs:373, +#: src/app/main/ui/dashboard/team.cljs:393 +msgid "errors.team-leave.insufficient-members" +msgstr "팀에 본인만 남아 있어 나갈 수 없습니다. 팀을 삭제해야 합니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:376, +#: src/app/main/ui/dashboard/team.cljs:396 +msgid "errors.team-leave.member-does-not-exists" +msgstr "할당하려는 멤버가 존재하지 않습니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:379, +#: src/app/main/ui/dashboard/team.cljs:399 +msgid "errors.team-leave.owner-cant-leave" +msgstr "" +"소유자(Owner)는 팀을 나갈 수 없습니다. 소유자 역할을 먼저 다른 분께 위임해야 " +"합니다." + +#: src/app/main/ui/workspace/tokens/sets/helpers.cljs:26, +#: src/app/main/ui/workspace/tokens/sets/helpers.cljs:45 +msgid "errors.token-set-already-exists" +msgstr "동일한 이름의 token 세트가 이미 존재합니다" + +#: src/app/main/data/tokens.cljs: +msgid "errors.token-set-doesnt-exists" +msgstr "알 수 없는 세트는 복제할 수 없습니다" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:337 +msgid "errors.token-set-exists-on-drop" +msgstr "" +"이동을 완료할 수 없습니다. 해당 경로에 동일한 이름의 세트가 이미 존재합니다." + +#: src/app/main/data/workspace/tokens/library_edit.cljs:125, +#: src/app/main/data/workspace/tokens/library_edit.cljs:144 +msgid "errors.token-theme-already-exists" +msgstr "동일한 이름의 테마 옵션이 이미 존재합니다" + +#: src/app/main/data/media.cljs:73 +msgid "errors.unexpected-error" +msgstr "예기치 않은 오류가 발생했습니다." + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "예기치 않은 오류: %s" + +#: src/app/main/ui/auth/verify_token.cljs:62 +msgid "errors.unexpected-token" +msgstr "알 수 없는 token" + +msgid "errors.validation" +msgstr "유효성 검사 오류" + +#: src/app/main/errors.cljs:190 +msgid "errors.version-already-locked" +msgstr "이 버전은 이미 잠겨 있습니다" + +#: src/app/main/errors.cljs:166 +msgid "errors.version-locked" +msgstr "이 버전은 잠겨 있어 다른 사람이 삭제할 수 없습니다" + +#: src/app/main/errors.cljs:287 +msgid "errors.version-not-supported" +msgstr "파일의 버전 번호가 호환되지 않습니다" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "WebGL이 작동을 멈췄습니다. 문제를 해결하려면 페이지를 새로고침하세요" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "캔버스 연결이 끊어졌습니다" + +#: src/app/main/ui/dashboard/team.cljs:1051 +msgid "errors.webhooks.connection" +msgstr "연결 오류, URL에 연결할 수 없습니다" + +#: src/app/main/ui/dashboard/team.cljs:1045 +msgid "errors.webhooks.invalid-uri" +msgstr "URL 유효성 검사를 통과하지 못했습니다." + +#: src/app/main/ui/dashboard/team.cljs:1204 +msgid "errors.webhooks.last-delivery" +msgstr "마지막 전송이 성공하지 못했습니다." + +#: src/app/main/ui/dashboard/team.cljs:1047, +#: src/app/main/ui/dashboard/team.cljs:1207 +msgid "errors.webhooks.ssl-validation" +msgstr "SSL 인증 오류." + +#: src/app/main/ui/dashboard/team.cljs:1049 +msgid "errors.webhooks.timeout" +msgstr "시간 초과" + +#: src/app/main/ui/dashboard/team.cljs:1043 +msgid "errors.webhooks.unexpected" +msgstr "유효성 검사 중 예기치 않은 오류 발생" + +#: src/app/main/ui/dashboard/team.cljs:1053, +#: src/app/main/ui/dashboard/team.cljs:1210 +msgid "errors.webhooks.unexpected-status" +msgstr "예기치 않은 상태 %s" + +#: src/app/main/ui/auth/login.cljs:97, src/app/main/ui/auth/login.cljs:101 +msgid "errors.wrong-credentials" +msgstr "이메일 또는 비밀번호가 올바르지 않습니다." + +#: src/app/main/ui/settings/password.cljs:25 +msgid "errors.wrong-old-password" +msgstr "이전 비밀번호가 올바르지 않습니다" + +#: src/app/main/ui/settings/feedback.cljs:120 +msgid "feedback.description" +msgstr "설명" + +#: src/app/main/ui/settings/feedback.cljs:122 +msgid "feedback.description-placeholder" +msgstr "의견을 보내시는 이유를 설명해주세요" + +#: src/app/main/ui/settings/feedback.cljs:150 +msgid "feedback.discourse-subtitle1" +msgstr "" +"함께해주셔서 기쁩니다. 도움이 필요하시면 게시하기 전에 먼저 검색을 이용해보세" +"요." + +#: src/app/main/ui/settings/feedback.cljs:149 +msgid "feedback.discourse-title" +msgstr "Penpot 커뮤니티" + +#: src/app/main/ui/settings/feedback.cljs:143 +msgid "feedback.other-ways-contact" +msgstr "기타 문의 방법" + +#: src/app/main/ui/settings/feedback.cljs:126 +msgid "feedback.penpot.link" +msgstr "" +"파일이나 프로젝트와 관련된 의견인 경우 여기에 Penpot 링크를 추가해주세요:" + +#: src/app/main/ui/settings/feedback.cljs:105 +msgid "feedback.subject" +msgstr "제목" + +#: src/app/main/ui/settings/feedback.cljs:102 +msgid "feedback.subtitle" +msgstr "" +"문의하시는 이유를 이슈, 제안, 궁금한 점 등으로 구분하여 설명해주세요. 담당 멤" +"버가 최대한 빨리 답변해 드리겠습니다." + +#: src/app/main/ui/settings/feedback.cljs:101 +msgid "feedback.title-contact-us" +msgstr "문의하기" + +#: src/app/main/ui/settings/feedback.cljs:156 +msgid "feedback.twitter-subtitle1" +msgstr "기술적인 문의 사항을 도와드립니다." + +#: src/app/main/ui/settings/feedback.cljs:155 +msgid "feedback.twitter-title" +msgstr "X(Twitter) 지원 계정" + +#: src/app/main/ui/settings/feedback.cljs:110, +#: src/app/main/ui/settings/feedback.cljs:111 +msgid "feedback.type" +msgstr "유형" + +#: src/app/main/ui/settings/feedback.cljs:115 +msgid "feedback.type.doubt" +msgstr "궁금한 점" + +#: src/app/main/ui/settings/feedback.cljs:113 +msgid "feedback.type.idea" +msgstr "아이디어/제안" + +#: src/app/main/ui/settings/feedback.cljs:114 +msgid "feedback.type.issue" +msgstr "이슈/문제" + +#: src/app/main/ui/exports/files.cljs:133 +msgid "files-download-modal.description-2" +msgstr "* 컴포넌트, 그래픽, 컬러 및/또는 타이포그래피가 포함될 수 있습니다." + +#: src/app/main/ui/exports/files.cljs:141 +msgid "files-download-modal.options.all.message" +msgstr "" +"공유 라이브러리가 포함된 파일이 연결 상태를 유지하며 내보내기에 포함됩니다." + +#: src/app/main/ui/exports/files.cljs:142 +msgid "files-download-modal.options.all.title" +msgstr "공유 라이브러리 내보내기" + +#: src/app/main/ui/exports/files.cljs:143 +msgid "files-download-modal.options.detach.message" +msgstr "" +"공유 라이브러리는 내보내기에 포함되지 않으며, 라이브러리에 에셋이 추가되지 않" +"습니다. " + +#: src/app/main/ui/exports/files.cljs:144 +msgid "files-download-modal.options.detach.title" +msgstr "공유 라이브러리 에셋을 일반 객체로 변환" + +#: src/app/main/ui/exports/files.cljs:145 +msgid "files-download-modal.options.merge.message" +msgstr "모든 외부 에셋이 파일 라이브러리에 병합된 상태로 파일이 내보내집니다." + +#: src/app/main/ui/exports/files.cljs:146 +msgid "files-download-modal.options.merge.title" +msgstr "공유 라이브러리 에셋을 파일 라이브러리에 포함" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "파일 다운로드" + +#: src/app/main/ui/settings/password.cljs:31 +msgid "generic.error" +msgstr "오류가 발생했습니다" + +#: src/app/main/ui/components/color_input.cljs:31 +msgid "inspect.attributes.color" +msgstr "색상" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:120 +msgid "inspect.attributes.image.preview" +msgstr "도형 채우기 이미지 미리보기" + +msgid "inspect.attributes.stroke.style.none" +msgstr "없음" + +#: src/app/main/ui/inspect/attributes/text.cljs:113 +msgid "inspect.attributes.typography.font-weight" +msgstr "글꼴 굵기" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:397, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:408 +msgid "inspect.attributes.typography.letter-spacing" +msgstr "자간" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:379, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:389 +msgid "inspect.attributes.typography.line-height" +msgstr "행간" + +#: src/app/main/ui/inspect/attributes/text.cljs:140 +msgid "inspect.attributes.typography.text-decoration" +msgstr "텍스트 장식" + +msgid "inspect.attributes.typography.text-decoration.line-through" +msgstr "취소선" + +#: src/app/main/ui/inspect/attributes/text.cljs:111 +msgid "inspect.attributes.typography.text-decoration.none" +msgstr "없음" + +#: src/app/main/ui/inspect/attributes/text.cljs:125, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:437 +msgid "inspect.attributes.typography.text-transform.capitalize" +msgstr "첫 글자 대문자" + +#: src/app/main/ui/inspect/attributes/text.cljs:124 +msgid "inspect.attributes.typography.text-transform.none" +msgstr "없음" + +#: src/app/main/ui/inspect/attributes/text.cljs:127 +msgid "inspect.attributes.typography.text-transform.unset" +msgstr "설정 해제" + +#: src/app/main/ui/inspect/attributes/variant.cljs:44 +msgid "inspect.attributes.variant" +msgstr "베리언트 속성" + +#: src/app/main/ui/inspect/attributes/variant.cljs:44 +msgid "inspect.attributes.variants" +msgstr "베리언트 속성" + +#: src/app/main/ui/inspect/right_sidebar.cljs:170 +msgid "inspect.color-space-label" +msgstr "색상 공간 선택" + +#: src/app/main/ui/inspect/right_sidebar.cljs:234 +msgid "inspect.empty.help" +msgstr "" +"디자인 검사(Inspect)에 대해 더 알고 싶으시면 Penpot 도움말 센터를 방문하세요" + +#: src/app/main/ui/inspect/right_sidebar.cljs:238 +msgid "inspect.empty.more" +msgstr "더 보기" + +#: src/app/main/ui/inspect/right_sidebar.cljs:232 +msgid "inspect.empty.select" +msgstr "속성과 코드를 검사할 도형, 보드 또는 그룹을 선택하세요" + +#: src/app/main/ui/inspect/right_sidebar.cljs:166 +msgid "inspect.layer-info" +msgstr "레이어 정보" + +#: src/app/main/ui/inspect/right_sidebar.cljs:137 +msgid "inspect.multiple-selected" +msgstr "%s개 선택됨" + +#: src/app/main/ui/inspect/right_sidebar.cljs:68 +msgid "inspect.subtitle.copy" +msgstr "복사" + +#: src/app/main/ui/inspect/right_sidebar.cljs:64 +msgid "inspect.subtitle.main" +msgstr "메인 컴포넌트" + +#: src/app/main/ui/inspect/styles/style_box.cljs:68 +msgid "inspect.tabs.styles.copy-shorthand" +msgstr "CSS 단축 표기를 클립보드에 복사" + +#: src/app/main/ui/inspect/styles/property_detail_copiable.cljs:51 +msgid "inspect.tabs.styles.copy-to-clipboard" +msgstr "클립보드에 복사" + +#: src/app/main/ui/inspect/styles/style_box.cljs:22 +msgid "inspect.tabs.styles.geometry-panel" +msgstr "크기 및 위치" + +#: src/app/main/ui/inspect/styles/style_box.cljs:60, +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:178 +msgid "inspect.tabs.styles.toggle-style" +msgstr "%s 패널 표시/숨김" + +#: src/app/main/ui/inspect/styles/style_box.cljs:21 +msgid "inspect.tabs.styles.token-panel" +msgstr "token 세트 및 테마" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:102, +#: src/app/main/ui/inspect/styles/rows/properties_row.cljs:60 +msgid "inspect.tabs.styles.token-resolved-value" +msgstr "계산된 값:" + +#: src/app/main/ui/inspect/styles/style_box.cljs:20 +msgid "inspect.tabs.styles.variants-panel" +msgstr "베리언트 속성" + +#: src/app/main/ui/dashboard/comments.cljs:96 +msgid "label.mark-all-as-read" +msgstr "모두 읽음으로 표시" + +#: src/app/main/ui/dashboard/sidebar.cljs:1138 +msgid "labels.about-penpot" +msgstr "Penpot 정보" + +#: src/app/main/ui/settings/sidebar.cljs:123 +msgid "labels.access-tokens" +msgstr "액세스 토큰" + +#: src/app/main/ui/workspace/libraries.cljs:169, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1040 +msgid "labels.add" +msgstr "추가" + +#: src/app/main/ui/workspace/libraries.cljs:169 +msgid "labels.adding" +msgstr "추가 중..." + +#: src/app/main/ui/onboarding/questions.cljs:160 +msgid "labels.adobe-xd" +msgstr "Adobe XD" + +#: src/app/main/ui/static.cljs:297 +msgid "labels.bad-gateway.desc-message" +msgstr "" +"잠시 기다린 후 다시 시도해주세요. 현재 서버 유지보수 작업을 진행 중입니다." + +#: src/app/main/ui/inspect/styles/style_box.cljs:26 +msgid "labels.blur" +msgstr "블러" + +#: src/app/main/ui/onboarding/questions.cljs:162 +msgid "labels.canva" +msgstr "Canva" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:181 +msgid "labels.collapse" +msgstr "접기" + +#: src/app/main/ui/workspace/colorpicker.cljs:424 +msgid "labels.color" +msgstr "색상" + +#: src/app/main/ui/comments.cljs:901 +msgid "labels.comment" +msgstr "댓글" + +#: src/app/main/ui/comments.cljs:905 +msgid "labels.comment.mark-as-solved" +msgstr "해결됨으로 표시" + +#: src/app/main/ui/dashboard/sidebar.cljs:1125 +msgid "labels.community-contributions" +msgstr "커뮤니티 및 기여" + +#: src/app/main/ui/inspect/right_sidebar.cljs:110 +msgid "labels.computed" +msgstr "계산됨" + +#: src/app/main/ui/static.cljs:415 +msgid "labels.contact-support" +msgstr "고객 지원 문의" + +#: src/app/main/ui/settings/sidebar.cljs:136 +msgid "labels.contact-us" +msgstr "문의하기" + +msgid "labels.continue-with" +msgstr "다음으로 계속하기:" + +#: src/app/main/ui/viewer/login.cljs:69 +msgid "labels.continue-with-penpot" +msgstr "Penpot 계정으로 계속할 수 있습니다" + +#: src/app/main/ui/components/copy_button.cljs:41 +msgid "labels.copy" +msgstr "복사" + +#: src/app/main/ui/inspect/attributes/common.cljs:101 +msgid "labels.copy-color" +msgstr "색상 복사" + +#: src/app/main/ui/static.cljs:67 +msgid "labels.copyright-period" +msgstr "Kaleidos © 2019-present" + +#: src/app/main/ui/dashboard/file_menu.cljs:291 +msgid "labels.delete-multi-files" +msgstr "%s개 파일 삭제" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "삭제된 항목" + +#: src/app/main/ui/onboarding/questions.cljs:86 +msgid "labels.developer" +msgstr "개발" + +#: src/app/main/ui/onboarding/questions.cljs:260 +msgid "labels.director" +msgstr "디렉터" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:211 +msgid "labels.discard" +msgstr "취소" + +#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 +msgid "labels.download" +msgstr "%s 다운로드" + +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:65 +msgid "labels.duplicate" +msgstr "복제" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:300 +msgid "labels.empty" +msgstr "비어 있음" + +#: src/app/main/ui/dashboard/import.cljs:297 +msgid "labels.error" +msgstr "오류" + +#: src/app/main/ui/onboarding/questions.cljs:404 +msgid "labels.event" +msgstr "이벤트" + +#: src/app/main/ui/settings/feedback.cljs:83 +msgid "labels.feedback-disabled" +msgstr "의견 보내기 비활성화됨" + +#: src/app/main/ui/settings/feedback.cljs:74 +msgid "labels.feedback-sent" +msgstr "의견이 전송되었습니다" + +#: src/app/main/ui/onboarding/questions.cljs:156 +msgid "labels.figma" +msgstr "Figma" + +#: src/app/main/ui/inspect/styles/style_box.cljs:23 +msgid "labels.fill" +msgstr "채우기" + +#: src/app/main/ui/onboarding/questions.cljs:259 +msgid "labels.founder" +msgstr "CEO 또는 설립자" + +#: src/app/main/ui/onboarding/questions.cljs:258 +msgid "labels.freelancer" +msgstr "프리랜서" + +#: src/app/main/ui/dashboard/sidebar.cljs:929, +#: src/app/main/ui/workspace/main_menu.cljs:176 +msgid "labels.github-repo" +msgstr "Github 저장소" + +#: src/app/main/ui/dashboard/sidebar.cljs:904, +#: src/app/main/ui/workspace/main_menu.cljs:205 +msgid "labels.give-feedback" +msgstr "의견 보내기" + +#: src/app/main/ui/onboarding/questions.cljs:88 +msgid "labels.graphic-design" +msgstr "그래픽 디자인" + +#: src/app/main/ui/dashboard/sidebar.cljs:1114 +msgid "labels.help-learning" +msgstr "도움말 및 학습" + +#: src/app/main/ui/dashboard/templates.cljs:91 +msgid "labels.hide" +msgstr "숨기기" + +#: src/app/main/ui/viewer/comments.cljs:103, +#: src/app/main/ui/workspace/comments.cljs:75 +msgid "labels.hide-resolved-comments" +msgstr "해결된 댓글 숨기기" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:131 +msgid "labels.import" +msgstr "가져오기" + +#: src/app/main/ui/dashboard/fonts.cljs:430 +msgid "labels.installed-fonts" +msgstr "설치된 글꼴" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "문제가 발생했습니다." + +#: src/app/main/ui/static.cljs:406 +msgid "labels.internal-error.desc-message-second" +msgstr "작업을 다시 시도하거나 지원팀에 문의하세요." + +#: src/app/main/ui/static.cljs:402 +msgid "labels.internal-error.main-message" +msgstr "내부 오류" + +#: src/app/main/ui/onboarding/questions.cljs:164 +msgid "labels.invision" +msgstr "InVision" + +#: src/app/main/ui/dashboard/sidebar.cljs:454, +#: src/app/main/ui/dashboard/team.cljs:102, +#: src/app/main/ui/dashboard/team.cljs:110, +#: src/app/main/ui/dashboard/team.cljs:944 +msgid "labels.invitations" +msgstr "초대" + +#: src/app/main/ui/settings/options.cljs:53 +msgid "labels.language" +msgstr "언어" + +#: src/app/main/ui/inspect/styles/style_box.cljs:28 +msgid "labels.layout" +msgstr "레이아웃" + +#: src/app/main/ui/dashboard/sidebar.cljs:893 +msgid "labels.learning-center" +msgstr "학습 센터" + +#: src/app/main/ui/workspace/main_menu.cljs:168 +msgid "labels.libraries-and-templates" +msgstr "라이브러리 및 템플릿" + +#: src/app/main/ui/auth/verify_token.cljs:100, +#: src/app/main/ui/dashboard/grid.cljs:126, +#: src/app/main/ui/dashboard/grid.cljs:147, +#: src/app/main/ui/dashboard/import.cljs:258, +#: src/app/main/ui/dashboard/placeholder.cljs:140, +#: src/app/main/ui/ds/product/loader.cljs:85, +#: src/app/main/ui/exports/files.cljs:60, src/app/main/ui/viewer.cljs:642, +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:249, +#: src/app/main/ui/workspace.cljs:129, src/app/main/ui.cljs:70, +#: src/app/main/ui.cljs:108, src/app/main/ui.cljs:127 +msgid "labels.loading" +msgstr "로드 중…" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:210 +msgid "labels.lock" +msgstr "잠금" + +#: src/app/main/ui/viewer/header.cljs:205 +msgid "labels.log-or-sign" +msgstr "로그인 또는 회원가입" + +#: src/app/main/ui/static.cljs:61, src/app/main/ui/static.cljs:137 +msgid "labels.login" +msgstr "로그인" + +#: src/app/main/ui/dashboard/sidebar.cljs:1148 +msgid "labels.logout" +msgstr "로그아웃" + +#: src/app/main/ui/onboarding/questions.cljs:89 +msgid "labels.marketing" +msgstr "마케팅" + +#: src/app/main/ui/dashboard/team.cljs:512 +msgid "labels.member" +msgstr "구성원" + +#: src/app/main/ui/dashboard/sidebar.cljs:450, +#: src/app/main/ui/dashboard/team.cljs:100, +#: src/app/main/ui/dashboard/team.cljs:108 +msgid "labels.members" +msgstr "구성원" + +#: src/app/main/ui/comments.cljs:581 +msgid "labels.mention" +msgstr "멘션" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:631 +msgid "labels.mixed-values" +msgstr "혼합" + +#: src/app/main/ui/settings/password.cljs:86 +msgid "labels.new-password" +msgstr "새 비밀번호" + +#: src/app/main/ui/dashboard/templates.cljs:301, +#: src/app/main/ui/onboarding/questions.cljs:54, +#: src/app/main/ui/viewer.cljs:112 +msgid "labels.next" +msgstr "다음" + +#: src/app/main/ui/dashboard/comments.cljs:122, +#: src/app/main/ui/workspace/comments.cljs:162 +msgid "labels.no-comments-available" +msgstr "모든 알림을 확인했습니다! 새 댓글 알림이 여기에 표시됩니다." + +#: src/app/main/ui/dashboard/team.cljs:737 +msgid "labels.no-invitations" +msgstr "대기 중인 초대가 없습니다." + +#: src/app/main/ui/dashboard/team.cljs:739 +msgid "labels.no-invitations-gather-people" +msgstr "사람들을 모아 멋진 결과물을 함께 만들어보세요." + +#: src/app/main/ui/static.cljs +msgid "labels.not-found.desc-message" +msgstr "페이지가 존재하지 않거나 접근 권한이 없습니다." + +#: src/app/main/ui/static.cljs:286 +msgid "labels.not-found.main-message" +msgstr "죄송합니다!" + +#: src/app/main/ui/settings/sidebar.cljs:103 +msgid "labels.notifications" +msgstr "알림" + +#: src/app/main/ui/dashboard/projects.cljs:240, +#: src/app/main/ui/dashboard/team.cljs:1354 +msgid "labels.num-of-files" +msgid_plural "labels.num-of-files" +msgstr[0] "파일 %s개" + +#: src/app/main/ui/viewer/thumbnails.cljs:82 +msgid "labels.num-of-frames" +msgid_plural "labels.num-of-frames" +msgstr[0] "보드 %s개" + +#: src/app/main/ui/dashboard/team.cljs:1349 +msgid "labels.num-of-projects" +msgid_plural "labels.num-of-projects" +msgstr[0] "프로젝트 %개" + +msgid "labels.ok" +msgstr "확인" + +#: src/app/main/ui/settings/password.cljs:79 +msgid "labels.old-password" +msgstr "이전 비밀번호" + +#: src/app/main/ui/workspace/comments.cljs +msgid "labels.only-yours" +msgstr "내 항목만" + +#: src/app/main/ui/comments.cljs:911, src/app/main/ui/comments.cljs:976, +#: src/app/main/ui/workspace/palette.cljs:199, +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:107, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:906, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:155, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:213, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:294, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:402, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1031, +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:316, +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:345, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:146 +msgid "labels.options" +msgstr "옵션" + +msgid "labels.or" +msgstr "또는" + +#: src/app/main/ui/onboarding/questions.cljs:131, +#: src/app/main/ui/onboarding/questions.cljs:203, +#: src/app/main/ui/onboarding/questions.cljs:285, +#: src/app/main/ui/onboarding/questions.cljs:358 +msgid "labels.other" +msgstr "기타 (직접 입력)" + +#: src/app/main/ui/onboarding/questions.cljs:91, +#: src/app/main/ui/onboarding/questions.cljs:166, +#: src/app/main/ui/onboarding/questions.cljs:255, +#: src/app/main/ui/onboarding/questions.cljs:324 +msgid "labels.other-short" +msgstr "기타" + +#: src/app/main/ui/dashboard/team.cljs:324, +#: src/app/main/ui/dashboard/team.cljs:564, +#: src/app/main/ui/dashboard/team.cljs:1335 +msgid "labels.owner" +msgstr "소유자(Owner)" + +#: src/app/main/ui/settings/sidebar.cljs:98 +msgid "labels.password" +msgstr "비밀번호" + +#: src/app/main/ui/dashboard/team.cljs:669 +msgid "labels.pending-invitation" +msgstr "대기 중" + +#: src/app/main/ui/dashboard/sidebar.cljs:973 +msgid "labels.penpot-changelog" +msgstr "Penpot 변경 사항" + +#: src/app/main/ui/dashboard/sidebar.cljs:899 +msgid "labels.penpot-hub" +msgstr "Penpot 허브" + +#: src/app/main/ui/comments.cljs:680 +msgid "labels.post" +msgstr "게시" + +#: src/app/main/ui/onboarding/questions.cljs:50, +#: src/app/main/ui/viewer.cljs:105 +msgid "labels.previous" +msgstr "이전" + +#: src/app/main/ui/onboarding/questions.cljs:85 +msgid "labels.product-design" +msgstr "제품 또는 UX 디자인" + +#: src/app/main/ui/onboarding/questions.cljs:90 +msgid "labels.product-management" +msgstr "제품 관리(PM)" + +#: src/app/main/ui/settings/profile.cljs:128, +#: src/app/main/ui/settings/sidebar.cljs:93 +msgid "labels.profile" +msgstr "프로필" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:205, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:179 +msgid "labels.reference" +msgstr "참조" + +#: src/app/main/data/common.cljs:83 +msgid "labels.refresh" +msgstr "새로고침" + +#: src/app/main/ui/settings/sidebar.cljs:129, +#: src/app/main/ui/workspace/main_menu.cljs:160 +msgid "labels.release-notes" +msgstr "릴리즈 노트" + +#: src/app/main/ui/workspace.cljs +msgid "labels.reload-file" +msgstr "파일 다시 로드" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "페이지 새로고침" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:167, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:406 +msgid "labels.remove" +msgstr "제거" + +#: src/app/main/ui/dashboard/team.cljs:355 +msgid "labels.remove-member" +msgstr "구성원 제거" + +#: src/app/main/ui/dashboard/file_menu.cljs:299, +#: src/app/main/ui/dashboard/project_menu.cljs:88, +#: src/app/main/ui/dashboard/sidebar.cljs:471, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:167, +#: src/app/main/ui/workspace/sidebar/versions.cljs:192, +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:63 +msgid "labels.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/dashboard/team_form.cljs:98 +msgid "labels.rename-team" +msgstr "팀 이름 바꾸기" + +#: src/app/main/ui/comments.cljs:642 +msgid "labels.replies" +msgstr "답글" + +#: src/app/main/ui/comments.cljs:647 +msgid "labels.replies.new" +msgstr "새 답글" + +#: src/app/main/ui/comments.cljs:641 +msgid "labels.reply" +msgstr "답글" + +#: src/app/main/ui/comments.cljs:646 +msgid "labels.reply.new" +msgstr "새 답글" + +#: src/app/main/ui/comments.cljs:713 +msgid "labels.reply.thread" +msgstr "답글 달기" + +#: src/app/main/ui/dashboard/team.cljs:788 +msgid "labels.resend" +msgstr "재전송" + +#: src/app/main/ui/dashboard/team.cljs:938 +msgid "labels.resend-invitation" +msgstr "초대 재전송" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:87, +#: src/app/main/ui/workspace/sidebar/versions.cljs:197 +msgid "labels.restore" +msgstr "복원" + +#: src/app/main/ui/components/progress.cljs:80, +#: src/app/main/ui/static.cljs:299, src/app/main/ui/static.cljs:308, +#: src/app/main/ui/static.cljs:419 +msgid "labels.retry" +msgstr "다시 시도" + +#: src/app/main/ui/dashboard/team.cljs:513, +#: src/app/main/ui/dashboard/team.cljs:945 +msgid "labels.role" +msgstr "역할" + +#: src/app/main/ui/dashboard/fonts.cljs:395, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:204, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:301, +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:110 +msgid "labels.save" +msgstr "저장" + +#: src/app/main/ui/dashboard/fonts.cljs:435 +msgid "labels.search-font" +msgstr "글꼴 검색" + +#: src/app/main/ui/onboarding/questions.cljs:84, +#: src/app/main/ui/onboarding/questions.cljs:230, +#: src/app/main/ui/onboarding/questions.cljs:240 +msgid "labels.select-option" +msgstr "옵션 선택" + +#: src/app/main/ui/settings/feedback.cljs:137 +msgid "labels.send" +msgstr "전송" + +#: src/app/main/ui/settings/feedback.cljs:137 +msgid "labels.sending" +msgstr "전송 중…" + +#: src/app/main/ui/static.cljs:306 +msgid "labels.service-unavailable.desc-message" +msgstr "시스템 정기 점검 중입니다." + +#: src/app/main/ui/static.cljs:305 +msgid "labels.service-unavailable.main-message" +msgstr "서비스를 사용할 수 없음" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:75 +msgid "labels.sets" +msgstr "세트" + +#: src/app/main/ui/dashboard/sidebar.cljs:464, +#: src/app/main/ui/dashboard/team.cljs:101, +#: src/app/main/ui/dashboard/team.cljs:115, +#: src/app/main/ui/settings/options.cljs:87, +#: src/app/main/ui/settings/sidebar.cljs:109 +msgid "labels.settings" +msgstr "설정" + +#: src/app/main/ui/inspect/styles/style_box.cljs:27, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:229 +msgid "labels.shadow" +msgstr "그림자" + +#: src/app/main/ui/viewer/header.cljs:201 +msgid "labels.share" +msgstr "공유" + +msgid "labels.share-prototype" +msgstr "프로토타입 공유" + +#: src/app/main/ui/dashboard/sidebar.cljs:840 +msgid "labels.shared-libraries" +msgstr "라이브러리" + +#: src/app/main/ui/dashboard/templates.cljs:87 +msgid "labels.show" +msgstr "표시" + +#: src/app/main/ui/viewer/comments.cljs:82, +#: src/app/main/ui/workspace/comments.cljs:57, +#: src/app/main/ui/workspace/comments.cljs:136 +msgid "labels.show-all-comments" +msgstr "모든 댓글 보기" + +#: src/app/main/ui/viewer/comments.cljs:115 +msgid "labels.show-comments-list" +msgstr "댓글 목록 보기" + +#: src/app/main/ui/workspace/comments.cljs:69, +#: src/app/main/ui/workspace/comments.cljs:138 +msgid "labels.show-mentions" +msgstr "내 멘션만 보기" + +#: src/app/main/ui/viewer/comments.cljs:91, +#: src/app/main/ui/workspace/comments.cljs:63, +#: src/app/main/ui/workspace/comments.cljs:137 +msgid "labels.show-your-comments" +msgstr "내 댓글만 보기" + +#: src/app/main/ui/onboarding/questions.cljs:158 +msgid "labels.sketch" +msgstr "Sketch" + +#: src/app/main/ui/dashboard/sidebar.cljs:825 +msgid "labels.sources" +msgstr "원본" + +#: src/app/main/ui/onboarding/questions.cljs:55 +msgid "labels.start" +msgstr "시작" + +#: src/app/main/ui/dashboard/team.cljs:954 +msgid "labels.status" +msgstr "상태" + +#: src/app/main/ui/inspect/styles/style_box.cljs:24, +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:46 +msgid "labels.stroke" +msgstr "선" + +#: src/app/main/ui/onboarding/questions.cljs:87 +msgid "labels.student-teacher" +msgstr "학생 또는 교사" + +#: src/app/main/ui/inspect/right_sidebar.cljs:108, +#: src/app/main/ui/inspect/styles.cljs:135 +msgid "labels.styles" +msgstr "스타일" + +#: src/app/main/ui/inspect/styles/style_box.cljs:33 +msgid "labels.svg" +msgstr "SVG" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "전환" + +#: src/app/main/ui/onboarding/questions.cljs:256 +msgid "labels.team-leader" +msgstr "팀 리더" + +#: src/app/main/ui/onboarding/questions.cljs:257 +msgid "labels.team-member" +msgstr "팀 구성원" + +#: src/app/main/ui/inspect/styles/style_box.cljs:25 +msgid "labels.text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/tokens/themes.cljs:36 +msgid "labels.themes" +msgstr "테마" + +#: src/app/main/ui/workspace/main_menu.cljs:152 +msgid "labels.tutorials" +msgstr "튜토리얼" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +msgid "labels.typography" +msgstr "타이포그래피" + +#: src/app/main/data/workspace/tokens/errors.cljs:121 +msgid "labels.unknown-error" +msgstr "알 수 없는 오류" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:204 +msgid "labels.unlock" +msgstr "잠금 해제" + +#: src/app/main/ui/dashboard/file_menu.cljs:285 +msgid "labels.unpublish-multi-files" +msgstr "%s개 파일 게시 취소" + +#: src/app/main/ui/settings/profile.cljs:111 +msgid "labels.update" +msgstr "업데이트" + +#: src/app/main/ui/dashboard/team_form.cljs:119 +msgid "labels.update-team" +msgstr "팀 업데이트" + +#: src/app/main/ui/dashboard/fonts.cljs:253 +msgid "labels.upload" +msgstr "업로드" + +#: src/app/main/ui/dashboard/fonts.cljs:180 +msgid "labels.upload-custom-fonts" +msgstr "사용자 지정 글꼴 업로드" + +#: src/app/main/ui/dashboard/fonts.cljs:252 +msgid "labels.uploading" +msgstr "업로드 중…" + +#: src/app/main/ui/inspect/right_sidebar.cljs:66, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1039 +msgid "labels.variant" +msgstr "베리언트" + +#: src/app/main/ui/dashboard/sidebar.cljs:967 +msgid "labels.version-notes" +msgstr "버전 %s 노트" + +#: src/app/main/ui/inspect/styles/style_box.cljs:32 +msgid "labels.visibility" +msgstr "가시성" + +#: src/app/main/ui/dashboard/team.cljs:268 +msgid "labels.you" +msgstr "(나)" + +#: src/app/main/ui/dashboard/sidebar.cljs:1101 +msgid "labels.your-account" +msgstr "내 계정" + +#: src/app/main/ui/onboarding/questions.cljs:403 +msgid "labels.youtube" +msgstr "YouTube" + +#: src/app/main/ui/ds/product/loader.cljs:21 +msgid "loader.tips.01.message" +msgstr "여러 프로젝트에서 디자인의 일관성을 유지하고 간편하게 업데이트하세요." + +#: src/app/main/ui/ds/product/loader.cljs:20 +msgid "loader.tips.01.title" +msgstr "재사용 가능한 컴포넌트" + +#: src/app/main/ui/ds/product/loader.cljs:23 +msgid "loader.tips.02.message" +msgstr "팀원들과 실시간으로 작업하고 즉시 피드백을 공유하세요." + +#: src/app/main/ui/ds/product/loader.cljs:22 +msgid "loader.tips.02.title" +msgstr "실시간 협업" + +#: src/app/main/ui/ds/product/loader.cljs:25 +msgid "loader.tips.03.message" +msgstr "익숙한 CSS 방식의 레이아웃 컨트롤로 유연하게 디자인하세요." + +#: src/app/main/ui/ds/product/loader.cljs:24 +msgid "loader.tips.03.title" +msgstr "CSS 스타일 레이아웃" + +#: src/app/main/ui/ds/product/loader.cljs:27 +msgid "loader.tips.04.message" +msgstr "디자인에서 직접 CSS 및 SVG 코드를 추출하세요." + +#: src/app/main/ui/ds/product/loader.cljs:26 +msgid "loader.tips.04.title" +msgstr "코드로 내보내기" + +#: src/app/main/ui/ds/product/loader.cljs:29 +msgid "loader.tips.05.message" +msgstr "에셋과 스타일을 공유하여 일관성을 유지하세요." + +#: src/app/main/ui/ds/product/loader.cljs:28 +msgid "loader.tips.05.title" +msgstr "디자인 라이브러리" + +#: src/app/main/ui/ds/product/loader.cljs:31 +msgid "loader.tips.06.message" +msgstr "애니메이션과 트랜지션을 통해 아이디어에 생동감을 불어넣으세요." + +#: src/app/main/ui/ds/product/loader.cljs:30 +msgid "loader.tips.06.title" +msgstr "인터랙티브 프로토타입" + +#: src/app/main/ui/ds/product/loader.cljs:33 +msgid "loader.tips.07.message" +msgstr "Penpot은 원활한 개발을 위해 SVG와 CSS 표준을 사용합니다." + +#: src/app/main/ui/ds/product/loader.cljs:32 +msgid "loader.tips.07.title" +msgstr "웹 표준 포맷" + +#: src/app/main/ui/ds/product/loader.cljs:35 +msgid "loader.tips.08.message" +msgstr "" +"Shift + A(오토 레이아웃)와 같은 편리한 단축키로 워크플로우를 가속화하세요." + +#: src/app/main/ui/ds/product/loader.cljs:34 +msgid "loader.tips.08.title" +msgstr "키보드 단축키" + +#: src/app/main/ui/ds/product/loader.cljs:37 +msgid "loader.tips.09.message" +msgstr "내 스타일에 맞는 테마를 선택하세요." + +#: src/app/main/ui/ds/product/loader.cljs:36 +msgid "loader.tips.09.title" +msgstr "다크 및 라이트 모드" + +#: src/app/main/ui/ds/product/loader.cljs:39 +msgid "loader.tips.10.message" +msgstr "커뮤니티에서 제작한 플러그인으로 Penpot의 기능을 확장해보세요." + +#: src/app/main/ui/ds/product/loader.cljs:38 +msgid "loader.tips.10.title" +msgstr "플러그인 지원" + +#: src/app/main/ui/workspace/colorpicker.cljs:484, +#: src/app/main/ui/workspace/colorpicker.cljs:485, +#: src/app/main/ui/workspace/colorpicker.cljs:487 +msgid "media.choose-image" +msgstr "이미지 선택" + +#: src/app/main/ui/workspace/colorpicker.cljs:257 +msgid "media.gradient" +msgstr "그라데이션" + +#: src/app/main/data/workspace/media.cljs:270, +#: src/app/main/ui/components/color_bullet.cljs:33, +#: src/app/main/ui/components/color_bullet.cljs:46, +#: src/app/main/ui/ds/utilities/swatch.cljs:45, +#: src/app/main/ui/ds/utilities/swatch.cljs:58, +#: src/app/main/ui/inspect/attributes/common.cljs:43, +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:66, +#: src/app/main/ui/workspace/colorpicker.cljs:259, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:402 +msgid "media.image" +msgstr "이미지" + +#: src/app/main/ui/inspect/attributes/common.cljs:53 +msgid "media.image.short" +msgstr "img" + +#: src/app/main/ui/workspace/colorpicker.cljs:477 +msgid "media.keep-aspect-ratio" +msgstr "가로세로 비율 유지" + +#: src/app/main/ui/workspace/colorpicker.cljs:228 +msgid "media.linear" +msgstr "선형" + +#: src/app/main/ui/workspace/colorpicker.cljs:229 +msgid "media.radial" +msgstr "방사형" + +#: src/app/main/ui/workspace/colorpicker.cljs:255 +msgid "media.solid" +msgstr "단색" + +#: src/app/main/data/common.cljs:118 +msgid "modals.add-shared-confirm-empty.hint" +msgstr "" +"라이브러리가 비어 있습니다. 공유 라이브러리로 추가하면, 앞으로 생성할 에셋들" +"을 다른 파일에서도 사용할 수 있게 됩니다. 정말 게시하시겠습니까?" + +#: src/app/main/data/common.cljs:118 +msgid "modals.add-shared-confirm.hint" +msgstr "" +"공유 라이브러리로 추가하면, 이 파일 라이브러리의 에셋들을 다른 파일에서도 사" +"용할 수 있게 됩니다." + +#: src/app/main/ui/workspace/nudge.cljs:59 +msgid "modals.big-nudge" +msgstr "크게 이동" + +#: src/app/main/ui/settings/change_email.cljs:97 +msgid "modals.change-email.info" +msgstr "본인 확인을 위해 현재 이메일인 \"%s\"로 이메일을 보내드립니다." + +#: src/app/main/ui/settings/access_tokens.cljs:152, +#: src/app/main/ui/settings/access_tokens.cljs:158 +msgid "modals.create-access-token.copy-token" +msgstr "토큰 복사" + +#: src/app/main/ui/settings/access_tokens.cljs:130 +msgid "modals.create-access-token.expiration-date.label" +msgstr "만료일" + +#: src/app/main/ui/settings/access_tokens.cljs:124 +msgid "modals.create-access-token.name.label" +msgstr "이름" + +#: src/app/main/ui/settings/access_tokens.cljs:126 +msgid "modals.create-access-token.name.placeholder" +msgstr "토큰의 용도를 알 수 있는 이름을 입력하세요:" + +#: src/app/main/ui/settings/access_tokens.cljs:178 +msgid "modals.create-access-token.submit-label" +msgstr "토큰 생성" + +#: src/app/main/ui/settings/access_tokens.cljs:111 +msgid "modals.create-access-token.title" +msgstr "액세스 토큰 생성" + +#: src/app/main/ui/dashboard/team.cljs:1103 +msgid "modals.create-webhook.url.label" +msgstr "페이로드(Payload) URL" + +#: src/app/main/ui/dashboard/team.cljs:1104 +msgid "modals.create-webhook.url.placeholder" +msgstr "https://example.com/postreceive" + +#: src/app/main/ui/settings/access_tokens.cljs:257 +msgid "modals.delete-acces-token.accept" +msgstr "토큰 삭제" + +#: src/app/main/ui/settings/access_tokens.cljs:256 +msgid "modals.delete-acces-token.message" +msgstr "정말 이 토큰을 삭제하시겠습니까?" + +#: src/app/main/ui/settings/access_tokens.cljs:255 +msgid "modals.delete-acces-token.title" +msgstr "토큰 삭제" + +#: src/app/main/ui/settings/delete_account.cljs:56 +msgid "modals.delete-account.cancel" +msgstr "취소하고 계정 유지" + +#: src/app/main/ui/settings/delete_account.cljs:61 +msgid "modals.delete-account.confirm" +msgstr "네, 계정을 삭제합니다" + +#: src/app/main/ui/settings/delete_account.cljs:50 +msgid "modals.delete-account.info" +msgstr "계정을 삭제하면 현재의 모든 프로젝트와 아카이브를 잃게 됩니다." + +#: src/app/main/ui/settings/delete_account.cljs:43 +msgid "modals.delete-account.title" +msgstr "정말 계정을 삭제하시겠습니까?" + +#: src/app/main/ui/comments.cljs:888 +msgid "modals.delete-comment-thread.message" +msgstr "정말 이 대화를 삭제하시겠습니까? 이 스레드의 모든 댓글이 삭제됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:156 +msgid "modals.delete-component-annotation.message" +msgstr "이 주석을 삭제하시겠습니까?" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:155 +msgid "modals.delete-component-annotation.title" +msgstr "주석 삭제" + +#: src/app/main/ui/dashboard/file_menu.cljs:118 +msgid "modals.delete-file-multi-confirm.message" +msgstr "%s개의 파일을 삭제하시겠습니까?" + +#: src/app/main/ui/dashboard/file_menu.cljs:117 +msgid "modals.delete-file-multi-confirm.title" +msgstr "%s개 파일 삭제 중" + +#: src/app/main/ui/dashboard/fonts.cljs:356 +msgid "modals.delete-font-variant.message" +msgstr "" +"정말 이 글꼴 스타일을 삭제하시겠습니까? 파일에서 사용 중인 경우 로드되지 않습" +"니다." + +#: src/app/main/ui/dashboard/fonts.cljs:342 +msgid "modals.delete-font.message" +msgstr "이 글꼴을 삭제하시겠습니까? 파일에서 사용 중인 경우 로드되지 않습니다." + +#: src/app/main/ui/delete_shared.cljs:54 +msgid "modals.delete-shared-confirm.accept" +msgid_plural "modals.delete-shared-confirm.accept" +msgstr[0] "파일 삭제" + +#: src/app/main/ui/delete_shared.cljs:58 +msgid "modals.delete-shared-confirm.activated.no-files-message" +msgid_plural "modals.delete-shared-confirm.activated.no-files-message" +msgstr[0] "어떤 파일에서도 활성화되어 있지 않습니다." + +#: src/app/main/ui/delete_shared.cljs:60 +msgid "modals.delete-shared-confirm.activated.scd-message" +msgid_plural "modals.delete-shared-confirm.activated.scd-message" +msgstr[0] "다음 위치에서 이 라이브러리가 활성화되어 있습니다: " + +#: src/app/main/ui/delete_shared.cljs:49 +msgid "modals.delete-shared-confirm.message" +msgid_plural "modals.delete-shared-confirm.message" +msgstr[0] "이 파일들을 삭제하시겠습니까?" + +#: src/app/main/ui/delete_shared.cljs:44 +msgid "modals.delete-shared-confirm.title" +msgid_plural "modals.delete-shared-confirm.title" +msgstr[0] "파일 삭제 중" + +#: src/app/main/ui/dashboard/sidebar.cljs:443 +msgid "modals.delete-team-confirm.accept" +msgstr "팀 삭제" + +#: src/app/main/ui/dashboard/sidebar.cljs:442 +msgid "modals.delete-team-confirm.message" +msgstr "" +"이 팀을 삭제하시겠습니까? 팀과 관련된 모든 프로젝트와 파일이 영구적으로 삭제" +"됩니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:441 +msgid "modals.delete-team-confirm.title" +msgstr "팀 삭제 중" + +#: src/app/main/ui/dashboard/team.cljs:461 +msgid "modals.delete-team-member-confirm.accept" +msgstr "구성원 삭제" + +#: src/app/main/ui/dashboard/team.cljs:460 +msgid "modals.delete-team-member-confirm.message" +msgstr "이 구성원을 팀에서 삭제하시겠습니까?" + +#: src/app/main/ui/dashboard/team.cljs:459 +msgid "modals.delete-team-member-confirm.title" +msgstr "팀 구성원 삭제" + +#: src/app/main/ui/delete_shared.cljs:62 +msgid "modals.delete-unpublish-shared-confirm.activated.hint" +msgid_plural "modals.delete-unpublish-shared-confirm.activated.hint" +msgstr[0] "" +"해당 파일에서 이미 사용된 에셋은 그대로 유지되며, 디자인에는 문제가 발생하지 " +"않습니다." + +#: src/app/main/ui/dashboard/team.cljs:1197 +msgid "modals.delete-webhook.accept" +msgstr "웹훅 삭제" + +#: src/app/main/ui/dashboard/team.cljs:1196 +msgid "modals.delete-webhook.message" +msgstr "이 웹훅을 삭제하시겠습니까?" + +#: src/app/main/ui/dashboard/team.cljs:1195 +msgid "modals.delete-webhook.title" +msgstr "웹훅 삭제 중" + +#: src/app/main/ui/dashboard/team.cljs:1126 +msgid "modals.edit-webhook.submit-label" +msgstr "웹훅 수정" + +#: src/app/main/ui/dashboard/team.cljs:1091 +msgid "modals.edit-webhook.title" +msgstr "웹훅 수정" + +#: src/app/main/ui/dashboard/team.cljs:249 +msgid "modals.invite-member-confirm.accept" +msgstr "초대 전송" + +#: src/app/main/ui/dashboard/team.cljs:245, +#: src/app/main/ui/onboarding/team_choice.cljs:203 +msgid "modals.invite-member.emails" +msgstr "이메일 주소 (쉼표로 구분)" + +#: src/app/main/ui/dashboard/team.cljs:229 +msgid "modals.invite-member.repeated-invitation" +msgstr "" +"일부 구성원은 이미 팀에 소속되어 있습니다. 나머지 구성원들만 초대합니다." + +#: src/app/main/ui/dashboard/team.cljs:222 +msgid "modals.invite-team-member.text" +msgstr "" +"팀에 구성원을 초대하여 이 파일과 모든 팀 파일에 접근할 수 있도록 할 수 있습니" +"다." + +#: src/app/main/ui/dashboard/team.cljs:218 +msgid "modals.invite-team-member.title" +msgstr "팀에 구성원 초대" + +#: src/app/main/ui/dashboard/sidebar.cljs:431, +#: src/app/main/ui/dashboard/team.cljs:427 +msgid "modals.leave-and-close-confirm.hint" +msgstr "" +"귀하가 이 팀의 유일한 구성원이므로, 팀을 나가면 프로젝트 및 파일과 함께 팀이 " +"삭제됩니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:430, +#: src/app/main/ui/dashboard/team.cljs:426 +msgid "modals.leave-and-close-confirm.message" +msgstr "정말 %s 팀을 나가시겠습니까?" + +#: src/app/main/ui/dashboard/change_owner.cljs:54 +msgid "modals.leave-and-reassign.forbidden" +msgstr "" +"소유자(Owner) 역할을 위임할 다른 구성원이 없으면 팀을 나갈 수 없습니다. 팀 삭" +"제를 고려해보세요." + +#: src/app/main/ui/dashboard/change_owner.cljs:50 +msgid "modals.leave-and-reassign.hint1" +msgstr "" +"귀하는 이 팀의 소유자입니다. 나가기 전에 소유자 역할을 위임할 다른 구성원을 " +"선택해주세요." + +#: src/app/main/ui/dashboard/change_owner.cljs:73 +msgid "modals.leave-and-reassign.promote-and-leave" +msgstr "위임하고 나가기" + +#: src/app/main/ui/dashboard/change_owner.cljs:30 +msgid "modals.leave-and-reassign.select-member-to-promote" +msgstr "위임할 구성원 선택" + +#: src/app/main/ui/dashboard/change_owner.cljs:44 +msgid "modals.leave-and-reassign.title" +msgstr "팀을 나가기 전에" + +#: src/app/main/ui/dashboard/sidebar.cljs:410, +#: src/app/main/ui/dashboard/sidebar.cljs:432, +#: src/app/main/ui/dashboard/team.cljs:428, +#: src/app/main/ui/dashboard/team.cljs:450 +msgid "modals.leave-confirm.accept" +msgstr "팀 나가기" + +#: src/app/main/ui/dashboard/sidebar.cljs:409, +#: src/app/main/ui/dashboard/team.cljs:449 +msgid "modals.leave-confirm.message" +msgstr "이 팀을 나가시겠습니까?" + +#: src/app/main/ui/dashboard/sidebar.cljs:408, +#: src/app/main/ui/dashboard/sidebar.cljs:429, +#: src/app/main/ui/dashboard/team.cljs:425, +#: src/app/main/ui/dashboard/team.cljs:448 +msgid "modals.leave-confirm.title" +msgstr "팀 나가는 중" + +#: src/app/main/ui/delete_shared.cljs:56 +msgid "modals.move-shared-confirm.accept" +msgid_plural "modals.move-shared-confirm.accept" +msgstr[0] "이동" + +#: src/app/main/ui/delete_shared.cljs:51 +msgid "modals.move-shared-confirm.message" +msgid_plural "modals.move-shared-confirm.message" +msgstr[0] "이 라이브러리를 이동하시겠습니까?" + +#: src/app/main/ui/delete_shared.cljs:46 +msgid "modals.move-shared-confirm.title" +msgid_plural "modals.move-shared-confirm.title" +msgstr[0] "라이브러리 이동" + +#: src/app/main/ui/workspace/main_menu.cljs:302, +#: src/app/main/ui/workspace/nudge.cljs:46 +msgid "modals.nudge-title" +msgstr "이동 간격" + +#: src/app/main/ui/dashboard/team.cljs:380 +msgid "modals.promote-owner-confirm.accept" +msgstr "소유권 이전" + +#: src/app/main/ui/dashboard/team.cljs:379 +msgid "modals.promote-owner-confirm.hint" +msgstr "" +"소유권을 이전하면 귀하의 역할이 관리자(Admin)로 변경되며, 이 팀에 대한 일부 " +"권한을 잃게 됩니다. " + +#: src/app/main/ui/dashboard/team.cljs:378 +msgid "modals.promote-owner-confirm.message" +msgstr "" +"귀하는 현재 이 팀의 소유자입니다. 정말 %s님을 팀의 새 소유자로 지정하시겠습니" +"까?" + +#: src/app/main/ui/dashboard/team.cljs:377 +msgid "modals.promote-owner-confirm.title" +msgstr "새 팀 소유자" + +#: src/app/main/ui/workspace/libraries.cljs:286 +msgid "modals.publish-empty-library.accept" +msgstr "게시" + +#: src/app/main/ui/workspace/libraries.cljs:285 +msgid "modals.publish-empty-library.message" +msgstr "라이브러리가 비어 있습니다. 정말 게시하시겠습니까?" + +#: src/app/main/ui/workspace/libraries.cljs:284 +msgid "modals.publish-empty-library.title" +msgstr "빈 라이브러리 게시" + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "modals.remove-shared-confirm.accept" +msgstr "공유 라이브러리에서 제거" + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "modals.remove-shared-confirm.hint" +msgstr "" +"공유 라이브러리에서 제거하면, 이 파일의 라이브러리를 다른 파일에서 더 이상 사" +"용할 수 없게 됩니다." + +#: src/app/main/ui/workspace/header.cljs, +#: src/app/main/ui/dashboard/file_menu.cljs +msgid "modals.remove-shared-confirm.message" +msgstr "\"%s\"를 공유 라이브러리에서 제거" + +#: src/app/main/ui/workspace/nudge.cljs:52 +msgid "modals.small-nudge" +msgstr "미세 이동" + +#: src/app/main/ui/delete_shared.cljs:55 +msgid "modals.unpublish-shared-confirm.accept" +msgid_plural "modals.unpublish-shared-confirm.accept" +msgstr[0] "게시 취소" + +#: src/app/main/ui/delete_shared.cljs:50 +msgid "modals.unpublish-shared-confirm.message" +msgid_plural "modals.unpublish-shared-confirm.message" +msgstr[0] "이 라이브러리의 게시를 취소하시겠습니까?" + +#: src/app/main/ui/delete_shared.cljs:45 +msgid "modals.unpublish-shared-confirm.title" +msgid_plural "modals.unpublish-shared-confirm.title" +msgstr[0] "라이브러리 게시 취소" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "modals.update-remote-component-in-bulk.hint" +msgstr "" +"공유 라이브러리의 컴포넌트를 업데이트하려고 합니다. 이를 사용하는 다른 파일들" +"에 영향을 줄 수 있습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "modals.update-remote-component-in-bulk.message" +msgstr "공유 라이브러리의 컴포넌트 일괄 업데이트" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:417 +msgid "modals.update-remote-component.accept" +msgstr "업데이트" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:416 +msgid "modals.update-remote-component.cancel" +msgstr "취소" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:415 +msgid "modals.update-remote-component.hint" +msgstr "" +"공유 라이브러리의 컴포넌트를 업데이트하려고 합니다. 이를 사용하는 다른 파일들" +"에 영향을 줄 수 있습니다." + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:414 +msgid "modals.update-remote-component.message" +msgstr "공유 라이브러리의 컴포넌트 업데이트" + +#: src/app/main/ui/static.cljs:288 +msgid "not-found.desc-message.doesnt-exist" +msgstr "페이지가 존재하지 않습니다" + +#: src/app/main/ui/static.cljs:287 +msgid "not-found.desc-message.error" +msgstr "404 오류" + +#: src/app/main/ui/static.cljs:138 +msgid "not-found.login.free" +msgstr "" +"Penpot은 디자인과 코드 사이의 협업을 위한 무료 오픈소스 디자인 툴입니다" + +#: src/app/main/ui/auth/recovery_request.cljs:114 +msgid "not-found.login.sent-recovery" +msgstr "다음을 통해 복구 이메일을 보냈습니다:" + +#: src/app/main/ui/auth/recovery_request.cljs:116 +msgid "not-found.login.sent-recovery-check" +msgstr "이메일을 확인하고 링크를 클릭하여 새 비밀번호를 생성하세요." + +#: src/app/main/ui/static.cljs:152 +msgid "not-found.login.signup-free" +msgstr "무료 회원가입" + +#: src/app/main/ui/static.cljs:153 +msgid "not-found.login.start-using" +msgstr "몇 초 안에 Penpot 사용을 시작해보세요!" + +#: src/app/main/ui/static.cljs:69 +msgid "not-found.made-with-love" +msgstr "사랑과 오픈 소스의 힘으로 만들어졌습니다" + +#: src/app/main/ui/static.cljs:248 +msgid "not-found.no-permission.already-requested.file" +msgstr "이미 이 파일에 대한 접근 권한을 요청하셨습니다." + +#: src/app/main/ui/static.cljs:249 +msgid "not-found.no-permission.already-requested.or-others.file" +msgstr "" +"이미 이 파일 또는 이 팀의 다른 파일/프로젝트에 대한 접근 권한을 요청하셨습니" +"다." + +#: src/app/main/ui/static.cljs:255 +msgid "not-found.no-permission.already-requested.or-others.project" +msgstr "" +"이미 이 프로젝트 또는 이 팀의 다른 프로젝트/파일에 대한 접근 권한을 요청하셨" +"습니다." + +#: src/app/main/ui/static.cljs:254 +msgid "not-found.no-permission.already-requested.project" +msgstr "이미 이 프로젝트에 대한 접근 권한을 요청하셨습니다." + +#: src/app/main/ui/static.cljs:269, src/app/main/ui/static.cljs:278 +msgid "not-found.no-permission.ask" +msgstr "접근 권한 요청" + +#: src/app/main/ui/static.cljs:261 +msgid "not-found.no-permission.done.remember" +msgstr "소유자가 승인하면 팀에 초대될 것임을 잊지 마세요." + +#: src/app/main/ui/static.cljs:260 +msgid "not-found.no-permission.done.success" +msgstr "요청이 성공적으로 전송되었습니다!" + +#: src/app/main/ui/static.cljs:266 +msgid "not-found.no-permission.file" +msgstr "이 파일에 대한 접근 권한이 없습니다." + +#: src/app/main/ui/static.cljs:56, src/app/main/ui/static.cljs:244, +#: src/app/main/ui/static.cljs:250, src/app/main/ui/static.cljs:256, +#: src/app/main/ui/static.cljs:262, src/app/main/ui/static.cljs:271, +#: src/app/main/ui/static.cljs:280 +msgid "not-found.no-permission.go-dashboard" +msgstr "내 Penpot으로 이동" + +#: src/app/main/ui/static.cljs:268, src/app/main/ui/static.cljs:277 +msgid "not-found.no-permission.if-approves" +msgstr "소유자가 승인하면 팀에 초대됩니다." + +#: src/app/main/ui/static.cljs:496, src/app/main/ui/static.cljs:509 +msgid "not-found.no-permission.penpot-file" +msgstr "Penpot 파일" + +#: src/app/main/ui/static.cljs:243, src/app/main/ui/static.cljs:275 +msgid "not-found.no-permission.project" +msgstr "이 프로젝트에 대한 접근 권한이 없습니다." + +#: src/app/main/ui/static.cljs:495, src/app/main/ui/static.cljs:507 +msgid "not-found.no-permission.project-name" +msgstr "프로젝트" + +#: src/app/main/ui/static.cljs:267 +msgid "not-found.no-permission.you-can-ask.file" +msgstr "이 파일에 접근하려면 팀 소유자에게 문의하세요." + +#: src/app/main/ui/static.cljs:276 +msgid "not-found.no-permission.you-can-ask.project" +msgstr "이 프로젝트에 접근하려면 팀 소유자에게 문의하세요." + +#: src/app/main/data/common.cljs:89 +msgid "notifications.by-code.maintenance" +msgstr "" +"시스템 점검 안내: 5분 이내에 짧은 점검을 위해 서비스가 중단될 예정입니다." + +#: src/app/main/data/common.cljs:82 +msgid "notifications.by-code.upgrade-version" +msgstr "새 버전이 출시되었습니다. 페이지를 새로고침해주세요" + +#: src/app/main/ui/dashboard/team.cljs:825 +msgid "notifications.invitation-deleted" +msgstr "초대가 성공적으로 삭제되었습니다" + +#: src/app/main/ui/dashboard/team.cljs:170, +#: src/app/main/ui/dashboard/team.cljs:867 +msgid "notifications.invitation-email-sent" +msgstr "초대가 성공적으로 전송되었습니다" + +#: src/app/main/ui/dashboard/team.cljs:635 +msgid "notifications.invitation-link-copied" +msgstr "초대 링크가 복사되었습니다" + +#: src/app/main/ui/settings/delete_account.cljs:24 +msgid "notifications.profile-deletion-not-allowed" +msgstr "" +"프로필을 삭제할 수 없습니다. 진행하기 전에 팀 소유권을 먼저 위임해주세요." + +#: src/app/main/ui/settings/change_email.cljs:46 +msgid "notifications.validation-email-sent" +msgstr "인증 이메일을 %s로 보냈습니다. 이메일을 확인해주세요!" + +msgid "onboarding-v2.before-start.desc1" +msgstr "" +"사용자 가이드와 YouTube 채널 등 Penpot 시작을 돕는 다양한 리소스가 준비되어 " +"있습니다." + +msgid "onboarding-v2.before-start.desc2" +msgstr "" +"프로토타이핑부터 디자인 정리 및 공유까지, Penpot 사용법에 대한 자세한 정보입" +"니다." + +msgid "onboarding-v2.before-start.desc2.title" +msgstr "사용자 가이드" + +msgid "onboarding-v2.before-start.desc3" +msgstr "" +"공식 튜토리얼 및 커뮤니티에서 제작한 튜토리얼 영상을 시청하실 수 있습니다." + +msgid "onboarding-v2.before-start.desc3.title" +msgstr "비디오 튜토리얼" + +msgid "onboarding-v2.before-start.title" +msgstr "시작하기 전에" + +#: src/app/main/ui/onboarding/newsletter.cljs:68 +msgid "onboarding-v2.newsletter.desc" +msgstr "Penpot 뉴스레터를 구독하고 제품 개발 현황 및 소식을 받아보세요." + +#: src/app/main/ui/onboarding/newsletter.cljs:88 +msgid "onboarding-v2.newsletter.news" +msgstr "Penpot 관련 소식 받기 (블로그 포스트, 비디오 튜토리얼, 스트리밍 등)." + +#: src/app/main/ui/onboarding/newsletter.cljs:96 +msgid "onboarding-v2.newsletter.privacy1" +msgstr "우리는 개인정보를 소중히 여깁니다. 다음을 확인해주세요: " + +#: src/app/main/ui/onboarding/newsletter.cljs:102 +msgid "onboarding-v2.newsletter.privacy2" +msgstr "" +"꼭 필요한 정보만 보내드립니다. 뉴스레터 하단의 수신 거부 링크를 통해 언제든" +"지 구독을 취소하실 수 있습니다." + +#: src/app/main/ui/auth/register.cljs:35, +#: src/app/main/ui/onboarding/newsletter.cljs:76 +msgid "onboarding-v2.newsletter.updates" +msgstr ".제품 업데이트 소식 받기 (새 기능, 릴리즈, 수정 사항 등)." + +msgid "onboarding-v2.welcome.desc1" +msgstr "" +"Penpot은 오픈소스이며 Kaleidos와 커뮤니티가 함께 만들어갑니다. 이미 많은 분들" +"이 서로 돕고 있으며, 누구나 다음과 같이 참여할 수 있습니다:" + +msgid "onboarding-v2.welcome.desc2" +msgstr "" +"전체 커뮤니티 및 Penpot 핵심 팀과 함께 Penpot의 현재와 미래에 대해 배우고, 공" +"유하고, 토론하는 공개 공간입니다." + +msgid "onboarding-v2.welcome.desc2.title" +msgstr "커뮤니티 참여" + +msgid "onboarding-v2.welcome.desc3" +msgstr "" +"번역, 기능 제안, 코어 기여, 버그 찾기 등 협업 방법을 확인하실 수 있습니다." + +msgid "onboarding-v2.welcome.desc3.title" +msgstr "기여 가이드" + +#: src/app/main/ui/onboarding/team_choice.cljs:241 +msgid "onboarding-v2.welcome.title" +msgstr "Penpot에 오신 것을 환영합니다!" + +#: src/app/main/ui/onboarding/team_choice.cljs:254 +msgid "onboarding.choice.team-up.continue-creating-team" +msgstr "팀 생성 계속하기" + +#: src/app/main/ui/onboarding/team_choice.cljs:230 +msgid "onboarding.choice.team-up.continue-without-a-team" +msgstr "팀 없이 계속하기" + +#: src/app/main/ui/onboarding/team_choice.cljs:214 +msgid "onboarding.choice.team-up.create-team-and-invite" +msgstr "팀 생성 및 초대" + +msgid "onboarding.choice.team-up.create-team-and-send-invites" +msgstr "팀 생성 및 초대 전송" + +#: src/app/main/ui/onboarding/team_choice.cljs:219 +msgid "onboarding.choice.team-up.create-team-and-send-invites-description" +msgstr "나중에 초대할 수도 있습니다" + +#: src/app/main/ui/onboarding/team_choice.cljs:177 +msgid "onboarding.choice.team-up.create-team-desc" +msgstr "팀 이름을 정한 후, 구성원들을 초대할 수 있습니다." + +#: src/app/main/ui/onboarding/team_choice.cljs:185 +msgid "onboarding.choice.team-up.create-team-placeholder" +msgstr "팀 이름 입력" + +#: src/app/main/ui/onboarding/team_choice.cljs:215 +msgid "onboarding.choice.team-up.create-team-without-invite" +msgstr "팀 생성" + +msgid "onboarding.choice.team-up.create-team-without-inviting" +msgstr "초대 없이 팀 생성" + +#: src/app/main/ui/dashboard/projects.cljs:97, +#: src/app/main/ui/onboarding/team_choice.cljs:187 +msgid "onboarding.choice.team-up.invite-members" +msgstr "구성원 초대" + +#: src/app/main/ui/onboarding/team_choice.cljs:188 +msgid "onboarding.choice.team-up.invite-members-info" +msgstr "" +"모든 분들을 포함하는 것을 잊지 마세요. 개발자, 디자이너, 매니저... 다양성이 " +"힘이 됩니다 :)" + +#: src/app/main/ui/dashboard/team.cljs:234, +#: src/app/main/ui/onboarding/team_choice.cljs:194 +msgid "onboarding.choice.team-up.roles" +msgstr "다음 역할로 초대:" + +#: src/app/main/ui/onboarding/team_choice.cljs:223 +msgid "onboarding.choice.team-up.start-without-a-team" +msgstr "팀 없이 시작하기" + +#: src/app/main/ui/onboarding/team_choice.cljs:225 +msgid "onboarding.choice.team-up.start-without-a-team-description" +msgstr "나중에 언제든지 팀을 생성할 수 있습니다." + +msgid "onboarding.newsletter.accept" +msgstr "네, 구독합니다" + +#: src/app/main/ui/onboarding/newsletter.cljs:42 +msgid "onboarding.newsletter.acceptance-message" +msgstr "구독 요청이 전송되었습니다. 확인 메일을 보내드릴게요." + +#: src/app/main/ui/onboarding/newsletter.cljs:100 +msgid "onboarding.newsletter.policy" +msgstr "개인정보 처리방침." + +#: src/app/main/ui/onboarding/newsletter.cljs:65 +msgid "onboarding.newsletter.title" +msgstr "Penpot 소식을 받아보시겠어요?" + +#: src/app/main/ui/onboarding/questions.cljs:108 +msgid "onboarding.questions.lets-get-started" +msgstr "시작해볼까요!" + +#: src/app/main/ui/onboarding/questions.cljs:249 +msgid "onboarding.questions.reasons.alternative" +msgstr "Figma, XD 등의 대안을 찾고 있음" + +#: src/app/main/ui/onboarding/questions.cljs:243 +msgid "onboarding.questions.reasons.exploring" +msgstr "그냥 둘러보는 중" + +#: src/app/main/ui/onboarding/questions.cljs:246 +msgid "onboarding.questions.reasons.fit" +msgstr "Penpot이 우리 팀에 적합한지 검토 중" + +#: src/app/main/ui/onboarding/questions.cljs:252 +msgid "onboarding.questions.reasons.testing" +msgstr "셀프 호스팅 전 테스트 중" + +#: src/app/main/ui/onboarding/questions.cljs:407 +msgid "onboarding.questions.referer.article" +msgstr "기사 (블로그, 포스트, 뉴스레터)" + +#: src/app/main/ui/onboarding/questions.cljs:405 +msgid "onboarding.questions.referer.search" +msgstr "검색 엔진 (Google, Yahoo, Bing 등)" + +#: src/app/main/ui/onboarding/questions.cljs:406 +msgid "onboarding.questions.referer.social" +msgstr "소셜 미디어 (X, LinkedIn, FB 등)" + +#: src/app/main/ui/onboarding/questions.cljs:322 +msgid "onboarding.questions.start-with.code" +msgstr "디자인에서 실제 코드 추출" + +#: src/app/main/ui/onboarding/questions.cljs:320 +msgid "onboarding.questions.start-with.ds" +msgstr "디자인 시스템 구축" + +#: src/app/main/ui/onboarding/questions.cljs:318 +msgid "onboarding.questions.start-with.prototyping" +msgstr "프로토타이핑" + +#: src/app/main/ui/onboarding/questions.cljs:314 +msgid "onboarding.questions.start-with.ui" +msgstr "앱의 UI/UX 디자인" + +#: src/app/main/ui/onboarding/questions.cljs:316 +msgid "onboarding.questions.start-with.wireframing" +msgstr "와이어프레임 제작" + +#: src/app/main/ui/onboarding/questions.cljs:116 +msgid "onboarding.questions.step1.question1" +msgstr "어떤 용도로 Penpot을 사용하실 예정인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:273 +msgid "onboarding.questions.step1.question2" +msgstr "오늘 Penpot을 방문하신 이유는 무엇인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:112 +msgid "onboarding.questions.step1.subtitle" +msgstr "" +"Penpot이 사용자분께 더 잘 맞도록 도와드리기 위해 정보를 조금만 알려주세요. 답" +"변해주신 내용은 새 기능의 우선순위를 정하고 시작 가이드를 제공하는 데 도움이 " +"됩니다." + +#: src/app/main/ui/onboarding/questions.cljs:110 +msgid "onboarding.questions.step1.title" +msgstr "사용자분에 대해 알려주세요" + +#: src/app/main/ui/onboarding/questions.cljs:190 +msgid "onboarding.questions.step2.title" +msgstr "가장 많이 사용하는 디자인 도구는 무엇인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:122 +msgid "onboarding.questions.step3.question1" +msgstr "주로 어떤 작업을 하시나요?" + +#: src/app/main/ui/onboarding/questions.cljs:303 +msgid "onboarding.questions.step3.question2" +msgstr "사용자분의 역할은 무엇인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:290 +msgid "onboarding.questions.step3.question3" +msgstr "회사의 규모는 어느 정도인가요?" + +#: src/app/main/ui/onboarding/questions.cljs:270 +msgid "onboarding.questions.step3.title" +msgstr "사용자분의 업무에 대해 알려주세요" + +#: src/app/main/ui/onboarding/questions.cljs:345 +msgid "onboarding.questions.step4.title" +msgstr "어디서부터 시작하고 싶으신가요?" + +#: src/app/main/ui/onboarding/questions.cljs:428 +msgid "onboarding.questions.step5.title" +msgstr "어떻게 Penpot을 알게 되셨나요?" + +#: src/app/main/ui/onboarding/questions.cljs:233 +msgid "onboarding.questions.team-size.11-30" +msgstr "11-30명" + +#: src/app/main/ui/onboarding/questions.cljs:234 +msgid "onboarding.questions.team-size.2-10" +msgstr "2-10명" + +#: src/app/main/ui/onboarding/questions.cljs:232 +msgid "onboarding.questions.team-size.31-50" +msgstr "31-50명" + +#: src/app/main/ui/onboarding/questions.cljs:235 +msgid "onboarding.questions.team-size.freelancer" +msgstr "프리랜서입니다" + +#: src/app/main/ui/onboarding/questions.cljs:231 +msgid "onboarding.questions.team-size.more-than-50" +msgstr "50명 이상" + +#: src/app/main/ui/onboarding/questions.cljs:236 +msgid "onboarding.questions.team-size.personal-project" +msgstr "개인 프로젝트 진행 중입니다" + +#: src/app/main/ui/onboarding/questions.cljs:79 +msgid "onboarding.questions.use.education" +msgstr "교육용" + +#: src/app/main/ui/onboarding/questions.cljs:80 +msgid "onboarding.questions.use.personal" +msgstr "개인용" + +#: src/app/main/ui/onboarding/questions.cljs:78 +msgid "onboarding.questions.use.work" +msgstr "업무용" + +#: src/app/main/ui/onboarding/team_choice.cljs:175 +msgid "onboarding.team-modal.create-team" +msgstr "팀 생성" + +#: src/app/main/ui/onboarding/team_choice.cljs:31 +msgid "onboarding.team-modal.create-team-desc" +msgstr "" +"팀을 구성하면 동일한 파일과 프로젝트에서 다른 Penpot 사용자와 협업할 수 있습" +"니다." + +#: src/app/main/ui/onboarding/team_choice.cljs:36 +msgid "onboarding.team-modal.create-team-feature-1" +msgstr "무제한 파일 및 프로젝트" + +#: src/app/main/ui/onboarding/team_choice.cljs:40 +msgid "onboarding.team-modal.create-team-feature-2" +msgstr "실시간 동시 편집" + +#: src/app/main/ui/onboarding/team_choice.cljs:44 +msgid "onboarding.team-modal.create-team-feature-3" +msgstr "역할 관리" + +#: src/app/main/ui/onboarding/team_choice.cljs:48 +msgid "onboarding.team-modal.create-team-feature-4" +msgstr "무제한 구성원" + +#: src/app/main/ui/onboarding/team_choice.cljs:52 +msgid "onboarding.team-modal.create-team-feature-5" +msgstr "100% 무료!" + +#: src/app/main/ui/onboarding/team_choice.cljs:29 +msgid "onboarding.team-modal.team-definition" +msgstr "팀이란?" + +#: src/app/main/ui/onboarding/templates.cljs:77 +msgid "onboarding.templates.subtitle" +msgstr "제공되는 템플릿들입니다." + +#: src/app/main/ui/onboarding/templates.cljs:76 +msgid "onboarding.templates.title" +msgstr "디자인 시작하기" + +msgid "onboarding.welcome.alt" +msgstr "Penpot" + +#: src/app/main/ui/auth/recovery.cljs:88 +msgid "profile.recovery.go-to-login" +msgstr "로그인으로 이동" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:373 +msgid "settings.detach" +msgstr "해제" + +#: src/app/main/ui/inspect/exports.cljs:148, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:196, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:213, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:215, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:240, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:260, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:278, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:295, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:342, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:496, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1062, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1302, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:138, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:149, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:223, +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:221, +#: src/app/main/ui/workspace/sidebar/options/menus/input_wrapper_tokens.cljs:28, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:233, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:385, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:396, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:422, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:432, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:520, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:554, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:587, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:621, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:763, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:801, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:80, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:86, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:424, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:447, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:458, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:486, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:499, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:508, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:519, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:540, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:552, +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:155, +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:200, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:336, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:391, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:410, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:422, +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:235 +msgid "settings.multiple" +msgstr "혼합됨" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:431 +msgid "settings.remove-color" +msgstr "색상 제거" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:436 +msgid "settings.select-this-color" +msgstr "이 스타일을 사용하는 항목 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:409 +msgid "shortcut-section.basics" +msgstr "기본" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:415 +msgid "shortcut-section.dashboard" +msgstr "대시보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:418 +msgid "shortcut-section.viewer" +msgstr "뷰어" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:412 +msgid "shortcut-section.workspace" +msgstr "워크스페이스" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:58 +msgid "shortcut-subsection.alignment" +msgstr "정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:59 +msgid "shortcut-subsection.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:60 +msgid "shortcut-subsection.general-dashboard" +msgstr "일반" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:61 +msgid "shortcut-subsection.general-viewer" +msgstr "일반" + +#: src/app/main/ui/workspace/main_menu.cljs:857, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:62 +msgid "shortcut-subsection.main-menu" +msgstr "메인 메뉴" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:63 +msgid "shortcut-subsection.modify-layers" +msgstr "레이어 수정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:64 +msgid "shortcut-subsection.navigation-dashboard" +msgstr "내비게이션" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:65 +msgid "shortcut-subsection.navigation-viewer" +msgstr "내비게이션" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:66 +msgid "shortcut-subsection.navigation-workspace" +msgstr "내비게이션" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:67 +msgid "shortcut-subsection.panels" +msgstr "패널" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:68 +msgid "shortcut-subsection.path-editor" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:69 +msgid "shortcut-subsection.shape" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:70 +msgid "shortcut-subsection.text-editor" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:71 +msgid "shortcut-subsection.tools" +msgstr "도구" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:72 +msgid "shortcut-subsection.zoom-viewer" +msgstr "확대/축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:73 +msgid "shortcut-subsection.zoom-workspace" +msgstr "확대/축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:74 +msgid "shortcuts.add-comment" +msgstr "댓글" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:75 +msgid "shortcuts.add-node" +msgstr "노드 추가" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:76 +msgid "shortcuts.align-bottom" +msgstr "아래 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:77 +msgid "shortcuts.align-center" +msgstr "가운데 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:78 +msgid "shortcuts.align-hcenter" +msgstr "가로 가운데 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:79 +msgid "shortcuts.align-justify" +msgstr "양끝 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:80 +msgid "shortcuts.align-left" +msgstr "왼쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:81 +msgid "shortcuts.align-right" +msgstr "오른쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:82 +msgid "shortcuts.align-top" +msgstr "위쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:83 +msgid "shortcuts.align-vcenter" +msgstr "세로 가운데 정렬" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:84 +msgid "shortcuts.artboard-selection" +msgstr "선택 영역을 보드로 만들기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:85 +msgid "shortcuts.bold" +msgstr "굵게 표시/해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:86 +msgid "shortcuts.bool-difference" +msgstr "빼기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:87 +msgid "shortcuts.bool-exclude" +msgstr "제외" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:88 +msgid "shortcuts.bool-intersection" +msgstr "교차" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:89 +msgid "shortcuts.bool-union" +msgstr "합치기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:90 +msgid "shortcuts.bring-back" +msgstr "맨 뒤로 보내기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:91 +msgid "shortcuts.bring-backward" +msgstr "뒤로 보내기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:92 +msgid "shortcuts.bring-forward" +msgstr "앞으로 가져오기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:93 +msgid "shortcuts.bring-front" +msgstr "맨 앞으로 가져오기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:94 +msgid "shortcuts.clear-undo" +msgstr "실행 취소 기록 삭제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:95 +msgid "shortcuts.copy" +msgstr "복사" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:96 +msgid "shortcuts.copy-link" +msgstr "링크 복사" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:106 +msgid "shortcuts.copy-props" +msgstr "속성 복사" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:97 +msgid "shortcuts.create-component-variant" +msgstr "컴포넌트 / 베리언트 생성" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:98 +msgid "shortcuts.create-new-project" +msgstr "새로 생성" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:99 +msgid "shortcuts.cut" +msgstr "잘라내기" + +#: src/app/main/ui/viewer/header.cljs:82, +#: src/app/main/ui/workspace/right_header.cljs:82, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:100 +msgid "shortcuts.decrease-zoom" +msgstr "축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:101 +msgid "shortcuts.delete" +msgstr "삭제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:102 +msgid "shortcuts.delete-node" +msgstr "노드 삭제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:103 +msgid "shortcuts.detach-component" +msgstr "컴포넌트 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:104 +msgid "shortcuts.draw-curve" +msgstr "곡선" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:105 +msgid "shortcuts.draw-ellipse" +msgstr "타원" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:106 +msgid "shortcuts.draw-frame" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:107 +msgid "shortcuts.draw-nodes" +msgstr "경로 그리기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:108 +msgid "shortcuts.draw-path" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:109 +msgid "shortcuts.draw-rect" +msgstr "사각형" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:110 +msgid "shortcuts.draw-text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:111 +msgid "shortcuts.duplicate" +msgstr "복제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:112 +msgid "shortcuts.escape" +msgstr "취소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:113 +msgid "shortcuts.export-shapes" +msgstr "도형 내보내기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:114 +msgid "shortcuts.fit-all" +msgstr "전체 맞춤" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:117 +msgid "shortcuts.font-size-dec" +msgstr "글꼴 크기 감소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:118 +msgid "shortcuts.font-size-inc" +msgstr "글꼴 크기 증가" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:124 +msgid "shortcuts.hide-ui" +msgstr "UI 표시/숨기기" + +#: src/app/main/ui/viewer/header.cljs:88, +#: src/app/main/ui/workspace/right_header.cljs:87, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:125 +msgid "shortcuts.increase-zoom" +msgstr "확대" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:126 +msgid "shortcuts.insert-image" +msgstr "이미지 삽입" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:127 +msgid "shortcuts.italic" +msgstr "기울임꼴 표시/해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:128 +msgid "shortcuts.join-nodes" +msgstr "노드 연결" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:129 +msgid "shortcuts.line-through" +msgstr "취소선 표시/해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:130 +msgid "shortcuts.make-corner" +msgstr "직각 노드로 변경" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:131 +msgid "shortcuts.make-curve" +msgstr "곡선 노드로 변경" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:132 +msgid "shortcuts.mask" +msgstr "마스크" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:133 +msgid "shortcuts.merge-nodes" +msgstr "노드 병합" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:134 +msgid "shortcuts.move" +msgstr "이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:135 +msgid "shortcuts.move-fast-down" +msgstr "아래로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:136 +msgid "shortcuts.move-fast-left" +msgstr "왼쪽으로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:137 +msgid "shortcuts.move-fast-right" +msgstr "오른쪽으로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:138 +msgid "shortcuts.move-fast-up" +msgstr "위로 크게 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:139 +msgid "shortcuts.move-nodes" +msgstr "노드 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:140 +msgid "shortcuts.move-unit-down" +msgstr "아래로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:141 +msgid "shortcuts.move-unit-left" +msgstr "왼쪽으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:142 +msgid "shortcuts.move-unit-right" +msgstr "오른쪽으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:143 +msgid "shortcuts.move-unit-up" +msgstr "위로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:144 +msgid "shortcuts.next-frame" +msgstr "다음 보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:509 +msgid "shortcuts.not-found" +msgstr "단축키를 찾을 수 없습니다" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:145 +msgid "shortcuts.opacity-0" +msgstr "불투명도 100% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:146 +msgid "shortcuts.opacity-1" +msgstr "불투명도 10% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:147 +msgid "shortcuts.opacity-2" +msgstr "불투명도 20% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:148 +msgid "shortcuts.opacity-3" +msgstr "불투명도 30% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:149 +msgid "shortcuts.opacity-4" +msgstr "불투명도 40% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:150 +msgid "shortcuts.opacity-5" +msgstr "불투명도 50% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:151 +msgid "shortcuts.opacity-6" +msgstr "불투명도 60% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:152 +msgid "shortcuts.opacity-7" +msgstr "불투명도 70% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:153 +msgid "shortcuts.opacity-8" +msgstr "불투명도 80% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:154 +msgid "shortcuts.opacity-9" +msgstr "불투명도 90% 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:155 +msgid "shortcuts.open-color-picker" +msgstr "색상 선택기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:156 +msgid "shortcuts.open-comments" +msgstr "뷰어 댓글 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:157 +msgid "shortcuts.open-dashboard" +msgstr "대시보드로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:158 +msgid "shortcuts.open-inspect" +msgstr "뷰어 검사 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:159 +msgid "shortcuts.open-interactions" +msgstr "뷰어 인터랙션 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:160 +msgid "shortcuts.open-viewer" +msgstr "뷰어 인터랙션 섹션으로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:161 +msgid "shortcuts.open-workspace" +msgstr "워크스페이스로 이동" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:255 +msgid "shortcuts.or" +msgstr " 또는 " + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:162 +msgid "shortcuts.paste" +msgstr "붙여넣기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:111 +msgid "shortcuts.paste-props" +msgstr "속성 붙여넣기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:604 +msgid "shortcuts.plugins" +msgstr "플러그인 관리자" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:163 +msgid "shortcuts.prev-frame" +msgstr "이전 보드" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:164 +msgid "shortcuts.redo" +msgstr "다시 실행" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:165 +msgid "shortcuts.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:166 +msgid "shortcuts.reset-zoom" +msgstr "확대/축소 초기화" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:167 +msgid "shortcuts.scale" +msgstr "스케일" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:168 +msgid "shortcuts.search-placeholder" +msgstr "단축키 검색" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:169 +msgid "shortcuts.select-all" +msgstr "전체 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:170 +msgid "shortcuts.select-next" +msgstr "다음 레이어 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:171 +msgid "shortcuts.select-parent-layer" +msgstr "상위 레이어 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:172 +msgid "shortcuts.select-prev" +msgstr "이전 레이어 선택" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:173 +msgid "shortcuts.separate-nodes" +msgstr "노드 분리" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:174 +msgid "shortcuts.show-pixel-grid" +msgstr "픽셀 그리드 표시/숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:175 +msgid "shortcuts.show-shortcuts" +msgstr "단축키 표시/숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:176 +msgid "shortcuts.snap-nodes" +msgstr "노드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:177 +msgid "shortcuts.snap-pixel-grid" +msgstr "픽셀 그리드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:178 +msgid "shortcuts.start-editing" +msgstr "편집 시작" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:179 +msgid "shortcuts.start-measure" +msgstr "측정 시작" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:180 +msgid "shortcuts.stop-measure" +msgstr "측정 중지" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:181 +msgid "shortcuts.thumbnail-set" +msgstr "썸네일 설정" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:491, +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:498 +msgid "shortcuts.title" +msgstr "키보드 단축키" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:182 +msgid "shortcuts.toggle-alignment" +msgstr "동적 정렬 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:183 +msgid "shortcuts.toggle-assets" +msgstr "에셋 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:184 +msgid "shortcuts.toggle-colorpalette" +msgstr "색상 선택자 켜기/끄기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:185 +msgid "shortcuts.toggle-focus-mode" +msgstr "포커스 모드 켜기/끄기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:186 +msgid "shortcuts.toggle-fullscreen" +msgstr "전체 화면 켜기/끄기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:187 +msgid "shortcuts.toggle-guides" +msgstr "가이드 표시 / 숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:188 +msgid "shortcuts.toggle-history" +msgstr "히스토리 패널 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:189 +msgid "shortcuts.toggle-layers" +msgstr "레이어 패널 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:190 +msgid "shortcuts.toggle-layout-flex" +msgstr "플렉스 레이아웃 추가 / 제거" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:191 +msgid "shortcuts.toggle-layout-grid" +msgstr "그리드 레이아웃 추가 / 제거" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:192 +msgid "shortcuts.toggle-lock" +msgstr "잠금 / 잠금 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:193 +msgid "shortcuts.toggle-lock-size" +msgstr "비율 잠금" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:194 +msgid "shortcuts.toggle-rulers" +msgstr "눈금자 표시 / 숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:195 +msgid "shortcuts.toggle-snap-guides" +msgstr "가이드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:196 +msgid "shortcuts.toggle-snap-ruler-guide" +msgstr "눈금자 가이드에 스냅" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:197 +msgid "shortcuts.toggle-textpalette" +msgstr "텍스트 팔레트 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:198 +msgid "shortcuts.toggle-theme" +msgstr "테마 변경" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:199 +msgid "shortcuts.toggle-visibility" +msgstr "표시 / 숨기기" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:200 +msgid "shortcuts.toggle-zoom-style" +msgstr "확대/축소 스타일 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:201 +msgid "shortcuts.underline" +msgstr "밑줄 전환" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:202 +msgid "shortcuts.undo" +msgstr "실행 취소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:203 +msgid "shortcuts.ungroup" +msgstr "그룹 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:204 +msgid "shortcuts.unmask" +msgstr "마스크 해제" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:205 +msgid "shortcuts.v-distribute" +msgstr "세로로 균등 배분" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:206 +msgid "shortcuts.zoom-lense-decrease" +msgstr "줌 렌즈 축소" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:207 +msgid "shortcuts.zoom-lense-increase" +msgstr "줌 렌즈 확대" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:208 +msgid "shortcuts.zoom-selected" +msgstr "선택 항목에 맞추기" + +#: src/app/main/ui/dashboard/subscription.cljs:114, +#: src/app/main/ui/dashboard/subscription.cljs:162 +msgid "subscription.dashboard.power-up.enterprise-plan" +msgstr "엔터프라이즈 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:109 +msgid "subscription.dashboard.power-up.enterprise-trial.top-title" +msgstr "엔터프라이즈 플랜 (체험판)" + +#: src/app/main/ui/dashboard/subscription.cljs:84 +msgid "subscription.dashboard.power-up.professional.bottom-button" +msgstr "업그레이드!" + +#: src/app/main/ui/dashboard/subscription.cljs:83 +msgid "subscription.dashboard.power-up.professional.bottom-description" +msgstr "팀을 위한 추가 저장 공간, 파일 복구 등의 기능을 이용해 보세요." + +#: src/app/main/ui/dashboard/subscription.cljs:82 +msgid "subscription.dashboard.power-up.professional.top-title" +msgstr "프로페셔널 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:64, +#: src/app/main/ui/settings/subscription.cljs:107, +#: src/app/main/ui/settings/subscription.cljs:131 +msgid "subscription.dashboard.power-up.subscribe" +msgstr "구독하기" + +#: src/app/main/ui/dashboard/subscription.cljs:94 +msgid "subscription.dashboard.power-up.trial.bottom-description" +msgstr "" +"체험판은 만족스러우신가요? 모든 기능을 무제한으로 사용해 보세요. [구독하기|" +"target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:93 +msgid "subscription.dashboard.power-up.trial.top-title" +msgstr "무제한 플랜 (체험판)" + +#: src/app/main/ui/dashboard/subscription.cljs:100, +#: src/app/main/ui/dashboard/subscription.cljs:161 +msgid "subscription.dashboard.power-up.unlimited-plan" +msgstr "무제한 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:101 +msgid "subscription.dashboard.power-up.unlimited.bottom-text" +msgstr "" +"모든 팀에 대해 고정된 가격으로 무제한 저장 공간, 확장된 파일 복구 기능 및 무" +"제한 편집자 권한을 누리세요. [엔터프라이즈 플랜 살펴보기.|target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:70 +msgid "subscription.dashboard.power-up.unlimited.cta" +msgstr "자세히 보기" + +#: src/app/main/ui/dashboard/subscription.cljs:68 +msgid "subscription.dashboard.power-up.unlimited.top-description" +msgstr "추가 편집자 시트, 저장 공간, 자동 저장 버전 및 파일 백업 등." + +#: src/app/main/ui/dashboard/subscription.cljs:81, +#: src/app/main/ui/dashboard/subscription.cljs:92, +#: src/app/main/ui/dashboard/subscription.cljs:99, +#: src/app/main/ui/dashboard/subscription.cljs:108, +#: src/app/main/ui/dashboard/subscription.cljs:113 +msgid "subscription.dashboard.power-up.your-subscription" +msgstr "현재 구독:" + +#: src/app/main/ui/dashboard/subscription.cljs:196 +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "" +"소유하신 팀 전체에 %s명의 편집자가 있으며, 프로페셔널 플랜은 최대 8명까지 지" +"원합니다." + +#: src/app/main/ui/dashboard/subscription.cljs:204 +msgid "subscription.dashboard.professional-dashboard-cta-upgrade-owner" +msgstr "" +"더 많은 편집자 시트와 저장 공간, 파일 복구 기능을 위해 무제한 또는 엔터프라이" +"즈 플랜으로 업그레이드해 주세요. [지금 구독하기.|target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:137 +msgid "subscription.dashboard.team-plan" +msgstr "팀 플랜" + +#: src/app/main/ui/dashboard/subscription.cljs:199 +msgid "subscription.dashboard.unlimited-dashboard-cta-title" +msgstr "" +"팀이 계속 성장하고 있네요! 무제한 플랜은 최대 %s명의 편집자를 지원하지만, 현" +"재 %s명이 소속되어 있습니다." + +#: src/app/main/ui/dashboard/subscription.cljs:207 +msgid "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" +msgstr "" +"현재 편집자 수에 맞춰 플랜을 업그레이드해 주세요. [지금 구독하기.|" +"target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:184 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "" +"소유한 팀 전체에 추가된 새로운 편집자만 향후 요금에 합산됩니다. 25명 이상의 " +"편집자에 대해서는 월 $175의 고정 요금이 적용됩니다." + +#: src/app/main/ui/dashboard/sidebar.cljs:1073 +msgid "subscription.dashboard.upgrade-plan.power-up" +msgstr "업그레이드" + +#: src/app/main/ui/settings/sidebar.cljs:116, +#: src/app/main/ui/settings/subscription.cljs:425, +#: src/app/main/ui/settings/subscription.cljs:462 +msgid "subscription.labels" +msgstr "구독" + +#: src/app/main/ui/settings/subscription.cljs:484, +#: src/app/main/ui/settings/subscription.cljs:508 +msgid "subscription.settings.add-payment-to-continue" +msgstr "체험판 종료 후에도 계속 이용하시려면 결제 수단을 추가해 주세요" + +#: src/app/main/ui/settings/subscription.cljs:478, +#: src/app/main/ui/settings/subscription.cljs:554 +msgid "subscription.settings.benefits.all-professional-benefits" +msgstr "프로페셔널 플랜의 모든 혜택 및:" + +#: src/app/main/ui/settings/subscription.cljs:490, +#: src/app/main/ui/settings/subscription.cljs:502, +#: src/app/main/ui/settings/subscription.cljs:512, +#: src/app/main/ui/settings/subscription.cljs:570 +msgid "subscription.settings.benefits.all-unlimited-benefits" +msgstr "무제한 플랜의 모든 혜택 및:" + +#: src/app/main/ui/settings/subscription.cljs:53 +msgid "subscription.settings.editors" +msgstr "(x %s명 편집자)" + +#: src/app/main/ui/dashboard/subscription.cljs:145, +#: src/app/main/ui/settings/subscription.cljs:104, +#: src/app/main/ui/settings/subscription.cljs:457, +#: src/app/main/ui/settings/subscription.cljs:510, +#: src/app/main/ui/settings/subscription.cljs:566 +msgid "subscription.settings.enterprise" +msgstr "엔터프라이즈" + +#: src/app/main/ui/settings/subscription.cljs:100, +#: src/app/main/ui/settings/subscription.cljs:456, +#: src/app/main/ui/settings/subscription.cljs:500 +msgid "subscription.settings.enterprise-trial" +msgstr "엔터프라이즈 (체험판)" + +#: src/app/main/ui/settings/subscription.cljs:504, +#: src/app/main/ui/settings/subscription.cljs:514, +#: src/app/main/ui/settings/subscription.cljs:572 +msgid "subscription.settings.enterprise.autosave" +msgstr "90일 자동 저장 버전 및 파일 복구" + +#: src/app/main/ui/settings/subscription.cljs:505, +#: src/app/main/ui/settings/subscription.cljs:515, +#: src/app/main/ui/settings/subscription.cljs:573 +msgid "subscription.settings.enterprise.capped-bill" +msgstr "월 정액제" + +#: src/app/main/ui/settings/subscription.cljs:503, +#: src/app/main/ui/settings/subscription.cljs:513, +#: src/app/main/ui/settings/subscription.cljs:571 +msgid "subscription.settings.enterprise.unlimited-storage-benefit" +msgstr "무제한 저장 공간" + +#: src/app/main/ui/dashboard/subscription.cljs:150, +#: src/app/main/ui/settings/subscription.cljs:482, +#: src/app/main/ui/settings/subscription.cljs:494, +#: src/app/main/ui/settings/subscription.cljs:506, +#: src/app/main/ui/settings/subscription.cljs:516 +msgid "subscription.settings.manage-your-subscription" +msgstr "나의 구독 관리" + +#: src/app/main/ui/settings/subscription.cljs:298 +msgid "subscription.settings.management-dialog.step-2-add-payment-button" +msgstr "결제 수단 추가" + +#: src/app/main/ui/settings/subscription.cljs:285 +msgid "subscription.settings.management-dialog.step-2-description" +msgstr "" +"결제 정보를 지금 추가하면 체험판 종료 후에도 구독이 끊김 없이 유지되며 당사" +"의 오픈소스 프로젝트를 후원하실 수 있습니다. 지금은 요금이 청구되지 않습니다." + +#: src/app/main/ui/settings/subscription.cljs:293 +msgid "subscription.settings.management-dialog.step-2-skip-button" +msgstr "지금은 건너뛰고 체험판 시작" + +#: src/app/main/ui/settings/subscription.cljs:203 +msgid "subscription.settings.management-dialog.step-2-title" +msgstr "체험판을 더 원활하게 시작할 수 있도록 도와주세요" + +#: src/app/main/ui/settings/subscription.cljs:209 +msgid "subscription.settings.management.dialog.currently-editors-title" +msgid_plural "subscription.settings.management.dialog.currently-editors-title" +msgstr[0] "현재 팀 전체에서 편집할 수 있는 사람이 %s명 있습니다." + +#: src/app/main/ui/settings/subscription.cljs:230 +msgid "subscription.settings.management.dialog.downgrade" +msgstr "" +"알림: 하위 플랜으로 변경하면 저장 공간이 줄어들고 백업 및 버전 히스토리 보존 " +"기간이 단축됩니다." + +#: src/app/main/ui/settings/subscription.cljs:211 +msgid "subscription.settings.management.dialog.editors" +msgstr "편집자" + +#: src/app/main/ui/settings/subscription.cljs:218 +msgid "subscription.settings.management.dialog.editors-explanation" +msgstr "(소유자, 관리자, 편집자 포함. 뷰어는 편집자에 포함되지 않음)" + +#: src/app/main/ui/settings/subscription.cljs:263 +msgid "subscription.settings.management.dialog.input-error" +msgstr "" +"현재 인원보다 적은 편집자 수를 설정할 수 없습니다. 실제 편집을 하지 않는 사용" +"자는 팀 설정에서 역할(편집자/관리자에서 뷰어로)을 변경해 주세요." + +#: src/app/main/ui/settings/subscription.cljs:259 +msgid "subscription.settings.management.dialog.payment-explanation" +msgstr "체험판 종료 후 청구됩니다. 지금은 신용카드가 필요하지 않습니다." + +#: src/app/main/ui/settings/subscription.cljs:252, +#: src/app/main/ui/settings/subscription.cljs:256 +msgid "subscription.settings.management.dialog.price-month" +msgstr "**$%s**/월" + +#: src/app/main/ui/settings/subscription.cljs:204 +msgid "subscription.settings.management.dialog.title" +msgstr "내 팀에 %s 적용" + +#: src/app/main/ui/settings/subscription.cljs:266 +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "" +"팁: 향후 초대를 고려하여 지금 시트 수를 늘려두실 수 있습니다. 팀 전체 편집자" +"가 25명 이상이면 월 $175의 고정 요금이 적용됩니다." + +#: src/app/main/ui/settings/subscription.cljs:533 +msgid "subscription.settings.member-since" +msgstr "Penpot 가입일: %s" + +#: src/app/main/ui/settings/subscription.cljs:546, +#: src/app/main/ui/settings/subscription.cljs:560, +#: src/app/main/ui/settings/subscription.cljs:576 +msgid "subscription.settings.more-information" +msgstr "자세한 정보" + +#: src/app/main/ui/settings/subscription.cljs:536 +msgid "subscription.settings.other-plans" +msgstr "다른 Penpot 플랜" + +#: src/app/main/ui/settings/subscription.cljs:540, +#: src/app/main/ui/settings/subscription.cljs:553 +msgid "subscription.settings.price-editor-month" +msgstr "편집자당 월 비용" + +#: src/app/main/ui/settings/subscription.cljs:569 +msgid "subscription.settings.price-organization-month" +msgstr "조직/월" + +#: src/app/main/ui/dashboard/subscription.cljs:140, +#: src/app/main/ui/settings/subscription.cljs:102, +#: src/app/main/ui/settings/subscription.cljs:469, +#: src/app/main/ui/settings/subscription.cljs:538 +msgid "subscription.settings.professional" +msgstr "프로페셔널" + +#: src/app/main/ui/settings/subscription.cljs:471, +#: src/app/main/ui/settings/subscription.cljs:542 +msgid "subscription.settings.professional.autosave-benefit" +msgstr "7일 자동 저장 버전 및 파일 복구" + +#: src/app/main/ui/settings/subscription.cljs:470, +#: src/app/main/ui/settings/subscription.cljs:541 +msgid "subscription.settings.professional.storage-benefit" +msgstr "10GB 저장 공간" + +#: src/app/main/ui/settings/subscription.cljs:472, +#: src/app/main/ui/settings/subscription.cljs:543 +msgid "subscription.settings.professional.teams-editors-benefit" +msgstr "무제한 팀 생성. 소유한 팀 전체 합산 최대 8명의 편집자." + +#: src/app/main/ui/settings/subscription.cljs:50 +msgid "subscription.settings.recommended" +msgstr "추천" + +#: src/app/main/ui/settings/subscription.cljs:466 +msgid "subscription.settings.section-plan" +msgstr "내 구독 정보" + +#: src/app/main/ui/settings/subscription.cljs:313 +msgid "subscription.settings.start-trial" +msgstr "무료 체험판 시작" + +#: src/app/main/ui/settings/subscription.cljs:278, +#: src/app/main/ui/settings/subscription.cljs:544, +#: src/app/main/ui/settings/subscription.cljs:558, +#: src/app/main/ui/settings/subscription.cljs:574 +msgid "subscription.settings.subscribe" +msgstr "구독하기" + +#: src/app/main/ui/settings/subscription.cljs:345 +msgid "subscription.settings.success.dialog.description" +msgstr "" +"계정 상세 정보의 '구독' 페이지에서 언제든지 구독 내용을 수정하실 수 있습니다." + +#: src/app/main/ui/settings/subscription.cljs:343 +msgid "subscription.settings.success.dialog.thanks" +msgstr "Penpot %s 플랜을 선택해주셔서 감사합니다!" + +#: src/app/main/ui/settings/subscription.cljs:347 +msgid "subscription.settings.sucess.dialog.footer" +msgstr "플랜을 즐기세요!" + +#: src/app/main/ui/settings/subscription.cljs:340 +msgid "subscription.settings.sucess.dialog.title" +msgstr "%s이(가) 되셨습니다!" + +#: src/app/main/ui/settings/subscription.cljs:526 +msgid "subscription.settings.support-us-since" +msgstr "이 플랜으로 저희를 지원해주신 날짜: %s" + +#: src/app/main/ui/settings/subscription.cljs:558, +#: src/app/main/ui/settings/subscription.cljs:574 +msgid "subscription.settings.try-it-free" +msgstr "14일 무료 체험" + +#: src/app/main/ui/dashboard/subscription.cljs:143, +#: src/app/main/ui/settings/subscription.cljs:103, +#: src/app/main/ui/settings/subscription.cljs:454, +#: src/app/main/ui/settings/subscription.cljs:488, +#: src/app/main/ui/settings/subscription.cljs:550 +msgid "subscription.settings.unlimited" +msgstr "무제한" + +#: src/app/main/ui/dashboard/subscription.cljs:142, +#: src/app/main/ui/settings/subscription.cljs:99, +#: src/app/main/ui/settings/subscription.cljs:453, +#: src/app/main/ui/settings/subscription.cljs:476 +msgid "subscription.settings.unlimited-trial" +msgstr "무제한 (체험판)" + +#: src/app/main/ui/settings/subscription.cljs:480, +#: src/app/main/ui/settings/subscription.cljs:492, +#: src/app/main/ui/settings/subscription.cljs:556 +msgid "subscription.settings.unlimited.autosave-benefit" +msgstr "30일 자동 저장 버전 및 파일 복구" + +#: src/app/main/ui/settings/subscription.cljs:481, +#: src/app/main/ui/settings/subscription.cljs:493, +#: src/app/main/ui/settings/subscription.cljs:557 +msgid "subscription.settings.unlimited.bill" +msgstr "월 최대 $175 정액 청구" + +#: src/app/main/ui/settings/subscription.cljs:479, +#: src/app/main/ui/settings/subscription.cljs:491, +#: src/app/main/ui/settings/subscription.cljs:555 +msgid "subscription.settings.unlimited.storage-benefit" +msgstr "25GB 저장 공간" + +#: src/app/main/ui/dashboard/subscription.cljs:175, +#: src/app/main/ui/workspace/main_menu.cljs:945 +msgid "subscription.workspace.header.menu.option.power-up" +msgstr "플랜 업그레이드" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:57 +msgid "subscription.workspace.versions.warning.enterprise.subtext-owner" +msgstr "이 한도를 늘리려면 [%s](mailto)로 문의하세요" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:59 +msgid "subscription.workspace.versions.warning.subtext-member" +msgstr "이 한도를 늘리려면 팀 소유자에게 문의하세요: [mailto:%s](%s)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:58 +msgid "subscription.workspace.versions.warning.subtext-owner" +msgstr "이 한도를 늘리려면 [플랜을 업그레이드하세요|target:self](%s)" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "초대 %s개 선택됨" + +#: src/app/main/ui/dashboard/files.cljs:181 +msgid "title.dashboard.files" +msgstr "%s - Penpot" + +#: src/app/main/ui/dashboard/fonts.cljs:46 +msgid "title.dashboard.font-providers" +msgstr "글꼴 제공자 - %s - Penpot" + +#: src/app/main/ui/dashboard/fonts.cljs:45 +msgid "title.dashboard.fonts" +msgstr "글꼴 - %s - Penpot" + +#: src/app/main/ui/dashboard/projects.cljs:357 +msgid "title.dashboard.projects" +msgstr "프로젝트 - %s - Penpot" + +#: src/app/main/ui/dashboard/search.cljs:50 +msgid "title.dashboard.search" +msgstr "검색 - %s - Penpot" + +#: src/app/main/ui/dashboard/libraries.cljs:58 +msgid "title.dashboard.shared-libraries" +msgstr "공유 라이브러리 - %s - Penpot" + +#: src/app/main/ui/auth/verify_token.cljs:70, src/app/main/ui/auth.cljs:34 +msgid "title.default" +msgstr "Penpot - 팀을 위한 자유로운 디자인" + +#: src/app/main/ui/settings/access_tokens.cljs:278 +msgid "title.settings.access-tokens" +msgstr "프로필 - 액세스 토큰" + +#: src/app/main/ui/settings/feedback.cljs:161 +msgid "title.settings.feedback" +msgstr "의견 보내기 - Penpot" + +#: src/app/main/ui/settings/notifications.cljs:45 +msgid "title.settings.notifications" +msgstr "알림 - Penpot" + +#: src/app/main/ui/settings/options.cljs:83 +msgid "title.settings.options" +msgstr "설정 - Penpot" + +#: src/app/main/ui/settings/password.cljs:105 +msgid "title.settings.password" +msgstr "비밀번호 - Penpot" + +#: src/app/main/ui/settings/profile.cljs:124 +msgid "title.settings.profile" +msgstr "프로필 - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:981 +msgid "title.team-invitations" +msgstr "초대 - %s - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:535 +msgid "title.team-members" +msgstr "구성원 - %s - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:1296 +msgid "title.team-settings" +msgstr "설정 - %s - Penpot" + +#: src/app/main/ui/dashboard/team.cljs:1249 +msgid "title.team-webhooks" +msgstr "웹훅 - %s - Penpot" + +#: src/app/main/ui/viewer.cljs:423 +msgid "title.viewer" +msgstr "%s - 보기 모드 - Penpot" + +#: src/app/main/ui/workspace.cljs:237 +msgid "title.workspace" +msgstr "%s - Penpot" + +#: src/app/main/ui.cljs:138 +msgid "viewer.breaking-change.description" +msgstr "" +"공유 링크가 더 이상 유효하지 않습니다. 새 링크를 만들거나 소유자에게 요청하세" +"요." + +#: src/app/main/ui.cljs:137 +msgid "viewer.breaking-change.message" +msgstr "죄송합니다!" + +#: src/app/main/ui/viewer.cljs:573 +msgid "viewer.empty-state" +msgstr "페이지에 보드가 없습니다." + +#: src/app/main/ui/viewer.cljs:578 +msgid "viewer.frame-not-found" +msgstr "보드를 찾을 수 없습니다." + +#: src/app/main/ui/viewer/header.cljs:336 +msgid "viewer.header.comments-section" +msgstr "댓글 (%s)" + +#: src/app/main/ui/viewer/interactions.cljs:298 +msgid "viewer.header.dont-show-interactions" +msgstr "인터랙션 숨기기" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "워크스페이스에서 편집" + +#: src/app/main/ui/viewer/header.cljs:193 +msgid "viewer.header.fullscreen" +msgstr "전체 화면" + +#: src/app/main/ui/viewer/header.cljs:346 +msgid "viewer.header.inspect-section" +msgstr "검사 (%s)" + +#: src/app/main/ui/viewer/interactions.cljs:288 +msgid "viewer.header.interactions" +msgstr "인터랙션" + +#: src/app/main/ui/viewer/header.cljs:327 +msgid "viewer.header.interactions-section" +msgstr "인터랙션 (%s)" + +#: src/app/main/ui/viewer/share_link.cljs:193 +msgid "viewer.header.share.copy-link" +msgstr "링크 복사" + +#: src/app/main/ui/viewer/interactions.cljs:306 +msgid "viewer.header.show-interactions" +msgstr "인터랙션 표시" + +#: src/app/main/ui/viewer/interactions.cljs:317 +msgid "viewer.header.show-interactions-on-click" +msgstr "클릭 시 인터랙션 표시" + +#: src/app/main/ui/viewer/header.cljs:233 +msgid "viewer.header.sitemap" +msgstr "사이트맵" + +#: src/app/main/ui/dashboard/team.cljs:1203 +msgid "webhooks.last-delivery.success" +msgstr "마지막 전송이 성공했습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:55 +msgid "workspace.align.hcenter" +msgstr "가로 가운데 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:69 +msgid "workspace.align.hdistribute" +msgstr "가로 간격 균등 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:48 +msgid "workspace.align.hleft" +msgstr "왼쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:62 +msgid "workspace.align.hright" +msgstr "오른쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:91 +msgid "workspace.align.vbottom" +msgstr "아래쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:84 +msgid "workspace.align.vcenter" +msgstr "세로 가운데 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:98 +msgid "workspace.align.vdistribute" +msgstr "세로 간격 균등 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/align.cljs:77 +msgid "workspace.align.vtop" +msgstr "위쪽 정렬 (%s)" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:172 +msgid "workspace.assets.add-library" +msgstr "라이브러리 추가" + +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "workspace.assets.assets" +msgstr "에셋" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:152 +msgid "workspace.assets.box-filter-all" +msgstr "모든 에셋" + +#: src/app/main/ui/dashboard/grid.cljs:161, +#: src/app/main/ui/dashboard/grid.cljs:193, +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:489, +#: src/app/main/ui/workspace/sidebar/assets.cljs:158 +msgid "workspace.assets.colors" +msgstr "색상" + +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:497 +msgid "workspace.assets.colors.add-color" +msgstr "색상 추가" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:81 +msgid "workspace.assets.component-group-options" +msgstr "컴포넌트 그룹 옵션" + +#: src/app/main/ui/dashboard/grid.cljs:157, +#: src/app/main/ui/dashboard/grid.cljs:172, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:559, +#: src/app/main/ui/workspace/sidebar/assets.cljs:155 +msgid "workspace.assets.components" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:580 +msgid "workspace.assets.components.add-component" +msgstr "컴포넌트 추가" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:177, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:602 +msgid "workspace.assets.components.num-variants" +msgstr "%s개 베리언트" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:141 +msgid "workspace.assets.create-group" +msgstr "그룹 생성" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:152 +msgid "workspace.assets.create-group-hint" +msgstr "항목 이름이 \"그룹 이름 / 항목 이름\" 형식으로 자동으로 지정됩니다" + +#: src/app/main/ui/workspace/context_menu.cljs:684, +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:251, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:640, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:442 +msgid "workspace.assets.delete" +msgstr "삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:689 +msgid "workspace.assets.duplicate" +msgstr "복제" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:619 +msgid "workspace.assets.duplicate-main" +msgstr "메인 복제" + +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:247, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:438 +msgid "workspace.assets.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:186 +msgid "workspace.assets.filter" +msgstr "필터" + +#: src/app/main/ui/workspace/sidebar/assets/graphics.cljs:386, +#: src/app/main/ui/workspace/sidebar/assets.cljs:152 +msgid "workspace.assets.graphics" +msgstr "그래픽" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:189, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:575, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:806 +msgid "workspace.assets.grid-view" +msgstr "그리드 보기" + +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:255, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:624, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:447 +msgid "workspace.assets.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:151 +msgid "workspace.assets.group-name" +msgstr "그룹 이름" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:190, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:571, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:802 +msgid "workspace.assets.list-view" +msgstr "목록 보기" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:108, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:686 +msgid "workspace.assets.local-library" +msgstr "로컬 라이브러리" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:176 +msgid "workspace.assets.manage-library" +msgstr "라이브러리 관리" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:307 +msgid "workspace.assets.not-found" +msgstr "에셋을 찾을 수 없습니다" + +#: src/app/main/ui/workspace/sidebar/assets/file_library.cljs:113 +msgid "workspace.assets.open-library" +msgstr "라이브러리 파일 열기" + +#: src/app/main/ui/workspace/context_menu.cljs:687, +#: src/app/main/ui/workspace/sidebar/assets/colors.cljs:243, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:615, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:67, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:433 +msgid "workspace.assets.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:142 +msgid "workspace.assets.rename-group" +msgstr "그룹 이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:181 +msgid "workspace.assets.search" +msgstr "에셋 검색" + +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "workspace.assets.selected-count" +msgid_plural "workspace.assets.selected-count" +msgstr[0] "%s개 항목 선택됨" + +#: src/app/main/ui/workspace/sidebar/assets.cljs +msgid "workspace.assets.shared-library" +msgstr "공유 라이브러리" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:229 +msgid "workspace.assets.sidebar.components" +msgid_plural "workspace.assets.sidebar.components" +msgstr[0] "컴포넌트 %s개" + +#: src/app/main/ui/workspace/sidebar/assets.cljs:201 +msgid "workspace.assets.sort" +msgstr "정렬" + +#: src/app/main/ui/dashboard/grid.cljs:165, +#: src/app/main/ui/dashboard/grid.cljs:220, +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:396, +#: src/app/main/ui/workspace/sidebar/assets.cljs:161 +msgid "workspace.assets.typography" +msgstr "타이포그래피" + +#: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:404 +msgid "workspace.assets.typography.add-typography" +msgstr "타이포그래피 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.assets.typography.font-id" +msgstr "글꼴" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:528 +msgid "workspace.assets.typography.font-size" +msgstr "크기" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:524 +msgid "workspace.assets.typography.font-style" +msgstr "글꼴 스타일" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:546 +msgid "workspace.assets.typography.go-to-edit" +msgstr "스타일 라이브러리 파일로 이동하여 편집" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:536 +msgid "workspace.assets.typography.letter-spacing" +msgstr "자간" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:532 +msgid "workspace.assets.typography.line-height" +msgstr "행간" + +#: src/app/main/ui/dashboard/grid.cljs:230, +#: src/app/main/ui/workspace/libraries.cljs:566, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:487, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:512, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:619, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:639 +msgid "workspace.assets.typography.sample" +msgstr "가나다" + +msgid "workspace.assets.typography.text-styles" +msgstr "텍스트 스타일" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:540 +msgid "workspace.assets.typography.text-transform" +msgstr "텍스트 변환" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:70 +msgid "workspace.assets.ungroup" +msgstr "그룹 해제" + +#: src/app/main/ui/workspace/colorpicker.cljs:428, +#: src/app/main/ui/workspace/colorpicker.cljs:441 +msgid "workspace.colorpicker.color-tokens" +msgstr "색상 token" + +#: src/app/main/ui/workspace/colorpicker.cljs:434 +msgid "workspace.colorpicker.get-color" +msgstr "색상 가져오기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:506 +msgid "workspace.component.swap.loop-error" +msgstr "컴포넌트는 자기 자신 안에 중첩될 수 없습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:505 +msgid "workspace.component.switch.loop-error-multi" +msgstr "" +"일부 복사본을 전환할 수 없습니다. 컴포넌트는 자기 자신 안에 중첩될 수 없습니" +"다." + +#: src/app/main/ui/workspace/context_menu.cljs:796 +msgid "workspace.context-menu.grid-cells.area" +msgstr "영역 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:799 +msgid "workspace.context-menu.grid-cells.create-board" +msgstr "보드 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:791 +msgid "workspace.context-menu.grid-cells.merge" +msgstr "셀 병합" + +#: src/app/main/ui/workspace/context_menu.cljs:754 +msgid "workspace.context-menu.grid-track.column.add-after" +msgstr "오른쪽에 열 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:753 +msgid "workspace.context-menu.grid-track.column.add-before" +msgstr "왼쪽에 열 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:755 +msgid "workspace.context-menu.grid-track.column.delete" +msgstr "열 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:756 +msgid "workspace.context-menu.grid-track.column.delete-shapes" +msgstr "열 및 도형 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:752 +msgid "workspace.context-menu.grid-track.column.duplicate" +msgstr "열 복제" + +#: src/app/main/ui/workspace/context_menu.cljs:761 +msgid "workspace.context-menu.grid-track.row.add-after" +msgstr "아래에 행 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:760 +msgid "workspace.context-menu.grid-track.row.add-before" +msgstr "위에 행 1개 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:762 +msgid "workspace.context-menu.grid-track.row.delete" +msgstr "행 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:763 +msgid "workspace.context-menu.grid-track.row.delete-shapes" +msgstr "행 및 도형 삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:759 +msgid "workspace.context-menu.grid-track.row.duplicate" +msgstr "행 복제" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "디버깅 도구" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:512 +msgid "workspace.focus.focus-mode" +msgstr "포커스 모드" + +#: src/app/main/ui/workspace/context_menu.cljs:396, +#: src/app/main/ui/workspace/context_menu.cljs:711 +msgid "workspace.focus.focus-off" +msgstr "포커스 해제" + +#: src/app/main/ui/workspace/context_menu.cljs:395 +msgid "workspace.focus.focus-on" +msgstr "포커스 설정" + +msgid "workspace.focus.selection" +msgstr "선택" + +#: src/app/util/color.cljs:34 +msgid "workspace.gradients.linear" +msgstr "선형 그래디언트" + +#: src/app/util/color.cljs:35 +msgid "workspace.gradients.radial" +msgstr "방사형 그래디언트" + +#: src/app/main/ui/workspace/main_menu.cljs:274 +msgid "workspace.header.menu.disable-dynamic-alignment" +msgstr "동적 정렬 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:228 +msgid "workspace.header.menu.disable-scale-content" +msgstr "비율 유지 크기 조정 비활성화" + +#: src/app/main/ui/workspace/header.cljs +msgid "workspace.header.menu.disable-scale-text" +msgstr "텍스트 크기 조정 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:259 +msgid "workspace.header.menu.disable-snap-guides" +msgstr "가이드 스냅 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:289 +msgid "workspace.header.menu.disable-snap-pixel-grid" +msgstr "픽셀 스냅 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:243 +msgid "workspace.header.menu.disable-snap-ruler-guides" +msgstr "눈금자 가이드 스냅 비활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:275 +msgid "workspace.header.menu.enable-dynamic-alignment" +msgstr "동적 정렬 활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:229 +msgid "workspace.header.menu.enable-scale-content" +msgstr "비율 크기 조절 활성화" + +#: src/app/main/ui/workspace/header.cljs +msgid "workspace.header.menu.enable-scale-text" +msgstr "텍스트 크기 조절 활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:260 +msgid "workspace.header.menu.enable-snap-guides" +msgstr "가이드에 스냅" + +#: src/app/main/ui/workspace/main_menu.cljs:290 +msgid "workspace.header.menu.enable-snap-pixel-grid" +msgstr "픽셀 스냅 활성화" + +#: src/app/main/ui/workspace/main_menu.cljs:244 +msgid "workspace.header.menu.enable-snap-ruler-guides" +msgstr "눈금자 가이드에 스냅" + +#: src/app/main/ui/workspace/main_menu.cljs:422 +msgid "workspace.header.menu.hide-artboard-names" +msgstr "보드 이름 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:376 +msgid "workspace.header.menu.hide-guides" +msgstr "가이드 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:393 +msgid "workspace.header.menu.hide-palette" +msgstr "색상 팔레트 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:434 +msgid "workspace.header.menu.hide-pixel-grid" +msgstr "픽셀 그리드 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:360 +msgid "workspace.header.menu.hide-rules" +msgstr "눈금자 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:407 +msgid "workspace.header.menu.hide-textpalette" +msgstr "글꼴 팔레트 숨기기" + +#: src/app/main/ui/workspace/main_menu.cljs:884 +msgid "workspace.header.menu.option.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/main_menu.cljs:873 +msgid "workspace.header.menu.option.file" +msgstr "파일" + +#: src/app/main/ui/workspace/main_menu.cljs:930 +msgid "workspace.header.menu.option.help-info" +msgstr "도움말 및 정보" + +#: src/app/main/ui/workspace/main_menu.cljs:916 +msgid "workspace.header.menu.option.power-up" +msgstr "플랜 업그레이드" + +#: src/app/main/ui/workspace/main_menu.cljs:906 +msgid "workspace.header.menu.option.preferences" +msgstr "환경 설정" + +#: src/app/main/ui/workspace/main_menu.cljs:895 +msgid "workspace.header.menu.option.view" +msgstr "보기" + +#: src/app/main/ui/workspace/main_menu.cljs:506 +msgid "workspace.header.menu.redo" +msgstr "다시 실행" + +#: src/app/main/ui/workspace/main_menu.cljs:477 +msgid "workspace.header.menu.select-all" +msgstr "모두 선택" + +#: src/app/main/ui/workspace/main_menu.cljs:423 +msgid "workspace.header.menu.show-artboard-names" +msgstr "보드 이름 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:377 +msgid "workspace.header.menu.show-guides" +msgstr "가이드 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:394 +msgid "workspace.header.menu.show-palette" +msgstr "색상 팔레트 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:435 +msgid "workspace.header.menu.show-pixel-grid" +msgstr "픽셀 그리드 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:361 +msgid "workspace.header.menu.show-rules" +msgstr "눈금자 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:408 +msgid "workspace.header.menu.show-textpalette" +msgstr "글꼴 팔레트 표시" + +#: src/app/main/ui/workspace/main_menu.cljs:316 +msgid "workspace.header.menu.toggle-dark-theme" +msgstr "다크 테마로 전환" + +#: src/app/main/ui/workspace/main_menu.cljs:314, +#: src/app/main/ui/workspace/main_menu.cljs:317 +msgid "workspace.header.menu.toggle-light-theme" +msgstr "라이트 테마로 전환" + +#: src/app/main/ui/workspace/main_menu.cljs:315 +msgid "workspace.header.menu.toggle-system-theme" +msgstr "시스템 테마로 전환" + +#: src/app/main/ui/workspace/main_menu.cljs:492 +msgid "workspace.header.menu.undo" +msgstr "실행 취소" + +#: src/app/main/ui/viewer/header.cljs:93, +#: src/app/main/ui/workspace/right_header.cljs:92 +msgid "workspace.header.reset-zoom" +msgstr "초기화" + +#: src/app/main/ui/workspace/left_header.cljs:128 +msgid "workspace.header.save-error" +msgstr "저장 오류" + +#: src/app/main/ui/workspace/left_header.cljs:127 +msgid "workspace.header.saved" +msgstr "저장됨" + +#: src/app/main/ui/workspace/left_header.cljs:125, +#: src/app/main/ui/workspace/left_header.cljs:126 +msgid "workspace.header.saving" +msgstr "저장 중" + +#: src/app/main/ui/workspace/right_header.cljs:232 +msgid "workspace.header.share" +msgstr "공유" + +#: src/app/main/ui/workspace/right_header.cljs:48, +#: src/app/main/ui/workspace/right_header.cljs:53 +msgid "workspace.header.unsaved" +msgstr "저장하지 않은 변경 사항" + +#: src/app/main/ui/workspace/right_header.cljs:237 +msgid "workspace.header.viewer" +msgstr "보기 모드 (%s)" + +#: src/app/main/ui/viewer/header.cljs:74, +#: src/app/main/ui/workspace/right_header.cljs:74 +msgid "workspace.header.zoom" +msgstr "확대/축소" + +#: src/app/main/ui/viewer/header.cljs:104 +msgid "workspace.header.zoom-fill" +msgstr "채우기 - 채우기 맞춤" + +#: src/app/main/ui/viewer/header.cljs:97 +msgid "workspace.header.zoom-fit" +msgstr "맞추기 - 화면에 맞게 축소" + +#: src/app/main/ui/workspace/right_header.cljs:96 +msgid "workspace.header.zoom-fit-all" +msgstr "전체 맞추기" + +#: src/app/main/ui/viewer/header.cljs:111 +msgid "workspace.header.zoom-full-screen" +msgstr "전체 화면" + +#: src/app/main/ui/workspace/right_header.cljs:104 +msgid "workspace.header.zoom-selected" +msgstr "선택 항목에 맞추기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "4면 여백 옵션 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/grid_cell.cljs:275, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:859 +msgid "workspace.layout-grid.editor.options.edit-grid" +msgstr "그리드 편집" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1539 +msgid "workspace.layout-grid.editor.options.exit" +msgstr "종료" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:584, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:593, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:599 +msgid "workspace.layout-grid.editor.padding.bottom" +msgstr "아래쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:669 +msgid "workspace.layout-grid.editor.padding.expand" +msgstr "4면 패딩 옵션 보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:416, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:427, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:433 +msgid "workspace.layout-grid.editor.padding.horizontal" +msgstr "가로 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:618, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:627, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:633 +msgid "workspace.layout-grid.editor.padding.left" +msgstr "왼쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:551, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:560, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:566 +msgid "workspace.layout-grid.editor.padding.right" +msgstr "오른쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:517, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:526, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:532 +msgid "workspace.layout-grid.editor.padding.top" +msgstr "위쪽 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:380, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:391, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:397 +msgid "workspace.layout-grid.editor.padding.vertical" +msgstr "세로 패딩" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:62 +msgid "workspace.layout-grid.editor.title" +msgstr "그리드 편집 중" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:70 +msgid "workspace.layout-grid.editor.top-bar.done" +msgstr "완료" + +#: src/app/main/ui/workspace/viewport/grid_layout_editor.cljs:66 +msgid "workspace.layout-grid.editor.top-bar.locate" +msgstr "위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1565 +msgid "workspace.layout-grid.editor.top-bar.locate.tooltip" +msgstr "그리드 레이아웃 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "콘텐츠에 맞춤 (가로)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "콘텐츠에 맞춤 (세로)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "높이 고정" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "너비 고정" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "높이 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "너비 100%" + +#: src/app/main/ui/workspace/libraries.cljs +msgid "workspace.libraries.add" +msgstr "추가" + +#: src/app/main/ui/workspace/libraries.cljs:100, +#: src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "색상 %s개" + +#: src/app/main/ui/workspace/color_palette.cljs:147 +msgid "workspace.libraries.colors.empty-palette" +msgstr "라이브러리에 아직 색상 스타일이 없습니다" + +#: src/app/main/ui/workspace/text_palette.cljs:161 +msgid "workspace.libraries.colors.empty-typography-palette" +msgstr "라이브러리에 아직 타이포그래피 스타일이 없습니다" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:88, +#: src/app/main/ui/workspace/colorpicker/libraries.cljs:48, +#: src/app/main/ui/workspace/text_palette_ctx_menu.cljs:49 +msgid "workspace.libraries.colors.file-library" +msgstr "파일 라이브러리" + +#: src/app/main/ui/workspace/colorpicker.cljs +msgid "workspace.libraries.colors.hsv" +msgstr "HSV" + +#: src/app/main/ui/workspace/color_palette_ctx_menu.cljs:111, +#: src/app/main/ui/workspace/colorpicker/libraries.cljs:47 +msgid "workspace.libraries.colors.recent-colors" +msgstr "최근 색상" + +#: src/app/main/ui/workspace/colorpicker.cljs +msgid "workspace.libraries.colors.rgb-complementary" +msgstr "RGB 보색" + +#: src/app/main/ui/workspace/colorpicker.cljs:355 +msgid "workspace.libraries.colors.rgba" +msgstr "RGBA" + +#: src/app/main/ui/workspace/colorpicker.cljs:555 +msgid "workspace.libraries.colors.save-color" +msgstr "색상 스타일 저장" + +#: src/app/main/ui/workspace/libraries.cljs:94, +#: src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "컴포넌트 %s개" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "연결됨:" + +#: src/app/main/ui/workspace/libraries.cljs:392 +msgid "workspace.libraries.empty.add-some" +msgstr "또는 다음 중 하나를 추가해 보세요:" + +#: src/app/main/ui/workspace/libraries.cljs:386 +msgid "workspace.libraries.empty.no-libraries" +msgstr "팀에 공유 라이브러리가 없습니다. 여기서 찾아보세요" + +#: src/app/main/ui/workspace/libraries.cljs:390 +msgid "workspace.libraries.empty.some-templates" +msgstr "일부 템플릿 보기" + +#: src/app/main/ui/workspace/libraries.cljs:313 +msgid "workspace.libraries.file-library" +msgstr "파일 라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:97, +#: src/app/main/ui/workspace/libraries.cljs:122 +msgid "workspace.libraries.graphics" +msgid_plural "workspace.libraries.graphics" +msgstr[0] "그래픽 %s개" + +#: src/app/main/ui/workspace/libraries.cljs:307 +msgid "workspace.libraries.in-this-file" +msgstr "이 파일의 라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:628, +#: src/app/main/ui/workspace/libraries.cljs:648 +msgid "workspace.libraries.libraries" +msgstr "라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs +msgid "workspace.libraries.library" +msgstr "라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:487 +msgid "workspace.libraries.library-updates" +msgstr "라이브러리 업데이트" + +#: src/app/main/ui/workspace/libraries.cljs:381 +msgid "workspace.libraries.loading" +msgstr "로딩 중…" + +#: src/app/main/ui/workspace/libraries.cljs:387 +msgid "workspace.libraries.more-templates" +msgstr "더 많은 템플릿을 찾아보세요 " + +#: src/app/main/ui/workspace/libraries.cljs:485 +msgid "workspace.libraries.no-libraries-need-sync" +msgstr "업데이트가 필요한 공유 라이브러리가 없습니다" + +#: src/app/main/ui/workspace/libraries.cljs:399 +msgid "workspace.libraries.no-matches-for" +msgstr "\"%s\"에 대한 검색 결과가 없습니다" + +#: src/app/main/ui/workspace/libraries.cljs:356 +msgid "workspace.libraries.search-shared-libraries" +msgstr "공유 라이브러리 검색" + +#: src/app/main/ui/workspace/libraries.cljs:352 +msgid "workspace.libraries.shared-libraries" +msgstr "공유 라이브러리" + +#: src/app/main/ui/workspace/libraries.cljs:372 +msgid "workspace.libraries.shared-library-btn" +msgstr "라이브러리 연결" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:332 +msgid "workspace.libraries.text.multiple-typography" +msgstr "여러 타이포그래피" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:335 +msgid "workspace.libraries.text.multiple-typography-tooltip" +msgstr "모든 타이포그래피 연결 해제" + +#: src/app/main/ui/workspace/libraries.cljs:103, +#: src/app/main/ui/workspace/libraries.cljs:130 +msgid "workspace.libraries.typography" +msgid_plural "workspace.libraries.typography" +msgstr[0] "타이포그래피 %s개" + +#: src/app/main/ui/workspace/libraries.cljs:343 +msgid "workspace.libraries.unlink-library-btn" +msgstr "라이브러리 연결 해제" + +#: src/app/main/ui/workspace/libraries.cljs:507 +msgid "workspace.libraries.update" +msgstr "업데이트" + +#: src/app/main/ui/workspace/libraries.cljs:583 +msgid "workspace.libraries.update.see-all-changes" +msgstr "모든 변경 사항 보기" + +#: src/app/main/ui/workspace/libraries.cljs:630 +msgid "workspace.libraries.updates" +msgstr "업데이트" + +#: src/app/main/ui/ds/notifications/shared/notification_pill.cljs:67, +#: src/app/main/ui/ds/notifications/shared/notification_pill.cljs:72 +msgid "workspace.notification-pill.detail" +msgstr "세부정보" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:784 +msgid "workspace.options.add-interaction" +msgstr "+ 버튼을 클릭하여 인터랙션을 추가하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:96 +msgid "workspace.options.blur-options.add-blur" +msgstr "블러 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:119 +msgid "workspace.options.blur-options.remove-blur" +msgstr "블러 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:92, +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:112 +msgid "workspace.options.blur-options.title" +msgstr "블러" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:91 +msgid "workspace.options.blur-options.title.group" +msgstr "그룹 블러" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:90 +msgid "workspace.options.blur-options.title.multiple" +msgstr "선택 블러" + +#: src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:115 +msgid "workspace.options.blur-options.toggle-blur" +msgstr "블러 전환" + +#: src/app/main/ui/workspace/sidebar/options/page.cljs:42, +#: src/app/main/ui/workspace/sidebar/options/page.cljs:50 +msgid "workspace.options.canvas-background" +msgstr "캔버스 배경" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:567 +msgid "workspace.options.clip-content" +msgstr "콘텐츠 클리핑" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1027, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1033, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1282 +msgid "workspace.options.component" +msgstr "컴포넌트" + +#: src/app/main/ui/inspect/annotation.cljs:19, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:194 +msgid "workspace.options.component.annotation" +msgstr "주석" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1041 +msgid "workspace.options.component.copy" +msgstr "복사" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:187 +msgid "workspace.options.component.create-annotation" +msgstr "주석 만들기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:186 +msgid "workspace.options.component.edit-annotation" +msgstr "주석 편집" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1040, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1286 +msgid "workspace.options.component.main" +msgstr "메인" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:781 +msgid "workspace.options.component.swap" +msgstr "컴포넌트 교체" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:820 +msgid "workspace.options.component.swap.empty" +msgstr "이 라이브러리에 에셋이 아직 없습니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1066 +msgid "workspace.options.component.unlinked" +msgstr "연결 해제됨" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:570 +msgid "workspace.options.component.variant.duplicated.copy.locate" +msgstr "충돌하는 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:567 +msgid "workspace.options.component.variant.duplicated.copy.title" +msgstr "" +"이 컴포넌트에 충돌하는 베리언트가 있습니다. 각 베리언트가 고유한 속성 조합을 " +"갖도록 하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1341 +msgid "workspace.options.component.variant.duplicated.group.locate" +msgstr "중복된 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1338 +msgid "workspace.options.component.variant.duplicated.group.title" +msgstr "일부 베리언트의 속성과 값이 동일합니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:267 +msgid "workspace.options.component.variant.duplicated.single.all" +msgstr "" +"이 베리언트들은 속성과 값이 동일합니다. 각각 고유하게 식별될 수 있도록 값을 " +"조정하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:264 +msgid "workspace.options.component.variant.duplicated.single.one" +msgstr "" +"이 베리언트는 다른 베리언트와 속성 및 값이 동일합니다. 고유하게 식별될 수 있" +"도록 값을 조정하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:270 +msgid "workspace.options.component.variant.duplicated.single.some" +msgstr "" +"일부 베리언트의 속성과 값이 동일합니다. 각각 고유하게 식별될 수 있도록 값을 " +"조정하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"이 컴포넌트에 이름이 유효하지 않은 베리언트가 있습니다. 올바른 형식을 따르세" +"요." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1331 +msgid "workspace.options.component.variant.malformed.group.locate" +msgstr "유효하지 않은 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1328 +msgid "workspace.options.component.variant.malformed.group.title" +msgstr "일부 베리언트의 이름이 유효하지 않습니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:560 +msgid "workspace.options.component.variant.malformed.locate" +msgstr "유효하지 않은 베리언트 위치 찾기" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:251 +msgid "workspace.options.component.variant.malformed.single.all" +msgstr "이 베리언트들의 이름이 유효하지 않습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:248 +msgid "workspace.options.component.variant.malformed.single.one" +msgstr "이 베리언트의 이름이 유효하지 않습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:254 +msgid "workspace.options.component.variant.malformed.single.some" +msgstr "일부 베리언트의 이름이 유효하지 않습니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:433 +msgid "workspace.options.component.variant.malformed.structure.example" +msgstr "[속성]=[값], [속성]=[값]" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:431 +msgid "workspace.options.component.variant.malformed.structure.title" +msgstr "다음 구조를 사용해 보세요:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:54 +msgid "workspace.options.component.variants-help-modal.intro" +msgstr "" +"베리언트 간 전환 시 변경 사항을 유지하려면, Penpot은 다음 조건을 만족하는 레" +"이어를 연결합니다:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"이 중 하나를 변경(예: 레이어 이름 바꾸기 또는 그룹화)하면 연결이 끊어지지만 " +"변경을 되돌리면 복원됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:67 +msgid "workspace.options.component.variants-help-modal.rule1" +msgstr "같은 이름을 사용합니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:76 +msgid "workspace.options.component.variants-help-modal.rule2" +msgstr "같은 유형을 사용합니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:77 +msgid "workspace.options.component.variants-help-modal.rule2.detail" +msgstr "직사각형, 타원, 경로 및 불린 연산 항목들은 동일한 유형으로 간주됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:87 +msgid "workspace.options.component.variants-help-modal.rule3" +msgstr "계층 수준이 동일합니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:88 +msgid "workspace.options.component.variants-help-modal.rule3.detail" +msgstr "그룹, 보드, 레이아웃은 동일한 것으로 간주됩니다." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1045, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1289, +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:47 +msgid "workspace.options.component.variants-help-modal.title" +msgstr "베리언트 연결 방식" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:164 +msgid "workspace.options.constraints" +msgstr "제약 조건" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:151 +msgid "workspace.options.constraints.bottom" +msgstr "아래쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:142, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:153 +msgid "workspace.options.constraints.center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:224 +msgid "workspace.options.constraints.fix-when-scrolling" +msgstr "스크롤 시 고정" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:139 +msgid "workspace.options.constraints.left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:141 +msgid "workspace.options.constraints.leftright" +msgstr "좌우" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:140 +msgid "workspace.options.constraints.right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:143, +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:154 +msgid "workspace.options.constraints.scale" +msgstr "크기 조절" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:150 +msgid "workspace.options.constraints.top" +msgstr "위쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/constraints.cljs:152 +msgid "workspace.options.constraints.topbottom" +msgstr "위아래" + +#: src/app/main/ui/workspace/sidebar/options.cljs:197 +msgid "workspace.options.design" +msgstr "디자인" + +#: src/app/main/ui/inspect/exports.cljs:140 +msgid "workspace.options.export" +msgstr "내보내기" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs, +#: src/app/main/ui/inspect/exports.cljs +msgid "workspace.options.export-multiple" +msgstr "선택 영역 내보내기" + +#: src/app/main/ui/inspect/exports.cljs:196, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:273 +msgid "workspace.options.export-object" +msgid_plural "workspace.options.export-object" +msgstr[0] "요소 %s개 내보내기" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:214 +msgid "workspace.options.export.add-export" +msgstr "내보내기 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:226, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:261 +msgid "workspace.options.export.remove-export" +msgstr "내보내기 제거" + +#: src/app/main/ui/inspect/exports.cljs:179, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:255 +msgid "workspace.options.export.suffix" +msgstr "접미사" + +#: src/app/main/ui/exports/assets.cljs:250 +msgid "workspace.options.exporting-complete" +msgstr "내보내기 완료" + +#: src/app/main/ui/exports/assets.cljs:171, +#: src/app/main/ui/exports/assets.cljs:251, +#: src/app/main/ui/inspect/exports.cljs:195, +#: src/app/main/ui/workspace/sidebar/options/menus/exports.cljs:272 +msgid "workspace.options.exporting-object" +msgstr "내보내는 중…" + +#: src/app/main/ui/exports/assets.cljs:249 +msgid "workspace.options.exporting-object-error" +msgstr "내보내기 실패" + +#: src/app/main/ui/exports/assets.cljs:252 +msgid "workspace.options.exporting-object-slow" +msgstr "내보내기가 예상보다 느립니다" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:107, +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:236 +msgid "workspace.options.fill" +msgstr "채우기" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:208 +msgid "workspace.options.fill.add-fill" +msgstr "채우기 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:223 +msgid "workspace.options.fill.remove-fill" +msgstr "채우기 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:405 +msgid "workspace.options.fit-content" +msgstr "콘텐츠에 맞게 보드 크기 조절" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:704 +msgid "workspace.options.flows.add-flow-start" +msgstr "플로우 시작 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:700 +msgid "workspace.options.flows.flow" +msgstr "플로우" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:164 +msgid "workspace.options.flows.flow-start" +msgstr "플로우 시작" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:672 +msgid "workspace.options.flows.flow-starts" +msgstr "플로우 시작" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:155 +msgid "workspace.options.flows.remove-flow" +msgstr "플로우 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:32 +msgid "workspace.options.grid.auto" +msgstr "자동" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:163 +msgid "workspace.options.grid.column" +msgstr "열" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.grid-title" +msgstr "그리드" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:204, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:247 +msgid "workspace.options.grid.params.color" +msgstr "색상" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.columns" +msgstr "열" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:270 +msgid "workspace.options.grid.params.gutter" +msgstr "거터" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:257 +msgid "workspace.options.grid.params.height" +msgstr "높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:281 +msgid "workspace.options.grid.params.margin" +msgstr "마진" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.rows" +msgstr "행" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:226, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:302 +msgid "workspace.options.grid.params.set-default" +msgstr "기본값으로 설정" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.size" +msgstr "크기" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs +msgid "workspace.options.grid.params.type" +msgstr "유형" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:241 +msgid "workspace.options.grid.params.type.bottom" +msgstr "아래쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:239 +msgid "workspace.options.grid.params.type.center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:238 +msgid "workspace.options.grid.params.type.left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:242 +msgid "workspace.options.grid.params.type.right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:235 +msgid "workspace.options.grid.params.type.stretch" +msgstr "늘이기" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:237 +msgid "workspace.options.grid.params.type.top" +msgstr "위쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:221, +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:300 +msgid "workspace.options.grid.params.use-default" +msgstr "기본값 사용" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:258 +msgid "workspace.options.grid.params.width" +msgstr "너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:164 +msgid "workspace.options.grid.row" +msgstr "행" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:162 +msgid "workspace.options.grid.square" +msgstr "사각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:106 +msgid "workspace.options.group-fill" +msgstr "그룹 채우기" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:45 +msgid "workspace.options.group-stroke" +msgstr "그룹 선" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:332 +msgid "workspace.options.guides.add-guide" +msgstr "가이드 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:191 +msgid "workspace.options.guides.remove-guide" +msgstr "가이드 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:329 +msgid "workspace.options.guides.title" +msgstr "가이드" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:187 +msgid "workspace.options.guides.toggle-guide" +msgstr "가이드 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:435, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:454 +msgid "workspace.options.height" +msgstr "높이" + +#: src/app/main/ui/workspace/sidebar/options.cljs:201 +msgid "workspace.options.inspect" +msgstr "검사" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:438 +msgid "workspace.options.interaction-action" +msgstr "동작" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:43, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:344 +msgid "workspace.options.interaction-after-delay" +msgstr "지연 후" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:578 +msgid "workspace.options.interaction-animation" +msgstr "애니메이션" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "아래" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "들어오기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "나가기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "위로" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:383 +msgid "workspace.options.interaction-animation-dissolve" +msgstr "디졸브" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:382 +msgid "workspace.options.interaction-animation-none" +msgstr "없음" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:389 +msgid "workspace.options.interaction-animation-push" +msgstr "밀기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:384 +msgid "workspace.options.interaction-animation-slide" +msgstr "슬라이드" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:368 +msgid "workspace.options.interaction-auto" +msgstr "자동" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:568 +msgid "workspace.options.interaction-background" +msgstr "배경 오버레이 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:561 +msgid "workspace.options.interaction-close-outside" +msgstr "외부 클릭 시 닫기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:349 +msgid "workspace.options.interaction-close-overlay" +msgstr "오버레이 닫기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:58 +msgid "workspace.options.interaction-close-overlay-dest" +msgstr "오버레이 닫기: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:426 +msgid "workspace.options.interaction-delay" +msgstr "지연" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:449 +msgid "workspace.options.interaction-destination" +msgstr "목적지" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:628 +msgid "workspace.options.interaction-duration" +msgstr "지속 시간" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:641 +msgid "workspace.options.interaction-easing" +msgstr "이징" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:393 +msgid "workspace.options.interaction-easing-ease" +msgstr "기본(ease)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:394 +msgid "workspace.options.interaction-easing-ease-in" +msgstr "감가속(In)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:396 +msgid "workspace.options.interaction-easing-ease-in-out" +msgstr "감가감속(In-Out)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:395 +msgid "workspace.options.interaction-easing-ease-out" +msgstr "가감속(Out)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:392 +msgid "workspace.options.interaction-easing-linear" +msgstr "선형(Linear)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +msgid "workspace.options.interaction-in" +msgstr "들어오기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:41, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:341 +msgid "workspace.options.interaction-mouse-enter" +msgstr "마우스 진입 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:42, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:342 +msgid "workspace.options.interaction-mouse-leave" +msgstr "마우스 이탈 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:430, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:632 +msgid "workspace.options.interaction-ms" +msgstr "ms" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:346 +msgid "workspace.options.interaction-navigate-to" +msgstr "이동" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:52 +msgid "workspace.options.interaction-navigate-to-dest" +msgstr "이동: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:53, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:55, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:57, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:357 +msgid "workspace.options.interaction-none" +msgstr "(설정 안 함)" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:654 +msgid "workspace.options.interaction-offset-effect" +msgstr "오프셋 효과" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:37, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:337 +msgid "workspace.options.interaction-on-click" +msgstr "클릭 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:347 +msgid "workspace.options.interaction-open-overlay" +msgstr "오버레이 열기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:54 +msgid "workspace.options.interaction-open-overlay-dest" +msgstr "오버레이 열기: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 +msgid "workspace.options.interaction-open-url" +msgstr "URL 열기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +msgid "workspace.options.interaction-out" +msgstr "나가기" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:380, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:554 +msgid "workspace.options.interaction-pos-bottom-center" +msgstr "아래 가운데" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:378, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:538 +msgid "workspace.options.interaction-pos-bottom-left" +msgstr "아래 왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:379, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:546 +msgid "workspace.options.interaction-pos-bottom-right" +msgstr "아래 오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:374, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:506 +msgid "workspace.options.interaction-pos-center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:373 +msgid "workspace.options.interaction-pos-manual" +msgstr "수동" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:377, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:530 +msgid "workspace.options.interaction-pos-top-center" +msgstr "위 가운데" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:375, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:514 +msgid "workspace.options.interaction-pos-top-left" +msgstr "위 왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:376, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:522 +msgid "workspace.options.interaction-pos-top-right" +msgstr "위 오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:492 +msgid "workspace.options.interaction-position" +msgstr "위 오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:460 +msgid "workspace.options.interaction-preserve-scroll" +msgstr "스크롤 위치 유지" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:60, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:350 +msgid "workspace.options.interaction-prev-screen" +msgstr "이전 화면" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:482 +msgid "workspace.options.interaction-relative-to" +msgstr "기준:" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:59, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:356, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:370, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:371 +msgid "workspace.options.interaction-self" +msgstr "자기 자신" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:348 +msgid "workspace.options.interaction-toggle-overlay" +msgstr "오버레이 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:56 +msgid "workspace.options.interaction-toggle-overlay-dest" +msgstr "오버레이 전환: %s" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:415 +msgid "workspace.options.interaction-trigger" +msgstr "트리거" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:469 +msgid "workspace.options.interaction-url" +msgstr "URL" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:39, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:339 +msgid "workspace.options.interaction-while-hovering" +msgstr "호버 시" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:40, +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:340 +msgid "workspace.options.interaction-while-pressing" +msgstr "누르는 동안" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:743 +msgid "workspace.options.interactions" +msgstr "인터랙션" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:746 +msgid "workspace.options.interactions.add-interaction" +msgstr "인터랙션 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs +msgid "workspace.options.interactions.remove-interaction" +msgstr "인터랙션 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:197 +msgid "workspace.options.layer-options.blend-mode.color" +msgstr "색상" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:186 +msgid "workspace.options.layer-options.blend-mode.color-burn" +msgstr "색상 번" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:189 +msgid "workspace.options.layer-options.blend-mode.color-dodge" +msgstr "색상 닷지" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:184 +msgid "workspace.options.layer-options.blend-mode.darken" +msgstr "어둡게" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:193 +msgid "workspace.options.layer-options.blend-mode.difference" +msgstr "차이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:194 +msgid "workspace.options.layer-options.blend-mode.exclusion" +msgstr "제외" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:192 +msgid "workspace.options.layer-options.blend-mode.hard-light" +msgstr "하드 라이트" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:195 +msgid "workspace.options.layer-options.blend-mode.hue" +msgstr "색조" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:187 +msgid "workspace.options.layer-options.blend-mode.lighten" +msgstr "밝게" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:198 +msgid "workspace.options.layer-options.blend-mode.luminosity" +msgstr "광도" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:185 +msgid "workspace.options.layer-options.blend-mode.multiply" +msgstr "곱하기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:183 +msgid "workspace.options.layer-options.blend-mode.normal" +msgstr "보통" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:190 +msgid "workspace.options.layer-options.blend-mode.overlay" +msgstr "오버레이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:196 +msgid "workspace.options.layer-options.blend-mode.saturation" +msgstr "채도" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:188 +msgid "workspace.options.layer-options.blend-mode.screen" +msgstr "스크린" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:191 +msgid "workspace.options.layer-options.blend-mode.soft-light" +msgstr "소프트 라이트" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +msgid "workspace.options.layer-options.title" +msgstr "레이어" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +msgid "workspace.options.layer-options.title.group" +msgstr "레이어 그룹 만들기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs +msgid "workspace.options.layer-options.title.multiple" +msgstr "선택된 레이어" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:255, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:261 +msgid "workspace.options.layer-options.toggle-layer" +msgstr "레이어 표시 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.advanced-ops" +msgstr "고급 옵션" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 +msgid "workspace.options.layout-item.layout-item-max-h" +msgstr "최대 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 +msgid "workspace.options.layout-item.layout-item-max-w" +msgstr "최대 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 +msgid "workspace.options.layout-item.layout-item-min-h" +msgstr "최소 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 +msgid "workspace.options.layout-item.layout-item-min-w" +msgstr "최소 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-max-h" +msgstr "최대 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-max-w" +msgstr "최대 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-min-h" +msgstr "최소 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout-item.title.layout-item-min-w" +msgstr "최소 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.bottom" +msgstr "아래쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.column" +msgstr "열" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.column-reverse" +msgstr "열 반전" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.row" +msgstr "행" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.direction.row-reverse" +msgstr "행 반전" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.gap" +msgstr "간격" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.left" +msgstr "왼쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout.margin" +msgstr "마진" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout.margin-all" +msgstr "전체" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs +msgid "workspace.options.layout.margin-simple" +msgstr "단일 마진" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.packed" +msgstr "촘촘히 배치" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.padding" +msgstr "패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.padding-all" +msgstr "전체" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.padding-simple" +msgstr "단일 패딩" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.right" +msgstr "오른쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.space-around" +msgstr "여백 포함 배분" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.space-between" +msgstr "균등 배분" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs +msgid "workspace.options.layout.top" +msgstr "위쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:241 +msgid "workspace.options.more-colors" +msgstr "색상 더보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:223 +msgid "workspace.options.more-lib-colors" +msgstr "라이브러리 색상 더보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "색상 token 더보기" + +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:229, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:241 +msgid "workspace.options.opacity" +msgstr "불투명도" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "가로" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "세로" + +#: src/app/main/ui/workspace/sidebar/options/shapes/frame.cljs, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs +msgid "workspace.options.position" +msgstr "위치" + +#: src/app/main/ui/workspace/sidebar/options.cljs:199 +msgid "workspace.options.prototype" +msgstr "프로토타입" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:182, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:206 +msgid "workspace.options.radius" +msgstr "반지름" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:271, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:323 +msgid "workspace.options.radius-bottom-left" +msgstr "왼쪽 아래" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:290, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:331 +msgid "workspace.options.radius-bottom-right" +msgstr "오른쪽 아래" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:234, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:307 +msgid "workspace.options.radius-top-left" +msgstr "왼쪽 위" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:253, +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:315 +msgid "workspace.options.radius-top-right" +msgstr "오른쪽 위" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:340 +msgid "workspace.options.radius.hide-all-corners" +msgstr "개별 반경 통합" + +#: src/app/main/ui/workspace/sidebar/options/menus/border_radius.cljs:341 +msgid "workspace.options.radius.show-single-corners" +msgstr "개별 반경 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:191 +msgid "workspace.options.recent-fonts" +msgstr "최근" + +#: src/app/main/ui/exports/assets.cljs:298 +msgid "workspace.options.retry" +msgstr "다시 시도" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:536, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:544 +msgid "workspace.options.rotation" +msgstr "회전" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:188 +msgid "workspace.options.search-font" +msgstr "글꼴 검색" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:786 +msgid "workspace.options.select-a-shape" +msgstr "도형, 보드 또는 그룹을 선택하여 다른 보드로 연결하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:201 +msgid "workspace.options.selection-color" +msgstr "선택 색상" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:105 +msgid "workspace.options.selection-fill" +msgstr "선택 채우기" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +msgid "workspace.options.selection-stroke" +msgstr "선택 선" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:144 +msgid "workspace.options.shadow-options.add-shadow" +msgstr "그림자 추가" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:47, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:181, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:183 +msgid "workspace.options.shadow-options.blur" +msgstr "블러" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:211 +msgid "workspace.options.shadow-options.color" +msgstr "그림자 색상" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:122 +msgid "workspace.options.shadow-options.drop-shadow" +msgstr "그림자 효과" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:123 +msgid "workspace.options.shadow-options.inner-shadow" +msgstr "내부 그림자" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:45, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:172 +msgid "workspace.options.shadow-options.offsetx" +msgstr "X" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:46, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:201 +msgid "workspace.options.shadow-options.offsety" +msgstr "Y" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:157, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:161 +msgid "workspace.options.shadow-options.remove-shadow" +msgstr "그림자 제거" + +#: src/app/main/ui/inspect/attributes/shadow.cljs:48, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:191, +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:193 +msgid "workspace.options.shadow-options.spread" +msgstr "확산" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:139 +msgid "workspace.options.shadow-options.title" +msgstr "그림자" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:138 +msgid "workspace.options.shadow-options.title.group" +msgstr "그룹 그림자" + +#: src/app/main/ui/workspace/sidebar/options/menus/shadow.cljs:137 +msgid "workspace.options.shadow-options.title.multiple" +msgstr "선택 영역 그림자" + +#: src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:157 +msgid "workspace.options.shadow-options.toggle-shadow" +msgstr "그림자 전환" + +#: src/app/main/ui/workspace/sidebar/options/menus/fill.cljs:258 +msgid "workspace.options.show-fill-on-export" +msgstr "내보내기에 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:574 +msgid "workspace.options.show-in-viewer" +msgstr "보기 모드에서 표시" + +#: src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:168 +msgid "workspace.options.size" +msgstr "크기" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:71, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:364 +msgid "workspace.options.size-presets" +msgstr "크기 프리셋" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.lock" +msgstr "비율 잠금" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:469 +msgid "workspace.options.size.unlock" +msgstr "비율 잠금 해제" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:44 +msgid "workspace.options.stroke" +msgstr "선" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.circle-marker" +msgstr "원형 마커" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:175 +msgid "workspace.options.stroke-cap.circle-marker-short" +msgstr "원" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.diamond-marker" +msgstr "다이아몬드 마커" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:176 +msgid "workspace.options.stroke-cap.diamond-marker-short" +msgstr "다이아몬드" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.line-arrow" +msgstr "선 화살표" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:172 +msgid "workspace.options.stroke-cap.line-arrow-short" +msgstr "화살표" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:170 +msgid "workspace.options.stroke-cap.none" +msgstr "없음" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:178 +msgid "workspace.options.stroke-cap.round" +msgstr "둥근" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:179 +msgid "workspace.options.stroke-cap.square" +msgstr "사각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.square-marker" +msgstr "사각형 마커" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:174 +msgid "workspace.options.stroke-cap.square-marker-short" +msgstr "직사각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs +msgid "workspace.options.stroke-cap.triangle-arrow" +msgstr "삼각형 화살표" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:173 +msgid "workspace.options.stroke-cap.triangle-arrow-short" +msgstr "삼각형" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:210 +msgid "workspace.options.stroke-color" +msgstr "선 색상" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:225, +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:230 +msgid "workspace.options.stroke-width" +msgstr "선 두께" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:189 +msgid "workspace.options.stroke.add-stroke" +msgstr "선 색상 추가" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:111 +msgid "workspace.options.stroke.center" +msgstr "중앙" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:139 +msgid "workspace.options.stroke.dashed" +msgstr "점선" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:138 +msgid "workspace.options.stroke.dotted" +msgstr "점선" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:112 +msgid "workspace.options.stroke.inner" +msgstr "안쪽" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:140 +msgid "workspace.options.stroke.mixed" +msgstr "혼합" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:113 +msgid "workspace.options.stroke.outer" +msgstr "바깥쪽" + +#: src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:202 +msgid "workspace.options.stroke.remove-stroke" +msgstr "선 제거" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:137 +msgid "workspace.options.stroke.solid" +msgstr "단색" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:127 +msgid "workspace.options.text-options.align-bottom" +msgstr "아래쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:123 +msgid "workspace.options.text-options.align-middle" +msgstr "중간 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:119 +msgid "workspace.options.text-options.align-top" +msgstr "위쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:92 +msgid "workspace.options.text-options.direction-ltr" +msgstr "LTR" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:96 +msgid "workspace.options.text-options.direction-rtl" +msgstr "RTL" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:165 +msgid "workspace.options.text-options.grow-auto-height" +msgstr "자동 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:161 +msgid "workspace.options.text-options.grow-auto-width" +msgstr "자동 너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:157 +msgid "workspace.options.text-options.grow-fixed" +msgstr "고정됨" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:400 +msgid "workspace.options.text-options.letter-spacing" +msgstr "자간" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:381 +msgid "workspace.options.text-options.line-height" +msgstr "줄 높이" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.lowercase" +msgstr "소문자" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs, +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.none" +msgstr "없음" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:192 +msgid "workspace.options.text-options.strikethrough" +msgstr "취소선 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:61 +msgid "workspace.options.text-options.text-align-center" +msgstr "중앙 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:69 +msgid "workspace.options.text-options.text-align-justify" +msgstr "양쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:57 +msgid "workspace.options.text-options.text-align-left" +msgstr "왼쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:65 +msgid "workspace.options.text-options.text-align-right" +msgstr "오른쪽 정렬" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:205 +msgid "workspace.options.text-options.title" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:204 +msgid "workspace.options.text-options.title-group" +msgstr "그룹 텍스트" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:203 +msgid "workspace.options.text-options.title-selection" +msgstr "그룹 텍스트" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.titlecase" +msgstr "단어 첫 글자 대문자" + +#: src/app/main/ui/workspace/sidebar/options/menus/text.cljs:188 +msgid "workspace.options.text-options.underline" +msgstr "밑줄 (%s)" + +#: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs +msgid "workspace.options.text-options.uppercase" +msgstr "대문자" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788 +msgid "workspace.options.use-play-button" +msgstr "헤더의 재생 버튼을 클릭해 프로토타입 보기를 실행하세요." + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:420, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:442 +msgid "workspace.options.width" +msgstr "너비" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:482, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:505 +msgid "workspace.options.x" +msgstr "X축" + +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:495, +#: src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:516 +msgid "workspace.options.y" +msgstr "Y축" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:140 +msgid "workspace.path.actions.add-node" +msgstr "노드 추가 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:148 +msgid "workspace.path.actions.delete-node" +msgstr "노드 삭제 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:123 +msgid "workspace.path.actions.draw-nodes" +msgstr "노드 그리기 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:166 +msgid "workspace.path.actions.join-nodes" +msgstr "노드 연결 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:184 +msgid "workspace.path.actions.make-corner" +msgstr "직선으로 변환 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:192 +msgid "workspace.path.actions.make-curve" +msgstr "곡선으로 변환 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:158 +msgid "workspace.path.actions.merge-nodes" +msgstr "노드 병합 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:131 +msgid "workspace.path.actions.move-nodes" +msgstr "노드 이동 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:174 +msgid "workspace.path.actions.separate-nodes" +msgstr "노드 분리 (%s)" + +#: src/app/main/ui/workspace/viewport/path_actions.cljs:203 +msgid "workspace.path.actions.snap-nodes" +msgstr "노드 스냅 (%s)" + +#: src/app/main/ui/workspace/plugins.cljs:85 +msgid "workspace.plugins.button-open" +msgstr "열기" + +#: src/app/main/ui/workspace/plugins.cljs:199 +msgid "workspace.plugins.discover" +msgstr "[더 많은 플러그인](%s) 찾아보기" + +#: src/app/main/ui/workspace/plugins.cljs:206 +msgid "workspace.plugins.empty-plugins" +msgstr "아직 설치된 플러그인이 없습니다" + +#: src/app/main/ui/workspace/plugins.cljs:193 +msgid "workspace.plugins.error.manifest" +msgstr "플러그인 매니페스트가 올바르지 않습니다." + +#: src/app/main/data/plugins.cljs:105, +#: src/app/main/ui/workspace/main_menu.cljs:766, +#: src/app/main/ui/workspace/plugins.cljs:84 +msgid "workspace.plugins.error.need-editor" +msgstr "이 플러그인을 사용하려면 편집자여야 합니다" + +#: src/app/main/ui/workspace/plugins.cljs:189 +msgid "workspace.plugins.error.url" +msgstr "플러그인이 존재하지 않거나 URL이 올바르지 않습니다." + +#: src/app/main/ui/workspace/plugins.cljs:185 +msgid "workspace.plugins.install" +msgstr "설치" + +#: src/app/main/ui/workspace/plugins.cljs:215 +msgid "workspace.plugins.installed-plugins" +msgstr "설치된 플러그인" + +#: src/app/main/ui/workspace/main_menu.cljs:721 +msgid "workspace.plugins.menu.plugins-manager" +msgstr "플러그인 관리자" + +#: src/app/main/ui/workspace/main_menu.cljs:918 +msgid "workspace.plugins.menu.title" +msgstr "플러그인" + +#: src/app/main/ui/workspace/plugins.cljs:376 +msgid "workspace.plugins.permissions-update.title" +msgstr "이 플러그인 업데이트" + +#: src/app/main/ui/workspace/plugins.cljs:380 +msgid "workspace.plugins.permissions-update.warning" +msgstr "" +"마지막으로 열었던 이후 플러그인이 수정되었습니다. 이제 다음에도 접근하려 합니" +"다:" + +#: src/app/main/ui/workspace/plugins.cljs:280 +msgid "workspace.plugins.permissions.allow-download" +msgstr "파일 다운로드 시작." + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "브라우저에 데이터를 저장합니다." + +#: src/app/main/ui/workspace/plugins.cljs:273 +msgid "workspace.plugins.permissions.comment-read" +msgstr "댓글과 답글을 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:267 +msgid "workspace.plugins.permissions.comment-write" +msgstr "댓글을 읽고 수정하며 귀하의 이름으로 답글을 답니다." + +#: src/app/main/ui/workspace/plugins.cljs:240 +msgid "workspace.plugins.permissions.content-read" +msgstr "사용자가 접근할 수 있는 파일의 내용을 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:234 +msgid "workspace.plugins.permissions.content-write" +msgstr "사용자가 접근할 수 있는 파일의 내용을 읽고 수정합니다." + +#: src/app/main/ui/workspace/plugins.cljs:327 +msgid "workspace.plugins.permissions.disclaimer" +msgstr "" +"이 플러그인은 외부 개발자가 만들었습니다. 접근 권한을 부여하기 전에 신뢰할 " +"수 있는지 확인하세요. 개인정보 보호 및 보안이 중요합니다. 문의 사항은 지원팀" +"에 연락하세요." + +#: src/app/main/ui/workspace/plugins.cljs:260 +msgid "workspace.plugins.permissions.library-read" +msgstr "라이브러리와 에셋을 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:254 +msgid "workspace.plugins.permissions.library-write" +msgstr "라이브러리와 에셋을 읽고 수정합니다." + +#: src/app/main/ui/workspace/plugins.cljs:320 +msgid "workspace.plugins.permissions.title" +msgstr "'%s' 플러그인이 다음에 접근하려 합니다:" + +#: src/app/main/ui/workspace/plugins.cljs:247 +msgid "workspace.plugins.permissions.user-read" +msgstr "현재 사용자의 프로필 정보를 읽습니다." + +#: src/app/main/ui/workspace/plugins.cljs:211 +msgid "workspace.plugins.plugin-list-link" +msgstr "플러그인 목록" + +#: src/app/main/ui/workspace/plugins.cljs:88 +msgid "workspace.plugins.remove-plugin" +msgstr "플러그인 제거" + +#: src/app/main/ui/workspace/plugins.cljs:180 +msgid "workspace.plugins.search-placeholder" +msgstr "플러그인 URL로 검색" + +msgid "workspace.plugins.success" +msgstr "플러그인이 올바르게 로드되었습니다." + +#: src/app/main/ui/workspace/plugins.cljs:174 +msgid "workspace.plugins.title" +msgstr "플러그인" + +#: src/app/main/ui/workspace/plugins.cljs:440 +msgid "workspace.plugins.try-out.cancel" +msgstr "나중에" + +#: src/app/main/ui/workspace/plugins.cljs:433 +msgid "workspace.plugins.try-out.message" +msgstr "" +"살펴보시겠습니까? 현재 팀의 새 초안에서 열립니다. (그렇지 않으면 언제든지 파" +"일의 설치된 플러그인에서 찾을 수 있습니다.)" + +#: src/app/main/ui/workspace/plugins.cljs:429 +msgid "workspace.plugins.try-out.title" +msgstr "'%s' 플러그인이 설치되었습니다!" + +#: src/app/main/ui/workspace/plugins.cljs:446 +msgid "workspace.plugins.try-out.try" +msgstr "플러그인 사용해보기" + +#: src/app/main/ui/workspace/context_menu.cljs:559 +msgid "workspace.shape.menu.add-flex" +msgstr "플렉스 레이아웃 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:563 +msgid "workspace.shape.menu.add-grid" +msgstr "그리드 레이아웃 추가" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1248, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1272 +msgid "workspace.shape.menu.add-layout" +msgstr "레이아웃 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:612, +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:508, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1051, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1219, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1293 +msgid "workspace.shape.menu.add-variant" +msgstr "베리언트 생성" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:512, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1073, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1221, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1307 +msgid "workspace.shape.menu.add-variant-property" +msgstr "새 속성 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:282 +msgid "workspace.shape.menu.back" +msgstr "맨 뒤로" + +#: src/app/main/ui/workspace/context_menu.cljs:279 +msgid "workspace.shape.menu.backward" +msgstr "뒤로" + +#: src/app/main/ui/workspace/context_menu.cljs:619, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:633, +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "베리언트로 결합" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "컴포넌트가 같은 페이지에 있어야 합니다" + +#: src/app/main/ui/workspace/context_menu.cljs:200 +msgid "workspace.shape.menu.copy" +msgstr "복사" + +#: src/app/main/ui/workspace/context_menu.cljs:218 +msgid "workspace.shape.menu.copy-css" +msgstr "CSS로 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:220 +msgid "workspace.shape.menu.copy-css-nested" +msgstr "CSS로 복사 (중첩된 레이어 포함)" + +#: src/app/main/ui/workspace/context_menu.cljs:203 +msgid "workspace.shape.menu.copy-link" +msgstr "링크 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:216 +msgid "workspace.shape.menu.copy-paste-as" +msgstr "다른 형식으로 복사/붙여넣기." + +#: src/app/main/ui/workspace/context_menu.cljs:230 +msgid "workspace.shape.menu.copy-props" +msgstr "속성 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:222 +msgid "workspace.shape.menu.copy-svg" +msgstr "SVG로 복사" + +#: src/app/main/ui/workspace/context_menu.cljs:227 +msgid "workspace.shape.menu.copy-text" +msgstr "텍스트로 복사" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:484 +msgid "workspace.shape.menu.create-annotation" +msgstr "주석 추가" + +#: src/app/main/ui/workspace/context_menu.cljs:382 +msgid "workspace.shape.menu.create-artboard-from-selection" +msgstr "선택 항목을 보드로" + +#: src/app/main/ui/workspace/context_menu.cljs:592 +msgid "workspace.shape.menu.create-component" +msgstr "컴포넌트 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:596 +msgid "workspace.shape.menu.create-multiple-components" +msgstr "여러 컴포넌트 생성" + +#: src/app/main/ui/workspace/context_menu.cljs:206 +msgid "workspace.shape.menu.cut" +msgstr "잘라내기" + +#: src/app/main/ui/workspace/context_menu.cljs:629, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1001, +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1290 +msgid "workspace.shape.menu.delete" +msgstr "삭제" + +#: src/app/main/ui/workspace/context_menu.cljs:506 +msgid "workspace.shape.menu.delete-flow-start" +msgstr "플로우 시작 삭제" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:489 +msgid "workspace.shape.menu.detach-instance" +msgstr "인스턴스 분리" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:488 +msgid "workspace.shape.menu.detach-instances-in-bulk" +msgstr "인스턴스 모두 분리" + +#: src/app/main/ui/workspace/context_menu.cljs:447, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:88 +msgid "workspace.shape.menu.difference" +msgstr "차이" + +#: src/app/main/ui/workspace/context_menu.cljs:212 +msgid "workspace.shape.menu.duplicate" +msgstr "복제복제" + +#: src/app/main/ui/workspace/context_menu.cljs:432 +msgid "workspace.shape.menu.edit" +msgstr "편집" + +#: src/app/main/ui/workspace/context_menu.cljs:453, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:98 +msgid "workspace.shape.menu.exclude" +msgstr "제외" + +#: src/app/main/ui/workspace/context_menu.cljs:437, +#: src/app/main/ui/workspace/context_menu.cljs:460, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:104 +msgid "workspace.shape.menu.flatten" +msgstr "병합" + +#: src/app/main/ui/workspace/context_menu.cljs:299 +msgid "workspace.shape.menu.flip-horizontal" +msgstr "좌우 반전" + +#: src/app/main/ui/workspace/context_menu.cljs:295 +msgid "workspace.shape.menu.flip-vertical" +msgstr "상하 반전" + +#: src/app/main/ui/workspace/context_menu.cljs:508 +msgid "workspace.shape.menu.flow-start" +msgstr "플로우 시작" + +#: src/app/main/ui/workspace/context_menu.cljs:273 +msgid "workspace.shape.menu.forward" +msgstr "앞으로" + +#: src/app/main/ui/workspace/context_menu.cljs:276 +msgid "workspace.shape.menu.front" +msgstr "맨 앞으로" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "workspace.shape.menu.go-main" +msgstr "메인 컴포넌트 파일로 이동" + +#: src/app/main/ui/workspace/context_menu.cljs:368 +msgid "workspace.shape.menu.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/context_menu.cljs:477, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:172 +msgid "workspace.shape.menu.hide" +msgstr "숨기기" + +#: src/app/main/ui/workspace/context_menu.cljs:706, +#: src/app/main/ui/workspace/main_menu.cljs:448 +msgid "workspace.shape.menu.hide-ui" +msgstr "UI 표시/숨기기" + +#: src/app/main/ui/workspace/context_menu.cljs:450, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:93 +msgid "workspace.shape.menu.intersection" +msgstr "교차" + +#: src/app/main/ui/workspace/context_menu.cljs:485, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:180, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:268 +msgid "workspace.shape.menu.lock" +msgstr "잠금" + +#: src/app/main/ui/workspace/context_menu.cljs:373 +msgid "workspace.shape.menu.mask" +msgstr "마스크" + +#: src/app/main/ui/workspace/context_menu.cljs:209, +#: src/app/main/ui/workspace/context_menu.cljs:703 +msgid "workspace.shape.menu.paste" +msgstr "붙여넣기" + +#: src/app/main/ui/workspace/context_menu.cljs:234 +msgid "workspace.shape.menu.paste-props" +msgstr "속성 붙여넣기" + +#: src/app/main/ui/workspace/context_menu.cljs:443 +msgid "workspace.shape.menu.path" +msgstr "경로" + +#: src/app/main/ui/workspace/context_menu.cljs:549 +msgid "workspace.shape.menu.remove-flex" +msgstr "플렉스 레이아웃 제거" + +#: src/app/main/ui/workspace/context_menu.cljs:552 +msgid "workspace.shape.menu.remove-grid" +msgstr "그리드 레이아웃 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1266 +msgid "workspace.shape.menu.remove-layout" +msgstr "레이아웃 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1157 +msgid "workspace.shape.menu.remove-variant-property" +msgstr "속성 제거" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1156 +msgid "workspace.shape.menu.remove-variant-property.last-property" +msgstr "베리언트는 최소한 하나의 속성을 가져야 합니다" + +#: src/app/main/ui/workspace/context_menu.cljs:329 +msgid "workspace.shape.menu.rename" +msgstr "이름 바꾸기" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:493 +msgid "workspace.shape.menu.reset-overrides" +msgstr "오버라이드 초기화" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:499 +msgid "workspace.shape.menu.restore-main" +msgstr "메인 컴포넌트 복원" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:498 +msgid "workspace.shape.menu.restore-variant" +msgstr "베리언트 복원" + +#: src/app/main/ui/workspace/context_menu.cljs:263 +msgid "workspace.shape.menu.select-layer" +msgstr "레이어 선택" + +#: src/app/main/ui/workspace/context_menu.cljs:474, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:171 +msgid "workspace.shape.menu.show" +msgstr "표시" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:481, +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1217 +msgid "workspace.shape.menu.show-in-assets" +msgstr "에셋 패널에 표시" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:502, +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:629 +msgid "workspace.shape.menu.show-main" +msgstr "메인 컴포넌트 표시" + +#: src/app/main/ui/workspace/context_menu.cljs:314 +msgid "workspace.shape.menu.thumbnail-remove" +msgstr "썸네일 제거" + +#: src/app/main/ui/workspace/context_menu.cljs:316 +msgid "workspace.shape.menu.thumbnail-set" +msgstr "썸네일로 설정" + +#: src/app/main/ui/workspace/context_menu.cljs:436 +msgid "workspace.shape.menu.transform-to-path" +msgstr "경로로 변환" + +#: src/app/main/ui/workspace/context_menu.cljs:364 +msgid "workspace.shape.menu.ungroup" +msgstr "그룹 해제" + +#: src/app/main/ui/workspace/context_menu.cljs:444, +#: src/app/main/ui/workspace/sidebar/options/menus/bool.cljs:83 +msgid "workspace.shape.menu.union" +msgstr "합집합" + +#: src/app/main/ui/workspace/context_menu.cljs:482, +#: src/app/main/ui/workspace/sidebar/layer_item.cljs:179, +#: src/app/main/ui/workspace/sidebar/options/menus/layer.cljs:274 +msgid "workspace.shape.menu.unlock" +msgstr "잠금 해제" + +#: src/app/main/ui/workspace/context_menu.cljs:378 +msgid "workspace.shape.menu.unmask" +msgstr "마스크 해제" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs, +#: src/app/main/ui/workspace/context_menu.cljs, +#: src/app/main/ui/workspace/context_menu.cljs +msgid "workspace.shape.menu.update-components-in-bulk" +msgstr "메인 컴포넌트 업데이트" + +#: src/app/main/ui/workspace/sidebar/assets/common.cljs:505 +msgid "workspace.shape.menu.update-main" +msgstr "메인 컴포넌트 업데이트" + +#: src/app/main/ui/components/tab_container.cljs:52, +#: src/app/main/ui/workspace/sidebar.cljs:62 +msgid "workspace.sidebar.collapse" +msgstr "사이드바 접기" + +#: src/app/main/ui/workspace/sidebar.cljs:73, +#: src/app/main/ui/workspace/sidebar.cljs:77 +msgid "workspace.sidebar.expand" +msgstr "사이드바 펼치기" + +#: src/app/main/ui/workspace/right_header.cljs:226 +msgid "workspace.sidebar.history" +msgstr "히스토리" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:509, +#: src/app/main/ui/workspace/sidebar.cljs:154, +#: src/app/main/ui/workspace/sidebar.cljs:157, +#: src/app/main/ui/workspace/sidebar.cljs:164 +msgid "workspace.sidebar.layers" +msgstr "레이어" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:313, +#: src/app/main/ui/workspace/sidebar/layers.cljs:374 +msgid "workspace.sidebar.layers.components" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "필터" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:310, +#: src/app/main/ui/workspace/sidebar/layers.cljs:338 +msgid "workspace.sidebar.layers.frames" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:311, +#: src/app/main/ui/workspace/sidebar/layers.cljs:350 +msgid "workspace.sidebar.layers.groups" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:315, +#: src/app/main/ui/workspace/sidebar/layers.cljs:398 +msgid "workspace.sidebar.layers.images" +msgstr "이미지" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:312, +#: src/app/main/ui/workspace/sidebar/layers.cljs:362 +msgid "workspace.sidebar.layers.masks" +msgstr "마스크" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:293 +msgid "workspace.sidebar.layers.search" +msgstr "레이어 검색" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:316, +#: src/app/main/ui/workspace/sidebar/layers.cljs:410 +msgid "workspace.sidebar.layers.shapes" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:314, +#: src/app/main/ui/workspace/sidebar/layers.cljs:386 +msgid "workspace.sidebar.layers.texts" +msgstr "텍스트" + +#: src/app/main/ui/inspect/attributes/svg.cljs:56, +#: src/app/main/ui/workspace/sidebar/options/menus/svg_attrs.cljs:101 +msgid "workspace.sidebar.options.svg-attrs.title" +msgstr "가져온 SVG 속성" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:264 +msgid "workspace.sidebar.sitemap" +msgstr "페이지" + +#: src/app/main/ui/workspace/sidebar/sitemap.cljs:274 +msgid "workspace.sidebar.sitemap.add-page" +msgstr "페이지 추가" + +#: src/app/main/ui/workspace/left_header.cljs:98 +msgid "workspace.sitemap" +msgstr "사이트맵" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:86 +msgid "workspace.tokens.active-themes" +msgstr "활성 테마 %s개" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs +msgid "workspace.tokens.add set" +msgstr "세트 추가" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:66, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:151, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:347 +msgid "workspace.tokens.add-new-theme" +msgstr "새 테마 추가" + +#: src/app/main/ui/workspace/tokens/sets/context_menu.cljs:62 +msgid "workspace.tokens.add-set-to-group" +msgstr "이 그룹에 세트 추가" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:196, +#: src/app/main/ui/workspace/tokens/management/group.cljs:156 +msgid "workspace.tokens.add-token" +msgstr "token 추가: %s" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:137 +msgid "workspace.tokens.applied-to" +msgstr "적용됨" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:330 +msgid "workspace.tokens.axis" +msgstr "축" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:356 +msgid "workspace.tokens.back-to-themes" +msgstr "테마 목록으로" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:89 +msgid "workspace.tokens.base-font-size" +msgstr "기본 글꼴 크기" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:43 +msgid "workspace.tokens.base-font-size.error" +msgstr "기본 글꼴 크기는 픽셀 또는 단위가 없는 값이어야 합니다." + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:127 +msgid "workspace.tokens.choose-file" +msgstr "파일 선택" + +#: src/app/main/ui/workspace/tokens/modals/import.cljs:132 +msgid "workspace.tokens.choose-folder" +msgstr "폴더 선택" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:299, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:130 +msgid "workspace.tokens.color" +msgstr "색상" + +#: src/app/main/data/workspace/tokens/errors.cljs:101, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 +msgid "workspace.tokens.composite-line-height-needs-font-size" +msgstr "" +"행간은 글꼴 크기에 따라 달라집니다. 확인된 값을 얻으려면 글꼴 크기를 추가하세" +"요." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:57 +msgid "workspace.tokens.create-new-theme" +msgstr "첫 번째 테마를 만들어 보세요." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:96, +#: src/app/main/ui/workspace/tokens/themes.cljs:44 +msgid "workspace.tokens.create-one" +msgstr "하나 생성하기." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:235 +msgid "workspace.tokens.create-token" +msgstr "새 %s token 생성" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:353 +msgid "workspace.tokens.delete" +msgstr "token 삭제" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:140 +msgid "workspace.tokens.delete-theme-title" +msgstr "테마 삭제" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:350 +msgid "workspace.tokens.duplicate" +msgstr "token 복제" + +#: src/app/main/data/workspace/tokens/library_edit.cljs:240, +#: src/app/main/data/workspace/tokens/library_edit.cljs:509 +msgid "workspace.tokens.duplicate-suffix" +msgstr "복사" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:337 +msgid "workspace.tokens.edit" +msgstr "token 편집" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:346 +msgid "workspace.tokens.edit-theme-title" +msgstr "테마 편집" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:74 +msgid "workspace.tokens.edit-themes" +msgstr "테마 편집" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 +msgid "workspace.tokens.edit-token" +msgstr "%s token 편집" + +#: src/app/main/data/workspace/tokens/errors.cljs:41 +msgid "workspace.tokens.empty-input" +msgstr "token 값은 비워둘 수 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 +msgid "workspace.tokens.enter-token-name" +msgstr "%s token 이름 입력" + +#: src/app/main/data/workspace/tokens/errors.cljs:15 +msgid "workspace.tokens.error-parse" +msgstr "가져오기 오류: JSON을 구문 분석할 수 없습니다." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:49 +msgid "workspace.tokens.export" +msgstr "내보내기" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:125 +msgid "workspace.tokens.export-tokens" +msgstr "token 내보내기" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:118 +msgid "workspace.tokens.export.multiple-files" +msgstr "여러 파일" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:38 +msgid "workspace.tokens.export.no-tokens-themes-sets" +msgstr "내보낼 token, 테마 또는 세트가 없습니다." + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:35 +msgid "workspace.tokens.export.preview" +msgstr "미리보기:" + +#: src/app/main/ui/workspace/tokens/export/modal.cljs:116 +msgid "workspace.tokens.export.single-file" +msgstr "단일 파일" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "글꼴 크기 또는 {alias} 입력" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "글꼴 굵기/스타일 설정 오류. 현재 글꼴에 이 스타일이 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:42, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:137 +msgid "workspace.tokens.font-weight-value-enter" +msgstr "글꼴 굵기(300, Bold Italic 등) 또는 {alias} 입력" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:240 +msgid "workspace.tokens.gaps" +msgstr "간격" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +msgid "workspace.tokens.generic-error" +msgstr "오류: " + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:93 +msgid "workspace.tokens.group-name" +msgstr "그룹 이름" + +#: src/app/main/ui/workspace/tokens/sets.cljs +msgid "workspace.tokens.grouping-set-alert" +msgstr "token 세트 그룹화는 아직 지원되지 않습니다." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:233 +msgid "workspace.tokens.import-button-prefix" +msgstr "%s 가져오기" + +#: src/app/main/data/workspace/tokens/errors.cljs:32, +#: src/app/main/data/workspace/tokens/errors.cljs:37 +msgid "workspace.tokens.import-error" +msgstr "가져오기 오류:" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:273 +msgid "workspace.tokens.import-menu-folder-option" +msgstr "폴더" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:271 +msgid "workspace.tokens.import-menu-json-option" +msgstr "단일 JSON 파일" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:272 +msgid "workspace.tokens.import-menu-zip-option" +msgstr "ZIP 파일" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:241 +msgid "workspace.tokens.import-multiple-files" +msgstr "여러 파일의 경우 파일 이름/경로가 세트 이름입니다." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:240 +msgid "workspace.tokens.import-single-file" +msgstr "단일 JSON 파일에서 최상위 키는 token 세트 이름이어야 합니다." + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:237 +msgid "workspace.tokens.import-tokens" +msgstr "token 가져오기" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:414, +#: src/app/main/ui/workspace/tokens/sidebar.cljs:415 +msgid "workspace.tokens.import-tooltip" +msgstr "JSON 파일을 가져오면 현재 token, 세트 및 테마가 모두 교체됩니다" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:247 +msgid "workspace.tokens.import-warning" +msgstr "token을 가져오면 현재 token, 세트 및 테마가 모두 덮어써집니다." + +#: src/app/main/ui/workspace/tokens/management.cljs:78 +msgid "workspace.tokens.inactive-set" +msgstr "비활성" + +#: src/app/main/ui/workspace/tokens/management.cljs:70 +msgid "workspace.tokens.inactive-set-description" +msgstr "" +"이 세트가 활성화되지 않았습니다. 테마를 변경하거나 이 세트를 활성화하여 변경 " +"사항을 확인하세요" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:240, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:195 +msgid "workspace.tokens.individual-tokens" +msgstr "개별 token 사용" + +#: src/app/main/data/workspace/tokens/errors.cljs:49 +msgid "workspace.tokens.invalid-color" +msgstr "유효하지 않은 색상 값: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "잘못된 token 값: 글꼴 모음 token만 참조할 수 있습니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"유효하지 않은 글꼴 굵기 값입니다. 숫자 값(100~950) 또는 표준 이름(thin, " +"light, regular, bold 등)을 사용하세요. 필요하면 뒤에 'Italic'을 붙일 수 있습" +"니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:23 +msgid "workspace.tokens.invalid-json" +msgstr "가져오기 오류: JSON 내 token 데이터가 유효하지 않습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:27 +msgid "workspace.tokens.invalid-json-token-name" +msgstr "가져오기 오류: JSON 내 token 이름이 유효하지 않습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:28 +msgid "workspace.tokens.invalid-json-token-name-detail" +msgstr "" +"\"%s\"은(는) 유효한 token 이름이 아닙니다.\n" +"token 이름은 .으로 구분된 문자와 숫자만 포함할 수 있으며 $ 기호로 시작할 수 " +"없습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "" +"유효하지 않은 그림자 유형: '내부 그림자' 또는 '그림자 효과'만 가능합니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:81 +msgid "workspace.tokens.invalid-text-case-token-value" +msgstr "잘못된 token 값: none, Uppercase, Lowercase, Capitalize만 가능합니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:85 +msgid "workspace.tokens.invalid-text-decoration-token-value" +msgstr "잘못된 token 값: none, underline, strike-through만 가능합니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "잘못된 값: 복합 그림자 token을 참조해야 합니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:97 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "잘못된 값: 복합 타이포그래피 token을 참조해야 합니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:61, +#: src/app/main/data/workspace/tokens/errors.cljs:73, +#: src/app/main/data/workspace/tokens/errors.cljs:77 +msgid "workspace.tokens.invalid-value" +msgstr "잘못된 token 값: %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 +msgid "workspace.tokens.label.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:207 +msgid "workspace.tokens.label.group-placeholder" +msgstr "그룹 추가 (예: 모드)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:214 +msgid "workspace.tokens.label.theme" +msgstr "테마" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:215 +msgid "workspace.tokens.label.theme-placeholder" +msgstr "테마 추가 (예: 라이트)" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "자간 또는 {alias} 입력" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +msgid "workspace.tokens.line-height-value-enter" +msgstr "행간(배수, px, %) 또는 {alias} 입력" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:232 +msgid "workspace.tokens.margins" +msgstr "마진" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:268 +msgid "workspace.tokens.max-size" +msgstr "최대 크기" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:262 +msgid "workspace.tokens.min-size" +msgstr "최소 크기" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "누락된 참조" + +#: src/app/main/data/workspace/tokens/errors.cljs:57 +msgid "workspace.tokens.missing-references" +msgstr "누락된 token 참조: " + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 +msgid "workspace.tokens.more-options" +msgstr "우클릭으로 옵션 보기" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 +msgid "workspace.tokens.no-active-sets" +msgstr "활성 세트 없음" + +#: src/app/main/ui/workspace/tokens/themes/theme_selector.cljs:91 +msgid "workspace.tokens.no-active-theme" +msgstr "활성 테마 없음" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:72 +msgid "workspace.tokens.no-permisions-set" +msgstr "세트를 활성화 / 비활성화하려면 편집자여야 합니다" + +#: src/app/main/ui/workspace/tokens/themes.cljs:54 +msgid "workspace.tokens.no-permission-themes" +msgstr "테마를 사용하려면 편집자여야 합니다" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +msgid "workspace.tokens.no-references-found" +msgstr "참조 없음" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +msgid "workspace.tokens.no-remap-needed" +msgstr "이 token은 현재 디자인에서 사용되지 않으므로 재매핑이 필요 없습니다." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:485 +msgid "workspace.tokens.no-sets-create" +msgstr "아직 정의된 세트가 없습니다. 먼저 하나를 만드세요." + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:93, +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:99 +msgid "workspace.tokens.no-sets-yet" +msgstr "아직 세트가 없습니다." + +#: src/app/main/ui/workspace/tokens/themes.cljs:40 +msgid "workspace.tokens.no-themes" +msgstr "테마가 없습니다." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:53 +msgid "workspace.tokens.no-themes-currently" +msgstr "현재 테마가 없습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:19 +msgid "workspace.tokens.no-token-files-found" +msgstr "이 파일에서 token, 세트 또는 테마를 찾을 수 없습니다." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "재매핑 안 함" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 +msgid "workspace.tokens.num-active-sets" +msgstr "활성 세트 %s개" + +#: src/app/main/data/workspace/tokens/errors.cljs:53 +msgid "workspace.tokens.number-too-large" +msgstr "잘못된 token 값. 확인된 값이 너무 큽니다: %s" + +#: src/app/main/data/workspace/tokens/errors.cljs:73, +#: src/app/main/data/workspace/tokens/warnings.cljs:15 +msgid "workspace.tokens.opacity-range" +msgstr "불투명도는 0~100% 또는 0~1 사이의 값이어야 합니다(예: 50% 또는 0.5)." + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 +msgid "workspace.tokens.original-value" +msgstr "원래 값: %s" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:216 +msgid "workspace.tokens.paddings" +msgstr "패딩" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:292 +msgid "workspace.tokens.radius" +msgstr "반지름" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:129 +msgid "workspace.tokens.ref-not-valid" +msgstr "참조가 유효하지 않거나 활성 세트에 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "타이포그래피 token 별칭 입력" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "그림자 token 별칭 입력" + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs +msgid "workspace.tokens.reference-error" +msgstr "참조 오류: " + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "토큰 재매핑" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "`%s`을 사용하는 모든 토큰을 `%s`로 재매핑하시겠습니까?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "이 작업은 이전 token 이름을 사용하는 모든 레이어와 참조를 변경합니다." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "이 작업은 시간이 조금 걸릴 수 있습니다." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +msgid "workspace.tokens.remapping-in-progress" +msgstr "token 참조 재매핑 중..." + +#: src/app/main/data/workspace/tokens/warnings.cljs:15, +#: src/app/main/data/workspace/tokens/warnings.cljs:19, +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, +#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, +#: src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, +#: src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, +#: src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, +#: src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 +msgid "workspace.tokens.resolved-value" +msgstr "확인된 값: %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:251 +msgid "workspace.tokens.save-theme" +msgstr "테마 저장" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:204, +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:309 +msgid "workspace.tokens.select-set" +msgstr "세트 선택." + +#: src/app/main/data/workspace/tokens/errors.cljs:45, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 +msgid "workspace.tokens.self-reference" +msgstr "token이 자기 자신을 참조하고 있습니다" + +#: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 +msgid "workspace.tokens.set-edit-placeholder" +msgstr "이름 입력 (그룹화하려면 '/' 사용)" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:361 +msgid "workspace.tokens.set-selection-theme" +msgstr "이 테마에 포함할 token 세트를 정의하세요:" + +#: src/app/main/ui/workspace/tokens/token_pill.cljs:47 +msgid "workspace.tokens.set.not-active" +msgstr "token 세트가 활성화되지 않았습니다" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:129 +msgid "workspace.tokens.sets-hint" +msgstr "테마 편집 및 세트 관리" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:91 +msgid "workspace.tokens.setting-description" +msgstr "여기서 1rem의 값을 정의하는 기본 글꼴 크기를 설정할 수 있습니다:" + +#: src/app/main/ui/workspace/tokens/settings/menu.cljs:84 +msgid "workspace.tokens.settings" +msgstr "token 설정" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "그림자 추가" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:161, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:162, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:165 +msgid "workspace.tokens.shadow-blur" +msgstr "블러" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "그림자 블러는 0 이상이어야 합니다." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +msgid "workspace.tokens.shadow-color" +msgstr "색상" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:114 +msgid "workspace.tokens.shadow-inset" +msgstr "내부" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "그림자 제거" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "확산" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "그림자 확산은 0 이상이어야 합니다." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +msgid "workspace.tokens.shadow-title" +msgstr "그림자" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "블러 값은 음수일 수 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "확산 값은 음수일 수 없습니다" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +msgid "workspace.tokens.shadow-x" +msgstr "X" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +msgid "workspace.tokens.shadow-y" +msgstr "Y" + +#: src/app/main/ui/workspace/tokens/management/context_menu.cljs:256 +msgid "workspace.tokens.size" +msgstr "크기" + +#: src/app/main/data/workspace/tokens/errors.cljs:77, +#: src/app/main/data/workspace/tokens/warnings.cljs:19 +msgid "workspace.tokens.stroke-width-range" +msgstr "선 두께는 0 이상이어야 합니다." + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 +msgid "workspace.tokens.text-case-value-enter" +msgstr "없음 | 대문자 | 소문자 | 각 단어 첫 글자 대문자 또는 {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 +msgid "workspace.tokens.text-decoration-value-enter" +msgstr "없음 | 밑줄 | 취소선 또는 {alias}" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 +msgid "workspace.tokens.theme-name" +msgstr "테마 %s" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +msgid "workspace.tokens.theme-name-already-exists" +msgstr "이미 같은 이름의 테마가 있습니다" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +msgid "workspace.tokens.theme.disable" +msgstr "비활성화" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +msgid "workspace.tokens.theme.enable" +msgstr "활성화" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:85 +msgid "workspace.tokens.themes-description" +msgstr "" +"여기서 테마를 관리하고, 활성화 / 비활성화하고, 활성 세트를 설정할 수 있습니" +"다." + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:49, +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:83 +msgid "workspace.tokens.themes-list" +msgstr "테마 목록" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 +msgid "workspace.tokens.token-description" +msgstr "설명" + +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:122, +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:246 +msgid "workspace.tokens.token-font-family-select" +msgstr "글꼴 모음 선택" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +msgid "workspace.tokens.token-font-family-value" +msgstr "글꼴 모음" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "글꼴 모음 또는 쉼표(,)로 구분된 글꼴 목록" + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:83, +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:112, +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:240 +msgid "workspace.tokens.token-name" +msgstr "이름" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "해당 경로에 이미 token이 있습니다: %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "이름은 최소 1자 이상이어야 합니다" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:267, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:222 +msgid "workspace.tokens.token-name-validation-error" +msgstr "" +" 은(는) 유효한 token 이름이 아닙니다.\n" +"token 이름은 .으로 구분된 문자와 숫자만 포함할 수 있으며 $ 기호로 시작할 수 " +"없습니다." + +#: src/app/main/ui/workspace/tokens/style_dictionary.cljs:259 +msgid "workspace.tokens.token-not-resolved" +msgstr "이름으로 참조 token을 확인할 수 없습니다: %s" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:267 +msgid "workspace.tokens.token-value" +msgstr "값" + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:266, +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:129 +msgid "workspace.tokens.token-value-enter" +msgstr "값 또는 {alias}를 사용한 별칭을 입력하세요" + +#: src/app/main/ui/workspace/tokens/management.cljs:67 +msgid "workspace.tokens.tokens-section-title" +msgstr "token - %s" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:122 +msgid "workspace.tokens.tools" +msgstr "도구" + +#: src/app/main/data/workspace/tokens/import_export.cljs:46 +msgid "workspace.tokens.unknown-token-type-message" +msgstr "" +"가져오기가 성공적으로 완료되었습니다. 일부 token이 포함되지 않았습니다." + +#: src/app/main/data/workspace/tokens/import_export.cljs:48 +msgid "workspace.tokens.unknown-token-type-section" +msgstr "유형 '%s'가 지원되지 않습니다 (%s)\n" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 +msgid "workspace.tokens.use-reference" +msgstr "참조 사용" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:132 +msgid "workspace.tokens.value-not-valid" +msgstr "값이 유효하지 않습니다" + +#: src/app/main/data/workspace/tokens/errors.cljs:69 +msgid "workspace.tokens.value-with-percent" +msgstr "잘못된 값: %가 허용되지 않습니다." + +#: src/app/main/data/workspace/tokens/errors.cljs:65 +msgid "workspace.tokens.value-with-units" +msgstr "잘못된 값: 단위가 허용되지 않습니다." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +msgid "workspace.tokens.warning-name-change" +msgstr "이 token의 이름을 바꾸면 기존 이름에 대한 참조가 끊어집니다" + +#: src/app/main/ui/workspace/sidebar.cljs:159, +#: src/app/main/ui/workspace/sidebar.cljs:166 +msgid "workspace.toolbar.assets" +msgstr "에셋" + +#: src/app/main/ui/workspace/palette.cljs:184 +msgid "workspace.toolbar.color-palette" +msgstr "색상 팔레트 (%s)" + +#: src/app/main/ui/workspace/right_header.cljs:217 +msgid "workspace.toolbar.comments" +msgstr "댓글 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:195 +msgid "workspace.toolbar.curve" +msgstr "곡선 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "디버깅 도구" + +#: src/app/main/ui/workspace/top_toolbar.cljs:172 +msgid "workspace.toolbar.ellipse" +msgstr "타원 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:130 +msgid "workspace.toolbar.frame" +msgstr "보드 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:129 +msgid "workspace.toolbar.frame-first-time" +msgstr "보드를 만듭니다. 클릭하고 드래그하여 크기를 정의합니다. (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:60 +msgid "workspace.toolbar.image" +msgstr "이미지 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:143 +msgid "workspace.toolbar.move" +msgstr "이동 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:205 +msgid "workspace.toolbar.path" +msgstr "경로 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:216 +msgid "workspace.toolbar.plugins" +msgstr "플러그인 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:162 +msgid "workspace.toolbar.rect" +msgstr "직사각형 (%s)" + +#: src/app/main/ui/workspace/left_toolbar.cljs +msgid "workspace.toolbar.shortcuts" +msgstr "단축키 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:182 +msgid "workspace.toolbar.text" +msgstr "텍스트 (%s)" + +#: src/app/main/ui/workspace/palette.cljs:190 +msgid "workspace.toolbar.text-palette" +msgstr "타이포그래피 (%s)" + +#: src/app/main/ui/workspace/top_toolbar.cljs:235, +#: src/app/main/ui/workspace/top_toolbar.cljs:236 +msgid "workspace.toolbar.toggle-toolbar" +msgstr "도구 모음 켜기/끄기" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:41 +msgid "workspace.top-bar.read-only.done" +msgstr "완료" + +#: src/app/main/ui/workspace/viewport/top_bar.cljs:37 +msgid "workspace.top-bar.view-only" +msgstr "**코드 검사 중** (보기 전용)" + +#: src/app/main/ui/workspace/sidebar/history.cljs:333 +msgid "workspace.undo.empty" +msgstr "아직 기록 변경사항이 없습니다" + +#: src/app/main/ui/workspace/sidebar/history.cljs:147 +msgid "workspace.undo.entry.delete" +msgstr "삭제됨 %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:146 +msgid "workspace.undo.entry.modify" +msgstr "수정됨 %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:148 +msgid "workspace.undo.entry.move" +msgstr "객체 이동됨" + +#: src/app/main/ui/workspace/sidebar/history.cljs:111 +msgid "workspace.undo.entry.multiple.circle" +msgstr "원" + +#: src/app/main/ui/workspace/sidebar/history.cljs:112 +msgid "workspace.undo.entry.multiple.color" +msgstr "색상 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:113 +msgid "workspace.undo.entry.multiple.component" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:114 +msgid "workspace.undo.entry.multiple.curve" +msgstr "곡선" + +#: src/app/main/ui/workspace/sidebar/history.cljs:115 +msgid "workspace.undo.entry.multiple.frame" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/history.cljs:116 +msgid "workspace.undo.entry.multiple.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/history.cljs:117 +msgid "workspace.undo.entry.multiple.media" +msgstr "그래픽 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:118 +msgid "workspace.undo.entry.multiple.multiple" +msgstr "객체" + +#: src/app/main/ui/workspace/sidebar/history.cljs:119 +msgid "workspace.undo.entry.multiple.page" +msgstr "페이지" + +#: src/app/main/ui/workspace/sidebar/history.cljs:120 +msgid "workspace.undo.entry.multiple.path" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/history.cljs:121 +msgid "workspace.undo.entry.multiple.rect" +msgstr "직사각형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:122 +msgid "workspace.undo.entry.multiple.shape" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:123 +msgid "workspace.undo.entry.multiple.text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:124 +msgid "workspace.undo.entry.multiple.typography" +msgstr "타이포그래피 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:145 +msgid "workspace.undo.entry.new" +msgstr "새 %s" + +#: src/app/main/ui/workspace/sidebar/history.cljs:125 +msgid "workspace.undo.entry.single.circle" +msgstr "원" + +#: src/app/main/ui/workspace/sidebar/history.cljs:126 +msgid "workspace.undo.entry.single.color" +msgstr "색상 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:127 +msgid "workspace.undo.entry.single.component" +msgstr "컴포넌트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:128 +msgid "workspace.undo.entry.single.curve" +msgstr "곡선" + +#: src/app/main/ui/workspace/sidebar/history.cljs:129 +msgid "workspace.undo.entry.single.frame" +msgstr "보드" + +#: src/app/main/ui/workspace/sidebar/history.cljs:130 +msgid "workspace.undo.entry.single.group" +msgstr "그룹" + +#: src/app/main/ui/workspace/sidebar/history.cljs:131 +msgid "workspace.undo.entry.single.image" +msgstr "이미지" + +#: src/app/main/ui/workspace/sidebar/history.cljs:132 +msgid "workspace.undo.entry.single.media" +msgstr "그래픽 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:133 +msgid "workspace.undo.entry.single.multiple" +msgstr "객체" + +#: src/app/main/ui/workspace/sidebar/history.cljs:134 +msgid "workspace.undo.entry.single.page" +msgstr "페이지" + +#: src/app/main/ui/workspace/sidebar/history.cljs:135 +msgid "workspace.undo.entry.single.path" +msgstr "경로" + +#: src/app/main/ui/workspace/sidebar/history.cljs:136 +msgid "workspace.undo.entry.single.rect" +msgstr "직사각형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:137 +msgid "workspace.undo.entry.single.shape" +msgstr "도형" + +#: src/app/main/ui/workspace/sidebar/history.cljs:138 +msgid "workspace.undo.entry.single.text" +msgstr "텍스트" + +#: src/app/main/ui/workspace/sidebar/history.cljs:139 +msgid "workspace.undo.entry.single.typography" +msgstr "타이포그래피 에셋" + +#: src/app/main/ui/workspace/sidebar/history.cljs:149 +msgid "workspace.undo.entry.unknown" +msgstr "%s에 대한 작업" + +#: src/app/main/ui/workspace/sidebar/history.cljs:335 +msgid "workspace.undo.title" +msgstr "히스토리" + +#: src/app/main/data/workspace/libraries.cljs:1247, +#: src/app/main/ui/workspace/sidebar/versions.cljs:85 +msgid "workspace.updates.dismiss" +msgstr "해제" + +#: src/app/main/data/workspace/libraries.cljs:1245 +msgid "workspace.updates.more-info" +msgstr "자세히" + +#: src/app/main/data/workspace/libraries.cljs:1243 +msgid "workspace.updates.there-are-updates" +msgstr "공유 라이브러리에 업데이트가 있습니다" + +#: src/app/main/data/workspace/libraries.cljs:1249 +msgid "workspace.updates.update" +msgstr "업데이트" + +#: src/app/main/ui/ds/product/milestone_group.cljs:73 +msgid "workspace.versions.autosaved.entry" +msgstr "%s 자동 저장 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:260 +msgid "workspace.versions.autosaved.version" +msgstr "자동 저장됨 %s" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:278 +msgid "workspace.versions.button.pin" +msgstr "버전 고정" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:273 +msgid "workspace.versions.button.restore" +msgstr "버전 복원" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:396, +#: src/app/main/ui/workspace/sidebar/versions.cljs:398 +msgid "workspace.versions.button.save" +msgstr "버전 저장" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:405 +msgid "workspace.versions.empty" +msgstr "아직 버전이 없습니다" + +#: src/app/main/ui/ds/product/milestone_group.cljs:67 +msgid "workspace.versions.expand-snapshot" +msgstr "스냅샷 확장" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:369 +msgid "workspace.versions.filter.all" +msgstr "모든 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:383 +msgid "workspace.versions.filter.label" +msgstr "버전 필터" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:370 +msgid "workspace.versions.filter.mine" +msgstr "내 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:374 +msgid "workspace.versions.filter.user" +msgstr "%s의 버전" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:391 +msgid "workspace.versions.loading" +msgstr "로드 중..." + +msgid "workspace.versions.locked-by-other" +msgstr "이 버전은 %s에 의해 잠겨있으며 수정할 수 없습니다" + +msgid "workspace.versions.locked-by-you" +msgstr "이 버전은 당신에 의해 잠겨있습니다" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:83 +msgid "workspace.versions.restore-warning" +msgstr "이 버전을 복원하시겠습니까?" + +msgid "workspace.versions.snapshot-menu" +msgstr "스냅샷 메뉴 열기" + +#: src/app/main/ui/workspace/sidebar.cljs:257 +msgid "workspace.versions.tab.actions" +msgstr "동작" + +#: src/app/main/ui/workspace/sidebar.cljs:255 +msgid "workspace.versions.tab.history" +msgstr "히스토리" + +msgid "workspace.versions.tooltip.locked-version" +msgstr "잠긴 버전 - 생성자만 수정할 수 있습니다" + +#: src/app/main/ui/ds/product/milestone.cljs:84, +#: src/app/main/ui/ds/product/milestone_group.cljs:86 +msgid "workspace.versions.version-menu" +msgstr "버전 메뉴 열기" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:60 +msgid "workspace.versions.warning.subtext" +msgstr "이 제한을 늘리고 싶으시면, [support@penpot.app](%s)으로 문의하세요" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:431 +msgid "workspace.versions.warning.text" +msgstr "자동 저장 버전은 %s일 동안 유지됩니다." + +msgid "workspace.viewport.click-to-close-path" +msgstr "경로를 닫으려면 클릭하세요" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:26 +msgid "inspect.tabs.styles.active-sets" +msgstr "활성 세트" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:21 +msgid "inspect.tabs.styles.active-themes" +msgstr "활성 테마" + +#: src/app/main/ui/dashboard/subscription.cljs:180 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-title" +msgstr "무제한 플랜 이용 중 사람 초대하기" diff --git a/frontend/translations/lv.po b/frontend/translations/lv.po index 94b374aa1d..eebc97d0fb 100644 --- a/frontend/translations/lv.po +++ b/frontend/translations/lv.po @@ -1,16 +1,16 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: \"Ņikita K.\" \n" -"Language-Team: Latvian " -"\n" +"PO-Revision-Date: 2026-02-18 14:09+0000\n" +"Last-Translator: Edgars Andersons \n" +"Language-Team: Latvian \n" "Language: lv\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" -"Plural-Forms: nplurals=3; plural=(n % 10 == 0 || n % 100 >= 11 && n % 100 " -"<= 19) ? 0 : ((n % 10 == 1 && n % 100 != 11) ? 1 : 2);\n" -"X-Generator: Weblate 5.16-dev\n" +"Plural-Forms: nplurals=3; plural=(n % 10 == 0 || n % 100 >= 11 && n % 100 <= " +"19) ? 0 : ((n % 10 == 1 && n % 100 != 11) ? 1 : 2);\n" +"X-Generator: Weblate 5.16.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -205,7 +205,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "... zīmolrades, ilustrācijām, mārketinga materiāliem utt." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Šī tekstvienība nepastāv vai ir izdzēsta." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1211,7 +1211,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Atvērt tekstvienību sarakstu" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Atdalīt tekstvienību" #: src/app/main/data/auth.cljs:339 @@ -3389,7 +3389,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Ir pieejama jauna versija, lūgums atsvaidzināt lapu" +msgstr "Ir pieejama jauna versija." #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" @@ -4622,11 +4622,11 @@ msgstr "" "\"Abonements\"." #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Izbaudi savu plānu!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Tu esi %s." #: src/app/main/ui/settings/subscription.cljs:526 @@ -6270,39 +6270,39 @@ msgstr "Papildu opcijas" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:686, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:693 msgid "workspace.options.layout-item.layout-item-max-h" -msgstr "Maks.augstums" +msgstr "Lielākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:624, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:631 msgid "workspace.options.layout-item.layout-item-max-w" -msgstr "Maks.platums" +msgstr "Lielākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:655, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:663 msgid "workspace.options.layout-item.layout-item-min-h" -msgstr "Min.augstums" +msgstr "Mazākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:591, src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:600 msgid "workspace.options.layout-item.layout-item-min-w" -msgstr "Min.platums" +msgstr "Mazākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-max-h" -msgstr "Maksimālais augstums" +msgstr "Lielākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-max-w" -msgstr "Maksimālais platums" +msgstr "Lielākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-min-h" -msgstr "Minimālais augstums" +msgstr "Mazākais augstums" #: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs #, unused msgid "workspace.options.layout-item.title.layout-item-min-w" -msgstr "Minimālais platums" +msgstr "Mazākais platums" #: src/app/main/ui/workspace/sidebar/options/menus/layout.cljs #, unused @@ -7378,7 +7378,7 @@ msgid "workspace.tokens.edit-themes" msgstr "Labot izskatus" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Tekstvienības vērtība nevar būt tukša" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7386,7 +7386,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Jāievada %s tekstvienības nosaukums" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Ievietošanas kļūda: nevarēja apstrādāt JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7432,7 +7432,7 @@ msgid "workspace.tokens.grouping-set-alert" msgstr "Tekstvienību apkopošana vēl netiek nodrošināta." #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Ievietošanas kļūda:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:241 @@ -7473,26 +7473,26 @@ msgstr "" "redzētu izmaiņas" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Nederīga krāsas vērtība: %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Ievietošanas kļūda: JSON satur nederīgus tekstvienību datus." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Ievietošanas kļūda: nederīgs tekstvienības nosaukums JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" nav derīgs tekstvienības nosaukums.\n" "Tekstvienību nosaukumos var būt tikai burti un cipari, kas atdalīti ar " "rakstzīmi \".\", un tie nedrīkst sākties ar zīmi \"$\"." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Nederīga tekstvienības vērtība: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7524,7 +7524,7 @@ msgid "workspace.tokens.min-size" msgstr "Mazākais lielums" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Trūkst tekstvienību atsauces: " #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 @@ -7564,11 +7564,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s spēkā esošas kopas" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Nederīga tekstvienības vērtība. Aprēķinātā vērtība ir pārāk liela: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Necaurspīdīgumam ir jābūt starp 0 un 100 % vai 0 un 1 (piem., 50% jeb 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -7607,7 +7607,7 @@ msgid "workspace.tokens.select-set" msgstr "Atlasīt kopu." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Tekstvienībai ir atsauce uz sevi" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7655,7 +7655,7 @@ msgid "workspace.tokens.size" msgstr "Izmērs" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Vilkuma platumam ir jābūt vienādam vai lielākam par 0." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 @@ -7720,16 +7720,16 @@ msgstr "TEKSTVIENĪBAS - %s" msgid "workspace.tokens.tools" msgstr "Rīki" -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Veids \"%s\" nav atbalstīts (%s)\n" +msgstr "Veids \"%s\" nav atbalstīts (%s):" #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:132 msgid "workspace.tokens.value-not-valid" msgstr "Vērtība nav derīga" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Nederīga vērtība: mērvienības nav atļautas." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/ms.po b/frontend/translations/ms.po index c23e749435..cdfd2be839 100644 --- a/frontend/translations/ms.po +++ b/frontend/translations/ms.po @@ -2332,7 +2332,7 @@ msgstr "Kemas kini komponen dalam pustaka kongsi" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Versi baharu tersedia, sila muat semula halaman" +msgstr "Versi baharu tersedia." #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" diff --git a/frontend/translations/nl.po b/frontend/translations/nl.po index 68fd89b0d8..67fb439590 100644 --- a/frontend/translations/nl.po +++ b/frontend/translations/nl.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Sebastiaan Pasma \n" -"Language-Team: Dutch " -"\n" +"PO-Revision-Date: 2026-02-17 10:09+0000\n" +"Last-Translator: Stephan Paternotte \n" +"Language-Team: Dutch \n" "Language: nl\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -201,7 +201,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "…branding, illustraties, marketingstukken, etc." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Dit token bestaat niet of is verwijderd." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1353,7 +1353,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Lijst met tokens openen" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Token loskoppelen" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -2115,7 +2115,6 @@ msgid "inspect.tabs.styles.active-themes" msgstr "Actieve thema's" #: src/app/main/ui/inspect/styles/style_box.cljs:68 -#, fuzzy msgid "inspect.tabs.styles.copy-shorthand" msgstr "CSS-code kopiëren naar klembord" @@ -3711,7 +3710,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Er is een nieuwe versie beschikbaar, vernieuw de pagina" +msgstr "Er is een nieuwe versie beschikbaar." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -5036,15 +5035,14 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Bedankt voor het kiezen van het Penpot %s-abonnement!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Veel plezier met je abonnement!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Je bent %s!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" msgstr "Je hebt ons gesteund met dit abonnement sinds: %s" @@ -7854,7 +7852,7 @@ msgid "workspace.tokens.color" msgstr "Kleur" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Regelafstand is afhankelijk van de lettergrootte. Voeg een lettergrootte " "toe om de opgeloste waarde te verkrijgen." @@ -7904,7 +7902,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s token bewerken" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "De tokenwaarde mag niet leeg zijn" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7912,7 +7910,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Voer de tokennaam %s in" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Fout bij importeren: Kan JSON niet verwerken." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7976,7 +7974,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importeren: %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Fout bij importeren:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -8033,63 +8031,63 @@ msgid "workspace.tokens.individual-tokens" msgstr "Individuele tokens gebruiken" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Ongeldige kleurwaarde: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Ongeldige tokenwaarde: je kunt alleen verwijzen naar een font-family-token" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Ongeldige gewichtswaarde lettertype: gebruik numerieke waarden (100-950) of " "standaardtermen (thin, light, regular, bold, etc.) optioneel gevolgd door " "'Italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Fout bij importeren: Ongeldige tokengegevens in JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Importfout: Ongeldige tokennaam in JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" is geen geldige tokennaam.\n" "Tokennamen mogen alleen letters en cijfers bevatten, gescheiden door . " "tekens en mogen niet beginnen met een $-teken." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "" "Ongeldig schaduwtype: alleen 'innerShadow' of 'dropShadow' worden " "geaccepteerd" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Ongeldige tokenwaarde: alleen none, Uppercase, Lowercase of Capitalize zijn " "toegestaan" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Ongeldige tokenwaarde: alleen none, underline en strike-through zijn " "toegestaan" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Ongeldige waarde: moet verwijzen naar een samengesteld schaduwtoken." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Ongeldige waarde: moet verwijzen naar een samengesteld typografietoken." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Ongeldige tokenwaarde: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8129,7 +8127,7 @@ msgid "workspace.tokens.min-size" msgstr "Min. grootte" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Ontbrekende tokenverwijzingen: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8181,7 +8179,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Je hebt momenteel geen thema's." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Er zijn geen tokens, verzamelingen of thema's gevonden in dit bestand." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -8189,15 +8187,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s actieve verzamelingen" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Ongeldige tokenwaarde. De opgeloste waarde is te groot: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "De dekking moet tussen 0 en 100% of 0 en 1 zijn (bijv. 50% of 0,5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "Oorspronkelijke waarde: %s" @@ -8232,7 +8229,6 @@ msgid "workspace.tokens.remapping-in-progress" msgstr "Token-referenties opnieuw toewijzen…" #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "Opgeloste waarde: %s" @@ -8245,7 +8241,7 @@ msgid "workspace.tokens.select-set" msgstr "Verzameling kiezen." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token bevat cirkelverwijzing" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8286,7 +8282,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Vervanging" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Schaduwonscherpte moet groter zijn dan of gelijk zijn aan 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8307,7 +8303,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spreiding" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Schaduwspreiding moet groter zijn dan of gelijk zijn aan 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8337,7 +8333,7 @@ msgid "workspace.tokens.size" msgstr "Grootte" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "De dikte van de streek moet groter zijn dan of gelijk zijn aan 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8379,7 +8375,6 @@ msgid "workspace.tokens.themes-list" msgstr "Lijst met thema's" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "Beschrijving" @@ -8399,10 +8394,6 @@ msgstr "Lettertypefamilie of lijst met lettertypen gescheiden door komma (,)" msgid "workspace.tokens.token-name" msgstr "Naam" -#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 -msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "Er bestaat al een token op het pad: %s" - #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" msgstr "Naam moet minimaal 1 teken zijn" @@ -8435,13 +8426,13 @@ msgstr "TOKENS - %s" msgid "workspace.tokens.tools" msgstr "Hulpmiddelen" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "Importeren was succesvol. Sommige tokens zijn niet inbegrepen." +msgstr "Importeren was succesvol, maar sommige tokens zijn overgeslagen omdat ze niet-ondersteunde $type-waarden gebruiken. Klap de details uit om te zien welke tokens getroffen zijn." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Typ '%s' wordt niet ondersteund (%s)\n" +msgstr "Typ '%s' wordt niet ondersteund (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8452,11 +8443,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "De waarde is niet geldig" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Ongeldige waarde: % is niet toegestaan." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Ongeldige waarde: Eenheden zijn niet toegestaan." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8788,3 +8779,66 @@ msgstr "Automatisch opgeslagen versies worden %s dagen bewaard." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Klik om het pad te sluiten" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL is gestopt met werken. Laad de pagina opnieuw om deze opnieuw in te " +"stellen" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oeps! De context van het doek ging verloren" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Nieuwe org aanmaken" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Onverwachte for: %s" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Pagina's opnieuw laden" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Hulpmiddelen voor foutopsporing" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Ontbrekende verwijzing" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Niet opnieuw toewijzen" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Tokens opnieuw toewijzen" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Alle tokens die '%s' gebruiken toewijzen aan '%s'?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Hierdoor worden alle lagen en verwijzingen die de oude tokennaam gebruiken, " +"gewijzigd." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Deze actie kan wel even duren." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "" +"Door dit token te hernoemen, wordt elke verwijzing naar de oude naam " +"verbroken" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Hulpmiddelen voor foutopsporing" diff --git a/frontend/translations/pt_BR.po b/frontend/translations/pt_BR.po index 82a5480389..eb14ed44e8 100644 --- a/frontend/translations/pt_BR.po +++ b/frontend/translations/pt_BR.po @@ -202,7 +202,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "... marca, ilustrações, materiais de marketing, etc." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Este token não existe ou foi excluído." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1182,7 +1182,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Abrir lista de tokens" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Desvincular token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 diff --git a/frontend/translations/pt_PT.po b/frontend/translations/pt_PT.po index c44f021561..c839a9496e 100644 --- a/frontend/translations/pt_PT.po +++ b/frontend/translations/pt_PT.po @@ -2968,7 +2968,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Está disponível uma nova versão, por favor atualiza a página" +msgstr "Está disponível uma nova versão." #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" diff --git a/frontend/translations/ro.po b/frontend/translations/ro.po index 9f8c230987..8995460842 100644 --- a/frontend/translations/ro.po +++ b/frontend/translations/ro.po @@ -208,7 +208,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "... mărci, ilustrații, piese de marketing, etc." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Acest token nu există sau a fost șters." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1199,7 +1199,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Deschide lista de token-uri" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Detașează tokenul" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -3446,7 +3446,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "O versiune nouă este valabilă, te rugăm să reîncarci pagina" +msgstr "O versiune nouă este valabilă." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -4747,11 +4747,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Mulțumim pentru că ai ales planul Penpot %s!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Bucură-te de abonament!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Ești %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7492,7 +7492,7 @@ msgid "workspace.tokens.color" msgstr "Culoare" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Înălțimea liniei depinde de dimensiunea fontului. Adaugă o dimensiune " "pentru font pentru a primi valoarea rezultată." @@ -7542,7 +7542,7 @@ msgid "workspace.tokens.edit-token" msgstr "Editează token %s" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Valoarea token-ului nu poate fi goală" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7550,7 +7550,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Introdu numele token-ului %s" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Eroare la import: Nu s-a putut interpreta JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7610,7 +7610,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importă %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Eroare import:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7667,49 +7667,49 @@ msgid "workspace.tokens.individual-tokens" msgstr "Folosește token-uri individuale" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Valoare culoare invalidă: %s" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Valoare greutate font invalidă: folosește valori numerice (100-950) sau " "nume standardizate (thin, light, regular, bold, etc.) opțional urmate de " "'italic'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Eroare import: Date token invalide în fișierul JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Eroare import: Nume token invalid în fișierul JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" nu este nu nume valid pentru token.\n" "Numele token-urilor trebuie să conțină doar litere și cifre separate de " "caracterul . și nu trebuie să înceapă cu un semn $." #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Valoare token invalidă: doar none, Uppercase, Lowercase sau Capitalize sunt " "acceptate" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "Valoare token invalidă: doar none, underline și strike-trough sunt acceptate" #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "" "Valoare invalidă: trebuie să facă referință la un token de tipografie " "compozită." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Valoare token invalidă: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7749,7 +7749,7 @@ msgid "workspace.tokens.min-size" msgstr "Dimensiune minimă" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Referințe token lipsă: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7789,7 +7789,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "În prezent nu ai teme." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Nu s-au găsit token-uri, seturi sau teme în acest fișier." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7797,11 +7797,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s seturi active" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Valoare token invalidă. Valoarea rezultată este prea mare: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opacitatea trebuie să fie între 0 și 100% sau 0 și 1 (ex: 50% sau 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 @@ -7844,7 +7844,7 @@ msgid "workspace.tokens.select-set" msgstr "Selectează setul." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token-ul își face referință singur" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7881,7 +7881,7 @@ msgid "workspace.tokens.size" msgstr "Dimensiune" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Lățimea conturului trebuie să fie mai mare sau egală cu 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -7956,13 +7956,13 @@ msgstr "TOKEN-URI- %s" msgid "workspace.tokens.tools" msgstr "Unelte" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "Import cu succes. Unele token-uri nu au fost incluse." +msgstr "Importul a fost realizat cu succes, dar unele token-uri au fost omise deoarece folosesc valori $type neacceptate. Extindeți detaliile pentru a vedea care token-uri au fost afectate." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Tipul '%s' nu este suportat (%s)\n" +msgstr "Tipul '%s' nu este suportat (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -7973,11 +7973,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Valoarea este invalidă" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Valoare invalidă: % nu este permis." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Valoare invalidă: Unitățile nu sunt permise." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/ru.po b/frontend/translations/ru.po index 8b107f92b7..58891a8f46 100644 --- a/frontend/translations/ru.po +++ b/frontend/translations/ru.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: The_BadUser \n" -"Language-Team: Russian " -"\n" +"PO-Revision-Date: 2026-04-28 15:09+0000\n" +"Last-Translator: bobsonzu0a5d198e17c343cb \n" +"Language-Team: Russian \n" "Language: ru\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17.1-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -204,7 +204,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "...брендинг, иллюстрации, маркетинговые материалы и т.д." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Этот токен не существует или был удален." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -676,7 +676,7 @@ msgstr "Файлы с ошибками загружены не будут." msgid "dashboard.import.import-message" msgid_plural "dashboard.import.import-message" msgstr[0] "1 файл был успешно импортирован." -msgstr[1] "Успешно импортировано файлов: %s" +msgstr[1] "%s файлов было успешно импортировано." #: src/app/main/ui/dashboard/import.cljs:474 msgid "dashboard.import.import-warning" @@ -1181,7 +1181,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Открыть список токенов" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Отсоединить токен" #: src/app/main/data/auth.cljs:339 @@ -3108,10 +3108,6 @@ msgstr "" "Технический перерыв: сервис будет недоступен короткое время в течение 5 " "минут." -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "Доступна новая версия, обновите страницу" - #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" msgstr "Приглашение успешно отправлено" @@ -5283,19 +5279,19 @@ msgstr "Поведение" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:393 msgid "workspace.options.interaction-easing-ease" -msgstr "Ease" +msgstr "Плавно" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:394 msgid "workspace.options.interaction-easing-ease-in" -msgstr "Ease in" +msgstr "Ускорение" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:396 msgid "workspace.options.interaction-easing-ease-in-out" -msgstr "Ease in out" +msgstr "Ускорение и замедление" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:395 msgid "workspace.options.interaction-easing-ease-out" -msgstr "Ease out" +msgstr "Замедление" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:392 msgid "workspace.options.interaction-easing-linear" @@ -6685,3 +6681,497 @@ msgstr "Автосохранённые версии будут хранитьс #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Нажмите для замыкания контура" + +#: src/app/main/ui/dashboard/deleted.cljs:313 +msgid "dashboard.clear-trash-button" +msgstr "Очистить корзину" + +#: src/app/main/ui/dashboard/deleted.cljs:262 +msgid "dashboard.delete-all-forever-confirmation.description" +msgstr "" +"Вы уверены, что хотите навсегда удалить все удаленные проекты и файлы? Это " +"необратимое действие." + +#: src/app/main/ui/dashboard/file_menu.cljs:221 +msgid "dashboard.delete-file-forever-confirmation.description" +msgstr "Вы уверены, что хотите навсегда удалить %s? Это необратимое действие." + +#: src/app/main/data/dashboard.cljs:778 +msgid "dashboard.delete-files-success-notification" +msgstr "%s файлов успешно удалено." + +#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +msgid "dashboard.delete-forever-confirmation.title" +msgstr "Удалить навсегда" + +#: src/app/main/ui/dashboard/deleted.cljs:85 +msgid "dashboard.delete-project-button" +msgstr "Удалить проект" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"Вы уверены, что хотите удалить проект %s навсегда? Вы собираетесь удалить " +"его и все находящиеся в нем файлы. Это необратимое действие." + +#: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 +msgid "dashboard.delete-success-notification" +msgstr "%s успешно удален." + +#: src/app/main/ui/dashboard/deleted.cljs:327 +msgid "dashboard.deleted.empty-state-description" +msgstr "Ваша корзина пуста. Удаленные файлы и проекты появятся здесь." + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "Будет удален %s" + +#, unused +msgid "dashboard.errors.error-on-delete-file" +msgstr "Произошла ошибка во время удаления файла %s." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "Произошла ошибка во время удаления файлов." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "Произошла ошибка во время удаления проекта %s." + +#: src/app/main/data/dashboard.cljs:909, src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "Произошла ошибка во время восстановления файла %s." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "Произошла ошибка во время восстановления файлов." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "Произошла ошибка во время восстановления проекта %s и его файлов." + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "Удаленный файл" +msgstr[1] "Удаленный файлы" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "Восстановить файл" +msgstr[1] "Восстановить файлы" + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "Удаление файлов…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "Восстановление файлов…" + +#: src/app/main/ui/dashboard/deleted.cljs:274 +msgid "dashboard.restore-all-confirmation.description" +msgstr "" +"Вы собираетесь восстановить все ваши проекты и файлы. Это может занять " +"некоторое время." + +#: src/app/main/ui/dashboard/deleted.cljs:273 +msgid "dashboard.restore-all-confirmation.title" +msgstr "Восстановить все проекты и файлы" + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "Восстановить все" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "файлы %s были успешно восстановлены." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "Восстановить проект" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "Вы собираетесь восстановить проект %s и все файлы в нем." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "Восстановить проект" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s был успешно восстановлен." + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "Удаленные файлы останутся в корзине в течение" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s дней. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "После этого они будут удалены навсегда." + +#: src/app/main/ui/comments.cljs:530 +msgid "comments.mentions.not-found" +msgstr "Людей не найдено для @%s" + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "" +"Если вы передумаете, вы можете восстановить их или удалить навсегда через " +"меню каждого файла." + +#: src/app/main/errors.cljs:305 +msgid "errors.deprecated.contact.after" +msgstr "чтобы мы могли вам помочь." + +#: src/app/main/errors.cljs:200 +#, unused +msgid "errors.internal-assertion-error" +msgstr "Ошибка внутренней сертификации" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Неожиданная ошибка: %s" + +#: src/app/main/ui/static.cljs:314 +#, fuzzy +msgid "errors.webgl-context-lost.main-message" +msgstr "Упс! Контекст холста был потерян" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "1 приглашение выбрано" +msgstr[1] "%s приглашений выбрано" + +#: src/app/main/ui/workspace/libraries.cljs:100, src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "1 цвет" +msgstr[1] "%s цветов" + +#: src/app/main/ui/workspace/libraries.cljs:94, src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "1 компонент" +msgstr[1] "%s компонентов" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "Удаление идет медленнее ожидаемого" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "Восстановление идет неожиданно медленно" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL прекратил работу. Пожалуйста, перезагрузите страницу чтобы сбросить его" + +#: src/app/main/ui/settings/feedback.cljs:126 +msgid "feedback.penpot.link" +msgstr "" +"Если обратная связь связана с файлом или проектом, оставьте ссылку на файл " +"penpot здесь:" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "Скачать файлы" + +#: src/app/main/ui/inspect/right_sidebar.cljs:170 +msgid "inspect.color-space-label" +msgstr "Выберите цветовое пространство" + +#: src/app/main/ui/inspect/right_sidebar.cljs:238 +msgid "inspect.empty.more" +msgstr "Больше информации" + +#: src/app/main/ui/inspect/right_sidebar.cljs:110 +msgid "labels.computed" +msgstr "Вычисленные" + +#: src/app/main/ui/static.cljs:415 +msgid "labels.contact-support" +msgstr "Связаться с поддержкой" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "Удаленные" + +#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 +msgid "labels.download" +msgstr "Скачать %s" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "Что-то пошло не так." + +#: src/app/main/ui/dashboard/file_menu.cljs:208 +msgid "dashboard-restore-file-confirmation.description" +msgstr "Вы собираетесь восстановить %s." + +#: src/app/main/ui/dashboard/file_menu.cljs:207 +msgid "dashboard-restore-file-confirmation.title" +msgstr "Восстановить файл" + +#: src/app/main/ui/dashboard/templates.cljs:87 +msgid "labels.show" +msgstr "Показать" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "Переключить" + +#: src/app/main/ui/inspect/styles/style_box.cljs:25 +msgid "labels.text" +msgstr "Текст" + +#: src/app/main/data/workspace/tokens/errors.cljs:121 +msgid "labels.unknown-error" +msgstr "Неизвестная ошибка" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:204 +msgid "labels.unlock" +msgstr "Разблокировать" + +#: src/app/main/ui/inspect/right_sidebar.cljs:66, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1039 +msgid "labels.variant" +msgstr "Вариант" + +#: src/app/main/ui/inspect/styles/style_box.cljs:32 +msgid "labels.visibility" +msgstr "Видимость" + +#: src/app/main/ui/ds/product/loader.cljs:27 +msgid "loader.tips.04.message" +msgstr "Получите CSS и SVG код напрямую из ваших макетов." + +#: src/app/main/ui/ds/product/loader.cljs:26 +msgid "loader.tips.04.title" +msgstr "Экспорт в код" + +#: src/app/main/ui/ds/product/loader.cljs:31 +msgid "loader.tips.06.message" +msgstr "Вдохните жизнь в ваши идеи с помощью анимаций и переходов." + +#: src/app/main/ui/ds/product/loader.cljs:30 +msgid "loader.tips.06.title" +msgstr "Интерактивные прототипы" + +#: src/app/main/ui/ds/product/loader.cljs:34 +msgid "loader.tips.08.title" +msgstr "Горячие клавиши" + +#: src/app/main/ui/ds/product/loader.cljs:37 +msgid "loader.tips.09.message" +msgstr "Выберите тему, подходящую под ваш стиль." + +#: src/app/main/ui/ds/product/loader.cljs:36 +msgid "loader.tips.09.title" +msgstr "Темный & светлый режим" + +#: src/app/main/ui/ds/product/loader.cljs:39 +msgid "loader.tips.10.message" +msgstr "Дополните Penpot плагинами от сообщества для расширения функционала." + +#: src/app/main/ui/dashboard/team.cljs:222 +msgid "modals.invite-team-member.text" +msgstr "" +"Вы можете пригласить участников в команду, чтобы они получили доступ к этому " +"и другим файлам команды." + +#: src/app/main/ui/static.cljs:287 +msgid "not-found.desc-message.error" +msgstr "404 ошибка" + +#: src/app/main/ui/static.cljs:138 +msgid "not-found.login.free" +msgstr "" +"Penpot — бесплатный инструмент с открытым исходным кодом для совместной " +"работы дизайнеров и разработчиков" + +#: src/app/main/ui/auth/recovery_request.cljs:114 +msgid "not-found.login.sent-recovery" +msgstr "Вы направили письмо для восстановления на" + +#: src/app/main/ui/auth/recovery_request.cljs:116 +msgid "not-found.login.sent-recovery-check" +msgstr "Проверьте вашу почту и перейдите по ссылке, чтобы задать новый пароль." + +#: src/app/main/ui/static.cljs:152 +msgid "not-found.login.signup-free" +msgstr "Зарегистрироваться бесплатно" + +#: src/app/main/ui/static.cljs:153 +msgid "not-found.login.start-using" +msgstr "И начните использовать Penpot мгновенно!" + +#: src/app/main/ui/static.cljs:69 +msgid "not-found.made-with-love" +msgstr "Сделано с любовью и открытым исходным кодом" + +#: src/app/main/ui/static.cljs:248 +msgid "not-found.no-permission.already-requested.file" +msgstr "Вы уже запросили доступ к этому файлу." + +#: src/app/main/ui/dashboard/placeholder.cljs:61 +msgid "dashboard.empty-project.explore" +msgstr "Рассмотрите варианты для добавления" + +#: src/app/main/errors.cljs:214 +msgid "errors.internal-worker-error" +msgstr "Произошла ошибка в работе веб-воркера." + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:120 +msgid "inspect.attributes.image.preview" +msgstr "Предварительный просмотр заливки фигуры" + +#: src/app/main/ui/inspect/styles/style_box.cljs:68 +msgid "inspect.tabs.styles.copy-shorthand" +msgstr "Копировать CSS-сокращение в буфер обмена" + +#: src/app/main/ui/dashboard/sidebar.cljs:1125 +msgid "labels.community-contributions" +msgstr "Сообщество и содействие" + +#: src/app/main/ui/static.cljs:67 +msgid "labels.copyright-period" +msgstr "Kaleidos © 2019–по настоящее время" + +#: src/app/main/ui/static.cljs:406 +msgid "labels.internal-error.desc-message-second" +msgstr "" +"Повторите попытку или свяжитесь с технической поддержкой, чтобы сообщить об " +"ошибке." + +#: src/app/main/ui/dashboard/sidebar.cljs:893 +msgid "labels.learning-center" +msgstr "Центр обучения" + +#: src/app/main/ui/comments.cljs:581 +msgid "labels.mention" +msgstr "Отметить" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:210 +msgid "labels.lock" +msgstr "Заблокировать" + +#: src/app/main/ui/static.cljs:61, src/app/main/ui/static.cljs:137 +msgid "labels.login" +msgstr "Логин" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:631 +msgid "labels.mixed-values" +msgstr "Смешать" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Создать новую организацию" + +#: src/app/main/ui/dashboard/team.cljs:739 +msgid "labels.no-invitations-gather-people" +msgstr "Объединяйтесь и творите вместе." + +#: src/app/main/ui/comments.cljs:911, src/app/main/ui/comments.cljs:976, src/app/main/ui/workspace/palette.cljs:199, src/app/main/ui/workspace/sidebar/options/menus/blur.cljs:107, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:906, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:155, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:213, src/app/main/ui/workspace/sidebar/options/menus/frame_grid.cljs:294, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:402, src/app/main/ui/workspace/sidebar/options/menus/layout_container.cljs:1031, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:316, src/app/main/ui/workspace/sidebar/options/menus/text.cljs:345, src/app/main/ui/workspace/sidebar/options/rows/shadow_row.cljs:146 +msgid "labels.options" +msgstr "Параметры" + +#: src/app/main/ui/dashboard/sidebar.cljs:899 +msgid "labels.penpot-hub" +msgstr "Хаб Penpot" + +#: src/app/main/ui/comments.cljs:680 +msgid "labels.post" +msgstr "Опубликовать" + +#: src/app/main/ui/dashboard/deleted.cljs:208 +msgid "labels.recent" +msgstr "Недавние" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:205, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:179 +msgid "labels.reference" +msgstr "Референс" + +#: src/app/main/data/common.cljs:83 +msgid "labels.refresh" +msgstr "Перезагрузить" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Обновить страницу" + +#: src/app/main/ui/comments.cljs:642 +msgid "labels.replies" +msgstr "ответы" + +#, unused +msgid "labels.ok" +msgstr "Ok" + +#: src/app/main/ui/comments.cljs:647 +msgid "labels.replies.new" +msgstr "новые ответы" + +#: src/app/main/ui/comments.cljs:641 +msgid "labels.reply" +msgstr "ответить" + +#: src/app/main/ui/comments.cljs:646 +msgid "labels.reply.new" +msgstr "новый ответ" + +#: src/app/main/ui/comments.cljs:713 +msgid "labels.reply.thread" +msgstr "Ответить" + +#: src/app/main/ui/dashboard/team.cljs:788 +msgid "labels.resend" +msgstr "Отправить повторно" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:87, src/app/main/ui/workspace/sidebar/versions.cljs:197 +msgid "labels.restore" +msgstr "Восстановить" + +#: src/app/main/ui/workspace/tokens/sidebar.cljs:75 +msgid "labels.sets" +msgstr "Наборы" + +#: src/app/main/ui/inspect/styles/style_box.cljs:27, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:229 +msgid "labels.shadow" +msgstr "Тень" + +#: src/app/main/ui/inspect/styles/style_box.cljs:24, src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:46 +msgid "labels.stroke" +msgstr "Обводка" + +#: src/app/main/ui/inspect/styles/style_box.cljs:33 +#, fuzzy +msgid "labels.svg" +msgstr "SVG" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +msgid "labels.typography" +msgstr "Типографика" + +#: src/app/main/ui/dashboard/sidebar.cljs:967 +msgid "labels.version-notes" +msgstr "Примечания к версии %s" + +#: src/app/main/ui/ds/product/loader.cljs:20 +msgid "loader.tips.01.title" +msgstr "Переиспользуемые компоненты" + +#: src/app/main/ui/ds/product/loader.cljs:23 +msgid "loader.tips.02.message" +msgstr "Работайте с командой в реальном времени, делитесь отзывами мгновенно." + +#: src/app/main/ui/ds/product/loader.cljs:22 +msgid "loader.tips.02.title" +msgstr "Совместная работа в реальном времени" diff --git a/frontend/translations/sr.po b/frontend/translations/sr.po index aeea104729..325ad703c3 100644 --- a/frontend/translations/sr.po +++ b/frontend/translations/sr.po @@ -2495,10 +2495,6 @@ msgstr "" msgid "modals.update-remote-component.message" msgstr "Ажурирајте компоненту у дељеној библиотеци" -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "Доступна је нова верзија, молимо Вас да освежите страницу" - #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" msgstr "Позивница је успешно послата" diff --git a/frontend/translations/sv.po b/frontend/translations/sv.po index c55a2c8824..a25e459c0b 100644 --- a/frontend/translations/sv.po +++ b/frontend/translations/sv.po @@ -201,7 +201,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "...varumärkesbyggande, illustrationer, marknadsföringsmaterial, etc." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Denna token existerar inte eller har raderats." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1183,7 +1183,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Öppna token-lista" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Lösgör token" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 @@ -3501,7 +3501,7 @@ msgstr "" #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "En ny version är tillgänglig, uppdatera sidan" +msgstr "En ny version är tillgänglig." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -4827,11 +4827,11 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Tack för att du valt Penpot %s-abonnemanget!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Lycka till med ditt abonnemang!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Du är %!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -7567,7 +7567,7 @@ msgid "workspace.tokens.color" msgstr "Färg" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Radhöjden beror på teckenstorleken. Lägg till en teckenstorlek för att få " "det uträknade värdet." @@ -7617,7 +7617,7 @@ msgid "workspace.tokens.edit-token" msgstr "Redigera %s token" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Token-värdet kan inte vara tomt" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7625,7 +7625,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Ange %s tokennamn" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Importeringsfel: Kunde inte tolka JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7689,7 +7689,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "Importera %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Importeringsfel:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7746,58 +7746,58 @@ msgid "workspace.tokens.individual-tokens" msgstr "Använd individuella tokens" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Ogiltigt färgvärde: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Ogiltigt token-värde: du kan bara referera till en teckensnittsfamiljs-token" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Ogiltigt värde för typsnittsvikt: använd numeriska värden (100–950) eller " "standardnamn (smal, lätt, normal, fet, etc.) eventuellt följt av 'Kursiv'" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Importeringsfel: Ogiltig token-data i JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Importeringsfel: Ogiltigt token-namn i JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" är inte ett giltigt token-namn.\n" "Token-namn ska endast innehålla bokstäver och siffror separerade med . " "tecken och får inte börja med ett $-tecken." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Ogiltig skuggningstyp: endast 'innerShadow' eller 'dropShadow' accepteras" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Ogiltigt token-värde: endast ingen, versaler, gemener eller versalisera " "accepteras" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "Ogiltigt token-värde: endast ingen, understruken och genomstruken accepteras" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Ogiltigt värde: måste referera till en sammansatt skuggnings-token." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Ogiltigt värde: måste referera till en sammansatt typografi-token." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Ogiltigt token-värde: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7837,7 +7837,7 @@ msgid "workspace.tokens.min-size" msgstr "Min. storlek" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Saknade token-referenser: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7877,7 +7877,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Du har för närvarande inga teman." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Inga tokens, uppsättningar eller teman hittades i den här filen." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7885,11 +7885,11 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s aktiva uppsättningar" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Ogiltigt token-värde. Det uträknade värdet är för stort: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "" "Opaciteten måste vara mellan 0 och 100 % eller 0 och 1 (t.ex. 50 % eller " "0,5)." @@ -7938,7 +7938,7 @@ msgid "workspace.tokens.select-set" msgstr "Välj uppsättning." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token har självreferens" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7977,7 +7977,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Oskärpa" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Skuggningsoskärpan måste vara större än eller lika med 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -7998,7 +7998,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Spridning" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Skuggningsspridning måste vara större än eller lika med 0." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8019,7 +8019,7 @@ msgid "workspace.tokens.size" msgstr "Storlek" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Streckbredden måste vara större än eller lika med 0." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8071,10 +8071,6 @@ msgstr "Typsnittsfamilj eller lista över typsnitt separerade med kommatecken (, msgid "workspace.tokens.token-name" msgstr "Namn" -#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 -msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "En token finns redan på denna sökväg: %s" - #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" msgstr "Namnet måste innehålla minst 1 tecken" @@ -8107,13 +8103,13 @@ msgstr "TOKEN - %s" msgid "workspace.tokens.tools" msgstr "Verktyg" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "Importen lyckades. Vissa tokens inkluderades ej." +msgstr "Importen lyckades, men vissa tokens hoppades över eftersom de använder $type-värden som inte stöds. Expandera detaljerna för att se vilka tokens som påverkades." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Typen '%s' stödjs ej (%s)\n" +msgstr "Typen '%s' stöds ej (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8124,11 +8120,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Värdet är inte giltigt" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Ogiltigt värde: % är inte tillåtet." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Ogiltigt värde: Enheter är ej tillåtna." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 diff --git a/frontend/translations/tr.po b/frontend/translations/tr.po index 96a7594ff9..25dd416b81 100644 --- a/frontend/translations/tr.po +++ b/frontend/translations/tr.po @@ -1,15 +1,15 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" -"Last-Translator: Anonymous \n" -"Language-Team: Turkish " -"\n" +"PO-Revision-Date: 2026-02-17 10:10+0000\n" +"Last-Translator: Oğuz Ersen \n" +"Language-Team: Turkish \n" "Language: tr\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=2; plural=n != 1;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.16\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -202,7 +202,7 @@ msgid "branding-illustrations-marketing-pieces" msgstr "...marka çalışması, çizimler, pazarlama materyalleri, vb." #: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 -msgid "color-row.token-color-row.deleted-token" +msgid "options.deleted-token" msgstr "Bu token yok veya silindi." #: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 @@ -1352,7 +1352,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "Token listesini aç" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "Tokeni ayır" #: src/app/main/data/auth.cljs:339 @@ -2091,7 +2091,6 @@ msgid "inspect.tabs.styles.active-themes" msgstr "Etkin temalar" #: src/app/main/ui/inspect/styles/style_box.cljs:68 -#, fuzzy msgid "inspect.tabs.styles.copy-shorthand" msgstr "CSS kısaltmasını panoya kopyala" @@ -2934,7 +2933,7 @@ msgstr "Sürüm %s notları" #: src/app/main/ui/workspace/sidebar/sitemap.cljs:271 msgid "labels.view-only" -msgstr "YALNIZCA GÖRÜNTÜLE" +msgstr "Yalnızca görüntüle" #: src/app/main/ui/dashboard/team.cljs:131, src/app/main/ui/dashboard/team.cljs:314, src/app/main/ui/dashboard/team.cljs:567, src/app/main/ui/dashboard/team.cljs:603, src/app/main/ui/onboarding/team_choice.cljs:56 msgid "labels.viewer" @@ -3673,7 +3672,7 @@ msgstr "Bakım arası: 5 dakika içinde kısa bir bakım için kapalı olacağı #: src/app/main/data/common.cljs:82 msgid "notifications.by-code.upgrade-version" -msgstr "Yeni bir sürüm mevcut, lütfen sayfayı yenileyin" +msgstr "Yeni bir sürüm mevcut." #: src/app/main/ui/dashboard/team.cljs:825 msgid "notifications.invitation-deleted" @@ -5002,15 +5001,14 @@ msgid "subscription.settings.success.dialog.thanks" msgstr "Penpot %s planını seçtiğiniz için teşekkür ederiz!" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Planınızın tadını çıkarın!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "%s oldunuz!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" msgstr "Bu planla bizi şu zamandan beri destekliyorsunuz: %s" @@ -7814,7 +7812,7 @@ msgid "workspace.tokens.color" msgstr "Renk" #: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 -msgid "workspace.tokens.composite-line-height-needs-font-size" +msgid "errors.tokens.composite-line-height-needs-font-size" msgstr "" "Satır yüksekliği yazı tipi boyutuna bağlıdır. Çözülen değeri elde etmek " "için bir yazı tipi boyutu ekleyin." @@ -7864,7 +7862,7 @@ msgid "workspace.tokens.edit-token" msgstr "%s tokenini düzenle" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Token değeri boş olamaz" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7872,7 +7870,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "%s token adını gir" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "İçe Aktarma Hatası: JSON ayrıştırılamadı." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7936,7 +7934,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "%s içe aktar" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "İçe Aktarma Hatası:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -7993,61 +7991,61 @@ msgid "workspace.tokens.individual-tokens" msgstr "Bireysel tokenler kullan" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Geçersiz renk değeri: %s" #: src/app/main/data/workspace/tokens/errors.cljs:93 -msgid "workspace.tokens.invalid-font-family-token-value" +msgid "errors.tokens.invalid-font-family-token-value" msgstr "Geçersiz token değeri: yalnızca font-family tokenine referans verebilirsiniz" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "" "Geçersiz yazı tipi kalınlığı değeri: sayısal değerler (100-950) veya " "standart adlar (ince, hafif, normal, kalın vb.) kullanın, isteğe bağlı " "olarak ardından 'İtalik' ekleyin" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "İçe Aktarma Hatası: JSON'da geçersiz token verisi." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "İçe Aktarma Hatası: JSON'da geçersiz token adı." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" geçerli bir token adı değil.\n" "Token adları yalnızca . karakterleriyle ayrılan harfler ve rakamlar " "içermeli ve $ işaretiyle başlamamalıdır." #: src/app/main/data/workspace/tokens/errors.cljs:105 -msgid "workspace.tokens.invalid-shadow-type-token-value" +msgid "errors.tokens.invalid-shadow-type-token-value" msgstr "Geçersiz gölge türü: yalnızca 'innerShadow' veya 'dropShadow' kabul edilir" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "" "Geçersiz token değeri: yalnızca none, Uppercase, Lowercase veya Capitalize " "kabul edilir" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "" "Geçersiz token değeri: yalnızca none, underline ve strike-through kabul " "edilir" #: src/app/main/data/workspace/tokens/errors.cljs:117 -msgid "workspace.tokens.invalid-token-value-shadow" +msgid "errors.tokens.invalid-token-value-shadow" msgstr "Geçersiz değer: bileşik gölge tokenine referans vermelidir." #: src/app/main/data/workspace/tokens/errors.cljs:97 -msgid "workspace.tokens.invalid-token-value-typography" +msgid "errors.tokens.invalid-token-value-typography" msgstr "Geçersiz değer: bileşik tipografi tokenine referans vermelidir." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Geçersiz token değeri: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -8087,7 +8085,7 @@ msgid "workspace.tokens.min-size" msgstr "Asgari boyut" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Eksik token referansları: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -8139,7 +8137,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "Şu anda hiç temanız yok." #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "Bu dosyada token, küme veya tema bulunamadı." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -8147,15 +8145,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s etkin küme" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Geçersiz token değeri. Çözülen değer çok büyük: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Opaklık 0 ile %100 veya 0 ile 1 arasında olmalıdır (örneğin %50 veya 0.5)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "Orijinal değer: %s" @@ -8190,7 +8187,6 @@ msgid "workspace.tokens.remapping-in-progress" msgstr "Token referansları yeniden eşleniyor..." #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "Çözülen değer: %s" @@ -8203,7 +8199,7 @@ msgid "workspace.tokens.select-set" msgstr "Küme seç." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Tokenin kendine referansı var" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -8244,7 +8240,7 @@ msgid "workspace.tokens.shadow-blur" msgstr "Bulanıklık" #: src/app/main/data/workspace/tokens/errors.cljs:109 -msgid "workspace.tokens.shadow-blur-range" +msgid "errors.tokens.shadow-blur-range" msgstr "Gölge bulanıklığı 0 veya 0'dan büyük olmalıdır." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 @@ -8265,7 +8261,7 @@ msgid "workspace.tokens.shadow-spread" msgstr "Yayılım" #: src/app/main/data/workspace/tokens/errors.cljs:113 -msgid "workspace.tokens.shadow-spread-range" +msgid "errors.tokens.shadow-spread-range" msgstr "Gölge yayılımı 0 veya 0'dan büyük olmalıdır." #: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 @@ -8295,7 +8291,7 @@ msgid "workspace.tokens.size" msgstr "Boyut" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Çerçeve genişliği 0'dan büyük veya 0 olmalıdır." #: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 @@ -8337,7 +8333,6 @@ msgid "workspace.tokens.themes-list" msgstr "Tema listesi" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "Açıklama" @@ -8357,10 +8352,6 @@ msgstr "Yazı tipi ailesi veya virgülle (,) ayrılan yazı tipi listesi" msgid "workspace.tokens.token-name" msgstr "Ad" -#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 -msgid "workspace.tokens.token-name-duplication-validation-error" -msgstr "Bu yolda zaten bir token var: %s" - #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 msgid "workspace.tokens.token-name-length-validation-error" msgstr "Ad en az 1 karakterden oluşmalıdır" @@ -8393,13 +8384,13 @@ msgstr "TOKENLER - %s" msgid "workspace.tokens.tools" msgstr "Araçlar" -#: src/app/main/data/workspace/tokens/import_export.cljs:46 +#: src/app/main/data/workspace/tokens/import_export.cljs:50 msgid "workspace.tokens.unknown-token-type-message" -msgstr "İçe aktarma başarılı oldu. Bazı tokenler dahil edilmedi." +msgstr "İçe aktarma başarılı oldu, ancak bazı tokenler desteklenmeyen $type değerleri kullandıkları için atlandı. Hangi tokenlerin etkilendiğini görmek için ayrıntıları genişletin." -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "'%s' türü desteklenmiyor (%s)\n" +msgstr "'%s' türü desteklenmiyor (%s):" #: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 msgid "workspace.tokens.use-reference" @@ -8410,11 +8401,11 @@ msgid "workspace.tokens.value-not-valid" msgstr "Değer geçerli değil" #: src/app/main/data/workspace/tokens/errors.cljs:69 -msgid "workspace.tokens.value-with-percent" +msgid "errors.tokens.value-with-percent" msgstr "Geçersiz değer: % izin verilmiyor." #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Geçersiz değer: Birimlere izin verilmiyor." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -8746,3 +8737,63 @@ msgstr "Otomatik kaydedilen sürümler %s gün boyunca saklanacaktır." #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Yolu kapatmak için tıklayın" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Yeni organizasyon oluştur" + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Beklenmeyen hata: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL çalışmayı durdurdu. Sıfırlamak için lütfen sayfayı yeniden yükleyin" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "Oops! Tuval içeriği kayboldu" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Sayfayı yeniden yükle" + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Hata ayıklama araçları" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Eksik referans" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Yeniden eşlenmesin" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Tokenleri yeniden eşle" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "`%s` kullanan tüm tokenler `%s` olarak yeniden eşlensin mi?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "" +"Bu, eski token adını kullanan tüm katmanları ve referansları değiştirecektir." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Bu işlem biraz zaman alabilir." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "" +"Bu tokenin adını değiştirmek, eski adına yapılan tüm referansları bozacaktır" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Hata ayıklama araçları" diff --git a/frontend/translations/ukr_UA.po b/frontend/translations/ukr_UA.po index 2e7b736c7f..db6e7f5370 100644 --- a/frontend/translations/ukr_UA.po +++ b/frontend/translations/ukr_UA.po @@ -1,16 +1,16 @@ msgid "" msgstr "" -"PO-Revision-Date: 2026-02-16 08:35+0000\n" +"PO-Revision-Date: 2026-03-09 20:09+0000\n" "Last-Translator: Denys Kisil \n" -"Language-Team: Ukrainian " -"\n" +"Language-Team: Ukrainian \n" "Language: ukr_UA\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=utf-8\n" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && " "n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n" -"X-Generator: Weblate 5.16-dev\n" +"X-Generator: Weblate 5.17-dev\n" #: src/app/main/ui/auth/register.cljs:215, src/app/main/ui/static.cljs:158, src/app/main/ui/viewer/login.cljs:100 msgid "auth.already-have-account" @@ -652,7 +652,7 @@ msgstr "" #: src/app/main/ui/dashboard.cljs:259 msgid "dashboard.import.bad-url" -msgstr "Імпортування не вдалось. Посилання шаблону неправильне" +msgstr "Імпортування не вдалось. URL шаблону неправильне" #: src/app/main/ui/dashboard.cljs:241 #, unused @@ -857,7 +857,7 @@ msgstr "Пришпилити/відшпилити" #: src/app/main/ui/dashboard.cljs:223 msgid "dashboard.plugins.bad-url" -msgstr "Посилання плагіну неправильне" +msgstr "Недійсний URL плагіну" #: src/app/main/ui/dashboard.cljs:221 msgid "dashboard.plugins.parse-error" @@ -1284,7 +1284,7 @@ msgstr "Під час роботи веб-виконавця сталась по #: src/app/main/ui/components/color_input.cljs:51 msgid "errors.invalid-color" -msgstr "Хибний колір" +msgstr "Недійсний колір" #: src/app/util/forms.cljs:35, src/app/util/forms.cljs:89 msgid "errors.invalid-data" @@ -1310,7 +1310,7 @@ msgstr "Помилковий текст" #: src/app/main/ui/static.cljs:74 msgid "errors.invite-invalid" -msgstr "Хибне запрошення" +msgstr "Недійсне запрошення" #: src/app/main/ui/static.cljs:75 msgid "errors.invite-invalid.info" @@ -1459,7 +1459,7 @@ msgstr "Помилка під'єднання, адреса недосяжна" #: src/app/main/ui/dashboard/team.cljs:1045 msgid "errors.webhooks.invalid-uri" -msgstr "Посилання не пройшло перевірку." +msgstr "URL не пройшов перевірку." #: src/app/main/ui/dashboard/team.cljs:1204 msgid "errors.webhooks.last-delivery" @@ -1687,7 +1687,7 @@ msgstr "Типографія" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:308 msgid "inspect.attributes.typography.font-family" -msgstr "Сімейство шрифта" +msgstr "Сімейство Шрифта" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:326, src/app/main/ui/workspace/sidebar/options/menus/typography.cljs:332 msgid "inspect.attributes.typography.font-size" @@ -2061,7 +2061,7 @@ msgstr "Фіґма" #: src/app/main/ui/dashboard/fonts.cljs:432 msgid "labels.font-family" -msgstr "Сімейство шрифтів" +msgstr "Сімейство Шрифтів" #, unused msgid "labels.font-providers" @@ -2745,7 +2745,7 @@ msgstr "Створити вебхук" #: src/app/main/ui/dashboard/team.cljs:1103 msgid "modals.create-webhook.url.label" -msgstr "Посилання на Payload" +msgstr "Адреса пейлоду" #: src/app/main/ui/dashboard/team.cljs:1104 msgid "modals.create-webhook.url.placeholder" @@ -3262,10 +3262,6 @@ msgstr "" "Перерва на технічне обслуговування: ми закінчимо технічне обслуговування " "протягом 5 хвилин." -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "Нова версія доступна, будь ласка, оновіть сторінку" - #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" msgstr "Запрощення успішно надіслано" @@ -4412,7 +4408,7 @@ msgstr "" #: src/app/main/ui/settings/subscription.cljs:259 msgid "subscription.settings.management.dialog.payment-explanation" -msgstr "(Платіж не буде зроблено)" +msgstr "Плата опісля пробного терміну. Наразі кредитна карта не потрібна." #: src/app/main/ui/settings/subscription.cljs:252, src/app/main/ui/settings/subscription.cljs:256 #, markdown @@ -4466,17 +4462,16 @@ msgstr "" "що у подробицях облікового запису." #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "Насолоджуйтесь планом!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "Ви %s!" #: src/app/main/ui/settings/subscription.cljs:526 -#, fuzzy msgid "subscription.settings.support-us-since" -msgstr "Ви підтримуєте нас за цим планом з %s" +msgstr "Ви підтримуєте нас цим планом з: %s" #: src/app/main/ui/settings/subscription.cljs:558, src/app/main/ui/settings/subscription.cljs:574 msgid "subscription.settings.try-it-free" @@ -4806,7 +4801,7 @@ msgstr "Сортувати" #: src/app/main/ui/dashboard/grid.cljs:165, src/app/main/ui/dashboard/grid.cljs:220, src/app/main/ui/workspace/sidebar/assets/typographies.cljs:396, src/app/main/ui/workspace/sidebar/assets.cljs:161 msgid "workspace.assets.typography" -msgstr "Типографіка" +msgstr "Типографіки" #: src/app/main/ui/workspace/sidebar/assets/typographies.cljs:404 msgid "workspace.assets.typography.add-typography" @@ -5434,7 +5429,7 @@ msgstr "Деякі з цих варіантів мають помилкові і #: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:433 msgid "workspace.options.component.variant.malformed.structure.example" -msgstr "[property] = [value], [property] = [value]" +msgstr "[property] = [value], [property] = [value]" #: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:431 msgid "workspace.options.component.variant.malformed.structure.title" @@ -5825,7 +5820,7 @@ msgstr "Вікрити накладення: %s" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:61, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:351 msgid "workspace.options.interaction-open-url" -msgstr "Відкрити посилання" +msgstr "Перейти за URL" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs #, unused @@ -5898,7 +5893,7 @@ msgstr "Подразник" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:469 msgid "workspace.options.interaction-url" -msgstr "Посилання" +msgstr "URL-адреса" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:39, src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:339 msgid "workspace.options.interaction-while-hovering" @@ -6492,7 +6487,7 @@ msgstr "Підкреслення (%s)" #: src/app/main/ui/workspace/sidebar/options/menus/typography.cljs #, unused msgid "workspace.options.text-options.uppercase" -msgstr "ВЕРХНІЙ РЕГІСТР" +msgstr "Верхній Регістр" #: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:788 msgid "workspace.options.use-play-button" @@ -6575,7 +6570,7 @@ msgstr "Щоб використовувати цей плагін, Ви маєт #: src/app/main/ui/workspace/plugins.cljs:189 msgid "workspace.plugins.error.url" -msgstr "Плагін не існує або посилання на нього неправильне." +msgstr "Плагін не існує або його URL недійсний." #: src/app/main/ui/workspace/plugins.cljs:185 msgid "workspace.plugins.install" @@ -6657,7 +6652,7 @@ msgstr "Видалити плагін" #: src/app/main/ui/workspace/plugins.cljs:180 msgid "workspace.plugins.search-placeholder" -msgstr "Вкажіть посилання на плагін" +msgstr "Вкажіть URL плагіну" #, unused msgid "workspace.plugins.success" @@ -7110,7 +7105,7 @@ msgid "workspace.tokens.edit-themes" msgstr "Редагувати теми" #: src/app/main/data/workspace/tokens/errors.cljs:41 -msgid "workspace.tokens.empty-input" +msgid "errors.tokens.empty-input" msgstr "Значення токену не може бути порожнім" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:241 @@ -7118,7 +7113,7 @@ msgid "workspace.tokens.enter-token-name" msgstr "Вкажіть %s ім'я токену" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "Помилка імпорту: Неможливо обробити JSON." #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -7164,7 +7159,7 @@ msgid "workspace.tokens.grouping-set-alert" msgstr "Групування наборів токенів поки не підтримується." #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "Помилка імпорту:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:241 @@ -7201,26 +7196,26 @@ msgstr "" "області перегляду" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "Помилкове значення кольору: %s" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "Помилка імпорту: Помилкові дани токену в JSON." #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "Помилка імпорту: Помилкове імʼя токену в JSON." #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "\"%s\" це не дійсне імʼя токену.\n" "Імена токену можуть містити в собі літери та цифри, розділені крапкою та не " "повинні починатись з $." #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "Помилкове значення токену: %s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7252,7 +7247,7 @@ msgid "workspace.tokens.min-size" msgstr "Мін. розмір" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "Відсутні посилання на токен: " #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:135 @@ -7292,15 +7287,14 @@ msgid "workspace.tokens.num-active-sets" msgstr "%s активних наборів" #: src/app/main/data/workspace/tokens/errors.cljs:53 -msgid "workspace.tokens.number-too-large" +msgid "errors.tokens.number-too-large" msgstr "Помилкове значення токену. Отримане значення завелике: %s" #: src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/warnings.cljs:15 -msgid "workspace.tokens.opacity-range" +msgid "errors.tokens.opacity-range" msgstr "Непрозорість має бути між 0 та 100% або ж між 0 та 1 (де 0.5 - 50%)." #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:121 -#, fuzzy msgid "workspace.tokens.original-value" msgstr "Початкове значення: %s" @@ -7322,7 +7316,6 @@ msgid "workspace.tokens.reference-error" msgstr "Помилка посилання: " #: src/app/main/data/workspace/tokens/warnings.cljs:15, src/app/main/data/workspace/tokens/warnings.cljs:19, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:56, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:84, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:103, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:285, src/app/main/ui/workspace/tokens/management/forms/controls/color_input.cljs:459, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:176, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:311, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:251, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:364, src/app/main/ui/workspace/tokens/management/forms/controls/input.cljs:465, src/app/main/ui/workspace/tokens/management/token_pill.cljs:122 -#, fuzzy msgid "workspace.tokens.resolved-value" msgstr "Отримане значення: %s" @@ -7335,7 +7328,7 @@ msgid "workspace.tokens.select-set" msgstr "Обрати набір." #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Токен має самопосилання" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 @@ -7370,7 +7363,7 @@ msgid "workspace.tokens.size" msgstr "Розмір" #: src/app/main/data/workspace/tokens/errors.cljs:77, src/app/main/data/workspace/tokens/warnings.cljs:19 -msgid "workspace.tokens.stroke-width-range" +msgid "errors.tokens.stroke-width-range" msgstr "Ширина обведення має бути більшим ніж 0 або дорівнювати 0." #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:154 @@ -7389,7 +7382,6 @@ msgid "workspace.tokens.themes-list" msgstr "Список тем" #: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:275, src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:276 -#, fuzzy msgid "workspace.tokens.token-description" msgstr "Опис" @@ -7425,16 +7417,16 @@ msgstr "ТОКЕНИ - %s" msgid "workspace.tokens.tools" msgstr "Інструменти" -#: src/app/main/data/workspace/tokens/import_export.cljs:48 +#: src/app/main/data/workspace/tokens/import_export.cljs:52 msgid "workspace.tokens.unknown-token-type-section" -msgstr "Тип \"%s\" непідтримуваний (%s)\n" +msgstr "Тип \"%s\" непідтримуваний (%s):" #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:132 msgid "workspace.tokens.value-not-valid" msgstr "Значення не є дійсним" #: src/app/main/data/workspace/tokens/errors.cljs:65 -msgid "workspace.tokens.value-with-units" +msgid "errors.tokens.value-with-units" msgstr "Помилкове значення: Одиниці не дозволені." #: src/app/main/ui/workspace/sidebar.cljs:159, src/app/main/ui/workspace/sidebar.cljs:166 @@ -7754,3 +7746,1081 @@ msgstr "Автозбережені версії зберігатимуться #, unused msgid "workspace.viewport.click-to-close-path" msgstr "Натисність щоб закінчити шлях" + +#: src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:101, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:108 +msgid "color-row.token-color-row.deleted-token" +msgstr "Даний токен не існує або був видалений." + +#: src/app/main/ui/workspace/colorpicker/color_tokens.cljs:35 +msgid "color-token.empty-state" +msgstr "" +"Відсутні токени кольорів. Перевірте активні набори/теми чи додайте нові " +"токени." + +#: src/app/main/ui/dashboard/file_menu.cljs:208 +msgid "dashboard-restore-file-confirmation.description" +msgstr "Ви збираєтесь відновити %s." + +#: src/app/main/ui/dashboard/file_menu.cljs:207 +msgid "dashboard-restore-file-confirmation.title" +msgstr "Відновити файл" + +#: src/app/main/ui/dashboard/deleted.cljs:313 +msgid "dashboard.clear-trash-button" +msgstr "Звільнити кошик" + +#: src/app/main/ui/dashboard/sidebar.cljs:347 +msgid "dashboard.create-new-org" +msgstr "Створити організацію" + +#: src/app/main/ui/dashboard/deleted.cljs:262 +msgid "dashboard.delete-all-forever-confirmation.description" +msgstr "" +"Ви впевнені що хочете назавжди позбутись видалених проєктів та файлів? Це " +"незворотня дія." + +#: src/app/main/ui/dashboard/file_menu.cljs:221 +msgid "dashboard.delete-file-forever-confirmation.description" +msgstr "Ви впевнені що хочете назавжди позбутись %s? Це незворотня дія." + +#: src/app/main/data/dashboard.cljs:778 +msgid "dashboard.delete-files-success-notification" +msgstr "%s файлів було успішно вилучено." + +#: src/app/main/ui/dashboard/deleted.cljs:51, src/app/main/ui/dashboard/deleted.cljs:53, src/app/main/ui/dashboard/deleted.cljs:261, src/app/main/ui/dashboard/deleted.cljs:263, src/app/main/ui/dashboard/file_menu.cljs:220, src/app/main/ui/dashboard/file_menu.cljs:222 +msgid "dashboard.delete-forever-confirmation.title" +msgstr "Позбутись назавжди" + +#: src/app/main/ui/dashboard/deleted.cljs:85 +msgid "dashboard.delete-project-button" +msgstr "Вилучити проєкт" + +#: src/app/main/ui/dashboard/deleted.cljs:52 +msgid "dashboard.delete-project-forever-confirmation.description" +msgstr "" +"Ви впевнені що хочете назавжди позбутись %s проєктів? Ви позбудетесь їх та " +"файлів, що в них містились, назавжди. Це незворотня дія." + +#: src/app/main/data/dashboard.cljs:777, src/app/main/data/dashboard.cljs:811 +msgid "dashboard.delete-success-notification" +msgstr "%s було успішно вилучено." + +#: src/app/main/ui/dashboard/deleted.cljs:327 +msgid "dashboard.deleted.empty-state-description" +msgstr "Ваш смітник порожній. Видалені файли та проєкти зʼявлятимуться тут." + +#: src/app/main/ui/dashboard/grid.cljs:248 +msgid "dashboard.deleted.will-be-deleted-at" +msgstr "Буде вилучено %s" + +#, unused +msgid "dashboard.errors.error-on-delete-file" +msgstr "Сталася помилка під час вилучення файлу %s." + +#: src/app/main/data/dashboard.cljs:781 +msgid "dashboard.errors.error-on-delete-files" +msgstr "Сталася помилка під час вилучення файлів." + +#: src/app/main/data/dashboard.cljs:814 +msgid "dashboard.errors.error-on-delete-project" +msgstr "Сталася помилка під час вилучення проєкту %s." + +#: src/app/main/data/dashboard.cljs:909, src/app/main/ui/dashboard/file_menu.cljs:201 +msgid "dashboard.errors.error-on-restore-file" +msgstr "Сталася помилка під час відновлення файлу %s." + +#: src/app/main/data/dashboard.cljs:910 +msgid "dashboard.errors.error-on-restore-files" +msgstr "Сталася помилка під час відновлення файлів." + +#: src/app/main/data/dashboard.cljs:942 +msgid "dashboard.errors.error-on-restoring-project" +msgstr "Сталася помилка під час відновлення проєкту %s та його файлів." + +#: src/app/main/ui/dashboard/file_menu.cljs:266 +msgid "dashboard.file-menu.delete-files-permanently-option" +msgid_plural "dashboard.file-menu.delete-files-permanently-option" +msgstr[0] "Вилучити файл" +msgstr[1] "Вилучити кільки файлів" +msgstr[2] "Вилучити файли" + +#: src/app/main/ui/dashboard/file_menu.cljs:263 +msgid "dashboard.file-menu.restore-files-option" +msgid_plural "dashboard.file-menu.restore-files-option" +msgstr[0] "Відновити файл" +msgstr[1] "Відновити кілька файлів" +msgstr[2] "Відновити файли" + +#: src/app/main/ui/dashboard/team.cljs:765 +msgid "dashboard.invitation-modal.delete" +msgstr "Ви збираєтесь вилучити запрошення до:" + +#: src/app/main/ui/dashboard/team.cljs:766 +msgid "dashboard.invitation-modal.resend" +msgstr "Ви збираєтесь перенадіслати запрошення до:" + +#: src/app/main/ui/dashboard/team.cljs:756 +msgid "dashboard.invitation-modal.title.delete-invitations" +msgstr "Вилучити запрошення" + +#: src/app/main/ui/dashboard/team.cljs:757 +msgid "dashboard.invitation-modal.title.resend-invitations" +msgstr "Перенадіслати запрошення" + +#: src/app/main/ui/dashboard/team.cljs:949 +msgid "dashboard.order-invitations-by-role" +msgstr "Сортувати за роллю" + +#: src/app/main/ui/dashboard/team.cljs:958 +msgid "dashboard.order-invitations-by-status" +msgstr "Сортувати за статусом" + +#: src/app/main/data/dashboard.cljs:722 +msgid "dashboard.progress-notification.deleting-files" +msgstr "Вилучаю файли…" + +#: src/app/main/data/dashboard.cljs:843 +msgid "dashboard.progress-notification.restoring-files" +msgstr "Відновлюю файли…" + +#: src/app/main/data/dashboard.cljs:723 +msgid "dashboard.progress-notification.slow-delete" +msgstr "Вилучення триває неочікувано довго" + +#: src/app/main/data/dashboard.cljs:844 +msgid "dashboard.progress-notification.slow-restore" +msgstr "Відновлення триває неочікувано довго" + +#: src/app/main/ui/dashboard/deleted.cljs:274 +msgid "dashboard.restore-all-confirmation.description" +msgstr "" +"Ви збираєтесь відновити усі проєкти та їх файли. Це може зайняти деякий час." + +#: src/app/main/ui/dashboard/deleted.cljs:273 +msgid "dashboard.restore-all-confirmation.title" +msgstr "Відновити усі проєкти й файли" + +#: src/app/main/ui/dashboard/deleted.cljs:308 +msgid "dashboard.restore-all-deleted-button" +msgstr "Відновити Все" + +#: src/app/main/data/dashboard.cljs:903 +msgid "dashboard.restore-files-success-notification" +msgstr "%s файлів було успішно відновлено." + +#: src/app/main/ui/dashboard/deleted.cljs:82 +msgid "dashboard.restore-project-button" +msgstr "Відновити проєкт" + +#: src/app/main/ui/dashboard/deleted.cljs:41 +msgid "dashboard.restore-project-confirmation.description" +msgstr "Ви збираєтесь відновити %s проєктів та всі їхні файли." + +#: src/app/main/ui/dashboard/deleted.cljs:40 +msgid "dashboard.restore-project-confirmation.title" +msgstr "Відновити Проєкт" + +#: src/app/main/data/dashboard.cljs:875, src/app/main/data/dashboard.cljs:902, src/app/main/data/dashboard.cljs:939, src/app/main/ui/dashboard/file_menu.cljs:198 +msgid "dashboard.restore-success-notification" +msgstr "%s було успішно відновлено." + +#: src/app/main/ui/dashboard/deleted.cljs:298 +msgid "dashboard.trash-info-text-part1" +msgstr "Вилучені файли лишатимуться у смітнику на" + +#: src/app/main/ui/dashboard/deleted.cljs:300 +msgid "dashboard.trash-info-text-part2" +msgstr " %s днів. " + +#: src/app/main/ui/dashboard/deleted.cljs:301 +msgid "dashboard.trash-info-text-part3" +msgstr "Після цього, ви позбудетесь від них назавжди." + +#: src/app/main/ui/dashboard/deleted.cljs:303 +msgid "dashboard.trash-info-text-part4" +msgstr "" +"Якщо вирішете інакше, Ви маєте змогу відновити їх або позбутись їх назавжди " +"у меню кожного з них." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:98 +msgid "ds.inputs.numeric-input.no-applicable-tokens" +msgstr "У активних наборах й темах не знайдено застосовних токенів." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:99 +msgid "ds.inputs.numeric-input.no-matches" +msgstr "Збігів не виявлено." + +#: src/app/main/ui/ds/controls/numeric_input.cljs:652, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:141 +msgid "ds.inputs.numeric-input.open-token-list-dropdown" +msgstr "Відкрити список токенів" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 +msgid "ds.inputs.token-field.detach-token" +msgstr "Відʼєднати токен" + +#: src/app/main/ui/ds/controls/utilities/token_field.cljs:43, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:99, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:106 +msgid "ds.inputs.token-field.no-active-token-option" +msgstr "" +"Цей токен не міститься в жодному з активних наборів або має недійсне " +"значення." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:296, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:240 +msgid "errors.field-max-length" +msgstr "Повинно містити щонайменше %s символів." + +#: src/app/main/errors.cljs:105 +msgid "errors.unexpected-exception" +msgstr "Неочікувана помилка: %s" + +#: src/app/main/ui/static.cljs:315 +msgid "errors.webgl-context-lost.desc-message" +msgstr "" +"WebGL припинив працювати. Просимо Вас перезавантажити сторінку, щоб скинути " +"його" + +#: src/app/main/ui/static.cljs:314 +msgid "errors.webgl-context-lost.main-message" +msgstr "От халепа! Полотно загубилось" + +#: src/app/main/ui/settings/feedback.cljs:122 +msgid "feedback.description-placeholder" +msgstr "Просимо Вас описати причину відгуку" + +#: src/app/main/ui/settings/feedback.cljs:143 +msgid "feedback.other-ways-contact" +msgstr "Інші способи звʼязатись з нами" + +#: src/app/main/ui/settings/feedback.cljs:126 +msgid "feedback.penpot.link" +msgstr "" +"Якщо відгук якось повʼязаний з файлом, чи проєктом, вкажіть посилання на " +"нього сюди:" + +#: src/app/main/ui/settings/feedback.cljs:101 +msgid "feedback.title-contact-us" +msgstr "Звʼяжіться з нами" + +#: src/app/main/ui/settings/feedback.cljs:110, src/app/main/ui/settings/feedback.cljs:111 +msgid "feedback.type" +msgstr "Тип" + +#: src/app/main/ui/settings/feedback.cljs:115 +msgid "feedback.type.doubt" +msgstr "Сумнів" + +#: src/app/main/ui/settings/feedback.cljs:113 +msgid "feedback.type.idea" +msgstr "Ідея" + +#: src/app/main/ui/settings/feedback.cljs:114 +msgid "feedback.type.issue" +msgstr "Вада" + +#: src/app/main/ui/exports/files.cljs:124 +msgid "files-download-modal.title" +msgstr "Вивантажити файли" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:120 +msgid "inspect.attributes.image.preview" +msgstr "Перегляд зображення заповнення фігури" + +#: src/app/main/ui/inspect/right_sidebar.cljs:170 +msgid "inspect.color-space-label" +msgstr "Обрати простір кольорів" + +#: src/app/main/ui/inspect/right_sidebar.cljs:238 +msgid "inspect.empty.more" +msgstr "Більше інфо" + +#: src/app/main/ui/inspect/right_sidebar.cljs:166 +msgid "inspect.layer-info" +msgstr "Дані шару" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:26 +msgid "inspect.tabs.styles.active-sets" +msgstr "Діючі набори" + +#: src/app/main/ui/inspect/styles/panels/tokens_panel.cljs:21 +msgid "inspect.tabs.styles.active-themes" +msgstr "Діючі теми" + +#: src/app/main/ui/inspect/styles/style_box.cljs:68 +msgid "inspect.tabs.styles.copy-shorthand" +msgstr "Копіювати скоропис CSS до буферу обміну" + +#: src/app/main/ui/inspect/styles/property_detail_copiable.cljs:51 +msgid "inspect.tabs.styles.copy-to-clipboard" +msgstr "Копіювати до буферу обміну" + +#: src/app/main/ui/inspect/styles/style_box.cljs:22 +#, unused +msgid "inspect.tabs.styles.geometry-panel" +msgstr "Розміри й Позиції" + +#: src/app/main/ui/inspect/styles/style_box.cljs:60, src/app/main/ui/workspace/colorpicker/color_tokens.cljs:178 +msgid "inspect.tabs.styles.toggle-style" +msgstr "Перемкнути панель %s" + +#: src/app/main/ui/inspect/styles/style_box.cljs:21 +msgid "inspect.tabs.styles.token-panel" +msgstr "Набори й Теми Токенів" + +#: src/app/main/ui/inspect/styles/rows/color_properties_row.cljs:102, src/app/main/ui/inspect/styles/rows/properties_row.cljs:60 +msgid "inspect.tabs.styles.token-resolved-value" +msgstr "Знайдене значення:" + +#: src/app/main/ui/inspect/styles/style_box.cljs:20 +msgid "inspect.tabs.styles.variants-panel" +msgstr "Параметри Варіанту" + +#: src/app/main/ui/dashboard/sidebar.cljs:1138 +msgid "labels.about-penpot" +msgstr "Про Penpot" + +#: src/app/main/ui/inspect/styles/style_box.cljs:26 +msgid "labels.blur" +msgstr "Розмиття" + +#: src/app/main/ui/workspace/colorpicker.cljs:424 +msgid "labels.color" +msgstr "Колір" + +#: src/app/main/ui/dashboard/sidebar.cljs:1125 +msgid "labels.community-contributions" +msgstr "Спільнота й Внески" + +#: src/app/main/ui/inspect/right_sidebar.cljs:110 +msgid "labels.computed" +msgstr "Обчислено" + +#: src/app/main/ui/static.cljs:415 +msgid "labels.contact-support" +msgstr "Звʼязатись з підтримкою" + +#: src/app/main/ui/settings/sidebar.cljs:136 +msgid "labels.contact-us" +msgstr "Звʼяжіться з нами" + +#: src/app/main/ui/static.cljs:67 +msgid "labels.copyright-period" +msgstr "Kaleidos © 2019-дотепер" + +#: src/app/main/ui/dashboard/deleted.cljs:215 +msgid "labels.deleted" +msgstr "Вилучено" + +#: src/app/main/ui/settings/feedback.cljs:134, src/app/main/ui/static.cljs:409 +msgid "labels.download" +msgstr "Завантажити %s" + +#: src/app/main/ui/inspect/styles/style_box.cljs:23 +msgid "labels.fill" +msgstr "Заповнити" + +#: src/app/main/ui/dashboard/sidebar.cljs:1114 +msgid "labels.help-learning" +msgstr "Допомога й Навчання" + +#: src/app/main/ui/static.cljs:405 +msgid "labels.internal-error.desc-message-first" +msgstr "Сталася халепа." + +#: src/app/main/ui/static.cljs:406 +msgid "labels.internal-error.desc-message-second" +msgstr "Спробуйте знову чи повідомте службу підтримки про помилку." + +#: src/app/main/ui/inspect/styles/style_box.cljs:28 +msgid "labels.layout" +msgstr "Шаблон" + +#: src/app/main/ui/dashboard/sidebar.cljs:893 +msgid "labels.learning-center" +msgstr "Навчальний Центр" + +#: src/app/main/ui/ds/controls/numeric_input.cljs:631 +msgid "labels.mixed-values" +msgstr "Змішано" + +#: src/app/main/ui/dashboard/sidebar.cljs:973 +msgid "labels.penpot-changelog" +msgstr "Список Змін Penpot" + +#: src/app/main/ui/dashboard/sidebar.cljs:899 +msgid "labels.penpot-hub" +msgstr "Хаб Penpot" + +#: src/app/main/ui/dashboard/sidebar.cljs:846 +msgid "labels.pinned-projects" +msgstr "Пришпилені проєкти" + +#: src/app/main/ui/dashboard/deleted.cljs:208 +msgid "labels.recent" +msgstr "Нещодавні" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:205, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:179 +msgid "labels.reference" +msgstr "Посилання" + +#: src/app/main/ui/static.cljs:318 +msgid "labels.reload-page" +msgstr "Перезавантажити сторінку" + +#: src/app/main/ui/dashboard/team.cljs:788 +msgid "labels.resend" +msgstr "Перенадіслати" + +#: src/app/main/ui/inspect/styles/style_box.cljs:27, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:229 +msgid "labels.shadow" +msgstr "Тінь" + +#: src/app/main/ui/dashboard/sidebar.cljs:825 +msgid "labels.sources" +msgstr "Джерела" + +#: src/app/main/ui/inspect/styles/style_box.cljs:24, src/app/main/ui/workspace/sidebar/options/menus/stroke.cljs:46 +msgid "labels.stroke" +msgstr "Обрамлення" + +#: src/app/main/ui/inspect/right_sidebar.cljs:108, src/app/main/ui/inspect/styles.cljs:135 +msgid "labels.styles" +msgstr "Стилі" + +#: src/app/main/ui/workspace/sidebar/options/rows/stroke_row.cljs:261 +msgid "labels.switch" +msgstr "Перемикач" + +#: src/app/main/ui/inspect/styles/style_box.cljs:25 +msgid "labels.text" +msgstr "Текст" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:189 +msgid "labels.typography" +msgstr "Типографіка" + +#: src/app/main/ui/workspace/libraries.cljs:103, src/app/main/ui/workspace/libraries.cljs:130 +msgid "workspace.libraries.typography" +msgid_plural "workspace.libraries.typography" +msgstr[0] "%s типографіка" +msgstr[1] "%s типографіки" +msgstr[2] "%s типографік" + +#: src/app/main/ui/inspect/right_sidebar.cljs:66, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1039 +msgid "labels.variant" +msgstr "Варіянт" + +#: src/app/main/ui/dashboard/sidebar.cljs:967 +msgid "labels.version-notes" +msgstr "Примітки версії %s" + +#: src/app/main/ui/inspect/styles/style_box.cljs:32 +msgid "labels.visibility" +msgstr "Видимість" + +#: src/app/main/ui/dashboard/team.cljs:825 +msgid "notifications.invitation-deleted" +msgstr "Запрошення вилучено успішно" + +#: src/app/main/ui/workspace/sidebar/shortcuts.cljs:97 +msgid "shortcuts.create-component-variant" +msgstr "Створити компонент/варіянт" + +#: src/app/main/ui/dashboard/subscription.cljs:84 +msgid "subscription.dashboard.power-up.professional.bottom-button" +msgstr "Підсилитись!" + +#: src/app/main/ui/dashboard/subscription.cljs:83 +msgid "subscription.dashboard.power-up.professional.bottom-description" +msgstr "Отримайте більше сховища, відновлення файлів та інше для Ваших команд." + +#: src/app/main/ui/dashboard/subscription.cljs:196 +msgid "subscription.dashboard.professional-dashboard-cta-title" +msgstr "" +"Ви маєте %s редакторів поміж Вашими командами, коли як професійний план " +"покриває до 8." + +#: src/app/main/ui/dashboard/subscription.cljs:204 +#, markdown +msgid "subscription.dashboard.professional-dashboard-cta-upgrade-owner" +msgstr "" +"Будь ласка оновіться до Безлімітного чи Корпоративного щоб мати більше " +"редакторів, сховища та відновлення файлів. [Підʼєднайте зараз.|target:self]" +"(%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:199 +msgid "subscription.dashboard.unlimited-dashboard-cta-title" +msgstr "" +"Ваша команда продовжує рости! Ваш безлімітний план покриває до %s " +"редакторів, проте Ви маєте %s." + +#: src/app/main/ui/dashboard/subscription.cljs:207 +#, markdown +msgid "subscription.dashboard.unlimited-dashboard-cta-upgrade-owner" +msgstr "" +"Будь ласка оновіть план щоб відповідати поточній кількості редакторів. " +"[Підʼєднайте зараз.|target:self](%s)" + +#: src/app/main/ui/dashboard/subscription.cljs:184 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-text" +msgstr "" +"Тільки нові редактори поміж Ваших команд враховуються в майбутній платіж. " +"Фіксовані $175/місяць досі застосовуються на 25+ редакторів." + +#: src/app/main/ui/dashboard/subscription.cljs:180 +msgid "subscription.dashboard.unlimited-members-extra-editors-cta-title" +msgstr "Запрошення людей з Безлімітним планом" + +#: src/app/main/ui/settings/subscription.cljs:503, src/app/main/ui/settings/subscription.cljs:513, src/app/main/ui/settings/subscription.cljs:571 +msgid "subscription.settings.enterprise.unlimited-storage-benefit" +msgstr "Безлімітне сховище" + +#: src/app/main/ui/settings/subscription.cljs:298 +msgid "subscription.settings.management-dialog.step-2-add-payment-button" +msgstr "Додати платіжні засоби" + +#: src/app/main/ui/settings/subscription.cljs:285 +msgid "subscription.settings.management-dialog.step-2-description" +msgstr "" +"Додайте платіжні засоби щоб продовжити Вашу підписку після кінця пробного " +"терміну й продовжити підтримувати наш відкритий проєкт. Плату поки не буде " +"стягнуто." + +#: src/app/main/ui/settings/subscription.cljs:293 +msgid "subscription.settings.management-dialog.step-2-skip-button" +msgstr "Пропустити й розпочати пробний період" + +#: src/app/main/ui/settings/subscription.cljs:203 +msgid "subscription.settings.management-dialog.step-2-title" +msgstr "Допоможіть нам рости й зробіть свій пробний період простіше" + +#: src/app/main/ui/settings/subscription.cljs:209 +msgid "subscription.settings.management.dialog.currently-editors-title" +msgid_plural "subscription.settings.management.dialog.currently-editors-title" +msgstr[0] "Наразі Ви маєте %s людину, які можуть редагувати, поміж Ваших команд." +msgstr[1] "Наразі Ви маєте %s людини, які можуть редагувати, поміж Ваших команд." +msgstr[2] "Наразі Ви маєте %s людей, які можуть редагувати, поміж Ваших команд." + +#: src/app/main/ui/settings/subscription.cljs:211 +msgid "subscription.settings.management.dialog.editors" +msgstr "Редактори" + +#: src/app/main/ui/settings/subscription.cljs:218 +msgid "subscription.settings.management.dialog.editors-explanation" +msgstr "(Власники, Адміни та Редаткори. Глядачі не є Редакторами)" + +#: src/app/main/ui/settings/subscription.cljs:263 +msgid "subscription.settings.management.dialog.input-error" +msgstr "" +"Ви не можете встановити менше редакторів ніж маєте зараз. Змініть роль " +"(редактор/адмін на глядача) людей, які не редагують файли, в параметрах " +"команди." + +#: src/app/main/ui/settings/subscription.cljs:471, src/app/main/ui/settings/subscription.cljs:542 +msgid "subscription.settings.professional.autosave-benefit" +msgstr "7-денні автозбережені версії та відновлення файлів" + +#: src/app/main/ui/settings/subscription.cljs:470, src/app/main/ui/settings/subscription.cljs:541 +msgid "subscription.settings.professional.storage-benefit" +msgstr "10ГБ сховища" + +#: src/app/main/ui/settings/subscription.cljs:472, src/app/main/ui/settings/subscription.cljs:543 +msgid "subscription.settings.professional.teams-editors-benefit" +msgstr "Безлімітні команди. До 8 редакторів поміж Ваших команд." + +#: src/app/main/ui/settings/subscription.cljs:50 +msgid "subscription.settings.recommended" +msgstr "Рекомендовано" + +#: src/app/main/ui/settings/subscription.cljs:343 +msgid "subscription.settings.success.dialog.thanks" +msgstr "Дякуємо за вибір плану %s Penpot!" + +#: src/app/main/ui/settings/subscription.cljs:480, src/app/main/ui/settings/subscription.cljs:492, src/app/main/ui/settings/subscription.cljs:556 +msgid "subscription.settings.unlimited.autosave-benefit" +msgstr "30-денні автозбережені версії та відновлення файлів" + +#: src/app/main/ui/settings/subscription.cljs:479, src/app/main/ui/settings/subscription.cljs:491, src/app/main/ui/settings/subscription.cljs:555 +msgid "subscription.settings.unlimited.storage-benefit" +msgstr "25ГБ сховища" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:57 +#, markdown +msgid "subscription.workspace.versions.warning.enterprise.subtext-owner" +msgstr "Якщо бажаєте збільшити цей ліміт, напишіть нам на [%s](mailto)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:59 +#, markdown +msgid "subscription.workspace.versions.warning.subtext-member" +msgstr "" +"Якщо бажаєте збільшити цей ліміт, звʼяжіться з власником команди: [%s]" +"(mailto)" + +#: src/app/main/ui/workspace/sidebar/versions.cljs:58 +#, markdown +msgid "subscription.workspace.versions.warning.subtext-owner" +msgstr "Якщо бажаєте збільшити цей ліміт, [оновіть план|target:self](%s)" + +#: src/app/main/ui/dashboard/team.cljs:933 +msgid "team.invitations-selected" +msgid_plural "team.invitations-selected" +msgstr[0] "%s запрошення обрано" +msgstr[1] "%s запрошення обрано" +msgstr[2] "%s запрошень обрано" + +#: src/app/main/ui/viewer/header.cljs:187 +msgid "viewer.header.edit-in-workspace" +msgstr "Змінити в робочому просторі" + +#: src/app/main/ui/workspace/sidebar/assets/groups.cljs:81 +msgid "workspace.assets.component-group-options" +msgstr "Параметри групи компонентів" + +#: src/app/main/ui/workspace/colorpicker.cljs:428, src/app/main/ui/workspace/colorpicker.cljs:441 +msgid "workspace.colorpicker.color-tokens" +msgstr "Токени кольорів" + +#: src/app/main/ui/workspace/colorpicker.cljs:434 +msgid "workspace.colorpicker.get-color" +msgstr "Отримати колір" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:506 +msgid "workspace.component.swap.loop-error" +msgstr "Компоненти не можна вкладати в них же." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:505 +msgid "workspace.component.switch.loop-error-multi" +msgstr "Деякі копії не можна поміняти. Компоненти не можна вкладати в них же." + +#: src/app/main/ui/workspace/sidebar/debug.cljs:38 +msgid "workspace.debug.title" +msgstr "Інструменти відлагодження" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:422 +msgid "workspace.layout-grid.editor.margin.expand" +msgstr "Показати параметри внутрішнього відступу з 4 боків" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:449 +msgid "workspace.layout-item.fit-content-horizontal" +msgstr "Заповнити вміст (Горизонтально)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:477 +msgid "workspace.layout-item.fit-content-vertical" +msgstr "Заповнити вміст (Вертикально)" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:465 +msgid "workspace.layout-item.fix-height" +msgstr "Зафіксувати висоту" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:439 +msgid "workspace.layout-item.fix-width" +msgstr "Зафіксувати ширину" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:471 +msgid "workspace.layout-item.height-100" +msgstr "Висота 100%" + +#: src/app/main/ui/workspace/sidebar/options/menus/layout_item.cljs:444 +msgid "workspace.layout-item.width-100" +msgstr "Ширина 100%" + +#: src/app/main/ui/workspace/libraries.cljs:100, src/app/main/ui/workspace/libraries.cljs:126 +msgid "workspace.libraries.colors" +msgid_plural "workspace.libraries.colors" +msgstr[0] "%s колір" +msgstr[1] "%s кольори" +msgstr[2] "%s кольорів" + +#: src/app/main/ui/workspace/libraries.cljs:94, src/app/main/ui/workspace/libraries.cljs:118 +msgid "workspace.libraries.components" +msgid_plural "workspace.libraries.components" +msgstr[0] "%s компонент" +msgstr[1] "%s компоненти" +msgstr[2] "%s компонентів" + +#: src/app/main/ui/workspace/libraries.cljs:338 +msgid "workspace.libraries.connected-to" +msgstr "Підʼєднано до" + +#: src/app/main/ui/workspace/libraries.cljs:97, src/app/main/ui/workspace/libraries.cljs:122 +msgid "workspace.libraries.graphics" +msgid_plural "workspace.libraries.graphics" +msgstr[0] "%s фігура" +msgstr[1] "%s фігури" +msgstr[2] "%s фігур" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:557 +msgid "workspace.options.component.variant.malformed.copy" +msgstr "" +"Цей компонент містить варіянти з недійсними іменами. Впевніться що кожен з " +"варіантів слідує відповідній структурі." + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:560 +msgid "workspace.options.component.variant.malformed.locate" +msgstr "Знайти недійсні варіанти" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:54 +msgid "workspace.options.component.variants-help-modal.intro" +msgstr "" +"Щоб зберегти зміни при перемиканні між варіянтами, Penpot зʼєднує шари, що:" + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:91 +msgid "workspace.options.component.variants-help-modal.outro" +msgstr "" +"Зміна одного з цих (тобто перейменування чи групування шару) ламає звʼязок, " +"та відкидання змін відновить це." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:67 +msgid "workspace.options.component.variants-help-modal.rule1" +msgstr "Розділяють одне імʼя." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:76 +msgid "workspace.options.component.variants-help-modal.rule2" +msgstr "Мають один й той самий тип." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:77 +msgid "workspace.options.component.variants-help-modal.rule2.detail" +msgstr "" +"Прямокутник, еліпс, криві та логічні операції вважаються за той самий тип." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:87 +msgid "workspace.options.component.variants-help-modal.rule3" +msgstr "Мають однаковий рівень у ієрархії." + +#: src/app/main/ui/workspace/sidebar/options/menus/variants_help_modal.cljs:88 +msgid "workspace.options.component.variants-help-modal.rule3.detail" +msgstr "Групи, дошки та шаблони вважаються рівними." + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:616 +msgid "workspace.options.interaction-animation-direction-down" +msgstr "Вниз" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:593 +msgid "workspace.options.interaction-animation-direction-in" +msgstr "У" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:612 +msgid "workspace.options.interaction-animation-direction-left" +msgstr "Вліво" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:596 +msgid "workspace.options.interaction-animation-direction-out" +msgstr "З" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:608 +msgid "workspace.options.interaction-animation-direction-right" +msgstr "Вправо" + +#: src/app/main/ui/workspace/sidebar/options/menus/interactions.cljs:620 +msgid "workspace.options.interaction-animation-direction-up" +msgstr "Вгору" + +#: src/app/main/ui/workspace/sidebar/options/menus/color_selection.cljs:265 +msgid "workspace.options.more-token-colors" +msgstr "Більше токенів кольорів" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:108, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:401 +msgid "workspace.options.orientation.horizontal" +msgstr "Горизонтально" + +#: src/app/main/ui/workspace/sidebar/options/drawing/frame.cljs:104, src/app/main/ui/workspace/sidebar/options/menus/measures.cljs:397 +msgid "workspace.options.orientation.vertical" +msgstr "Вертикально" + +#: src/app/main/ui/workspace/plugins.cljs:287 +msgid "workspace.plugins.permissions.allow-localstorage" +msgstr "Зберігати дані в бровзері." + +#: src/app/main/ui/workspace/context_menu.cljs:619, src/app/main/ui/workspace/sidebar/assets/components.cljs:633, src/app/main/ui/workspace/sidebar/assets/groups.cljs:75, src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1106 +msgid "workspace.shape.menu.combine-as-variants" +msgstr "Поєднати як варіянти" + +#: src/app/main/ui/workspace/sidebar/assets/components.cljs:635 +msgid "workspace.shape.menu.combine-as-variants-error" +msgstr "Компонентам потрібно знаходитись на одній сторінці" + +#: src/app/main/ui/workspace/sidebar/options/menus/component.cljs:1156 +msgid "workspace.shape.menu.remove-variant-property.last-property" +msgstr "Варіант повинен містити принаймні один параметр" + +#: src/app/main/ui/workspace/sidebar/layers.cljs:297 +msgid "workspace.sidebar.layers.filter" +msgstr "Фільтр" + +#: src/app/main/data/workspace/tokens/errors.cljs:101, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:251 +msgid "workspace.tokens.composite-line-height-needs-font-size" +msgstr "" +"Висота Лінії залежить від Розміру Шрифта. Додайте Розмір Шрифта щоб знайти " +"значення." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs:234 +msgid "workspace.tokens.edit-token" +msgstr "Змінити токен %s" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:129 +msgid "workspace.tokens.font-size-value-enter" +msgstr "Розмір шрифту чи {alias}" + +#: src/app/main/data/workspace/tokens/application.cljs:325 +msgid "workspace.tokens.font-variant-not-found" +msgstr "" +"Помилка під час встановлення розміру/стилю шрифта. Цей стиль шрифта не існує " +"у поточному шрифті" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:42, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:137 +msgid "workspace.tokens.font-weight-value-enter" +msgstr "Вага шрифту (300, Жирний Курсив...) чи {alias}" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:233 +msgid "workspace.tokens.import-button-prefix" +msgstr "Імпортувати %s" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:273 +msgid "workspace.tokens.import-menu-folder-option" +msgstr "Тека" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:271 +msgid "workspace.tokens.import-menu-json-option" +msgstr "Єдиний файл JSON" + +#: src/app/main/ui/workspace/tokens/import/modal.cljs:272 +msgid "workspace.tokens.import-menu-zip-option" +msgstr "ZIP-архів" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:240, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:195 +msgid "workspace.tokens.individual-tokens" +msgstr "Використовувати індивідуальні токени" + +#: src/app/main/data/workspace/tokens/errors.cljs:93 +msgid "workspace.tokens.invalid-font-family-token-value" +msgstr "Недійсне значення токену: можна лише посилатись на токен сімʼї шрифтів" + +#: src/app/main/data/workspace/tokens/errors.cljs:89 +msgid "workspace.tokens.invalid-font-weight-token-value" +msgstr "" +"Недійсне значення ваги шрифту: використовуйте числове значення (100-950) чи " +"стандартні назви (тонший, тонкий, звичайний, жирний, інше.) з опціональним " +"'Курсив' перед ними" + +#: src/app/main/data/workspace/tokens/errors.cljs:105 +msgid "workspace.tokens.invalid-shadow-type-token-value" +msgstr "Недійсний тип тіні: дозволено тільки 'innerShadow' чи 'dropShadow'" + +#: src/app/main/data/workspace/tokens/errors.cljs:81 +msgid "workspace.tokens.invalid-text-case-token-value" +msgstr "" +"Недійсне значення токену: тільки верхній чи нижній регістр, великі на " +"початку чи жодне з цих" + +#: src/app/main/data/workspace/tokens/errors.cljs:85 +msgid "workspace.tokens.invalid-text-decoration-token-value" +msgstr "" +"Недійсне значення токену: тільки жодне, нижнє підкреслення та закреслення" + +#: src/app/main/data/workspace/tokens/errors.cljs:117 +msgid "workspace.tokens.invalid-token-value-shadow" +msgstr "Недійсне значення: повинно посилатись на збірний токен тіні." + +#: src/app/main/data/workspace/tokens/errors.cljs:97 +msgid "workspace.tokens.invalid-token-value-typography" +msgstr "Недійсне значення: повинно посилатись на збірний токен типографіки." + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:153 +msgid "workspace.tokens.letter-spacing-value-enter-composite" +msgstr "Літеральний відступ чи {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:145 +msgid "workspace.tokens.line-height-value-enter" +msgstr "Висота лінії (множник, px, %) чи {alias}" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:303 +msgid "workspace.tokens.missing-reference" +msgstr "Відсутнє посилання" + +#: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 +msgid "workspace.tokens.more-options" +msgstr "Праве натискання миші для показу параметрів" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:87 +#, unused +msgid "workspace.tokens.no-references-found" +msgstr "Не знайдено посилань" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs +#, unused +msgid "workspace.tokens.no-remap-needed" +msgstr "" +"Цей токен наразі не застосовується у Вашому дизайні, потрібний перерозподіл." + +#: src/app/main/data/workspace/tokens/errors.cljs:19 +msgid "workspace.tokens.no-token-files-found" +msgstr "Жодних токенів, наборів чи тем у файлі не виявлено." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:95 +msgid "workspace.tokens.not-remap" +msgstr "Не перерозподіляти" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:178 +msgid "workspace.tokens.reference-composite" +msgstr "Введіть псевдо токену типографіки" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:204 +msgid "workspace.tokens.reference-composite-shadow" +msgstr "Введіть псевдо токену тіні" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:99 +msgid "workspace.tokens.remap" +msgstr "Перерозподілити токени" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:86 +msgid "workspace.tokens.remap-token-references-title" +msgstr "Перерозподілити усі токени що використовуть `%s` до `%s`?" + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:88 +msgid "workspace.tokens.remap-warning-effects" +msgstr "Це змінить усі шари й посилання що використовують старе імʼя токену." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:89 +msgid "workspace.tokens.remap-warning-time" +msgstr "Ця дія може тривати довго." + +#: src/app/main/ui/workspace/tokens/remapping_modal.cljs:92 +#, unused +msgid "workspace.tokens.remapping-in-progress" +msgstr "Перерозподіляю посилання на токени..." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:232 +msgid "workspace.tokens.shadow-add-shadow" +msgstr "Додати Тінь" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:161, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:162, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:165 +msgid "workspace.tokens.shadow-blur" +msgstr "Розмиття" + +#: src/app/main/data/workspace/tokens/errors.cljs:109 +msgid "workspace.tokens.shadow-blur-range" +msgstr "Розмиття тіні повинне бути більшим чи дорівнювати 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:987, src/app/main/ui/workspace/tokens/management/create/form.cljs:988 +#, unused +msgid "workspace.tokens.shadow-color" +msgstr "Колір" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:114 +msgid "workspace.tokens.shadow-inset" +msgstr "Вставка" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:123 +msgid "workspace.tokens.shadow-remove-shadow" +msgstr "Вилучити Тінь" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:173, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:174, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:177 +msgid "workspace.tokens.shadow-spread" +msgstr "Розкид" + +#: src/app/main/data/workspace/tokens/errors.cljs:113 +msgid "workspace.tokens.shadow-spread-range" +msgstr "Розкид тіні має бути більшим чи дорівнювати 0." + +#: src/app/main/ui/workspace/tokens/management/create/form.cljs:1215 +#, unused +msgid "workspace.tokens.shadow-title" +msgstr "Тіні" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:281 +msgid "workspace.tokens.shadow-token-blur-value-error" +msgstr "Значення розмиття не може бути відʼємним" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:287 +#, unused +msgid "workspace.tokens.shadow-token-spread-value-error" +msgstr "Значення розкиду не може бути відʼємним" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:139, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:141 +msgid "workspace.tokens.shadow-x" +msgstr "Х" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:150, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:152 +msgid "workspace.tokens.shadow-y" +msgstr "" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:40, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:161 +msgid "workspace.tokens.text-case-value-enter" +msgstr "жоден | верхній регістр | нижній регістр | верхня на початку чи {alias}" + +#: src/app/main/ui/workspace/tokens/management/forms/form_container.cljs:41, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:169 +msgid "workspace.tokens.text-decoration-value-enter" +msgstr "жоден | підкреслення | закреслення чи {alias}" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:51 +#, unused +msgid "workspace.tokens.theme-name-already-exists" +msgstr "Тема з цим імʼям вже існує" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:100 +#, unused +msgid "workspace.tokens.theme.disable" +msgstr "Вимкнути" + +#: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:96 +#, unused +msgid "workspace.tokens.theme.enable" +msgstr "Задіяти" + +#: src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:122, src/app/main/ui/workspace/tokens/management/forms/controls/fonts_combobox.cljs:246 +msgid "workspace.tokens.token-font-family-select" +msgstr "Обрати сімейство шрифтів" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:121 +msgid "workspace.tokens.token-font-family-value" +msgstr "Сімейство шрифта" + +#: src/app/main/ui/workspace/tokens/management/forms/typography.cljs:120 +msgid "workspace.tokens.token-font-family-value-enter" +msgstr "Сімейство шрифта або список шрифтів, розділені комою (,)" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:268, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:223 +msgid "workspace.tokens.token-name-duplication-validation-error" +msgstr "Токен вже існує на шляху: %s" + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:265, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:220 +msgid "workspace.tokens.token-name-length-validation-error" +msgstr "Імʼя повинне містити принаймні 1 символ" + +#: src/app/main/data/workspace/tokens/import_export.cljs:46 +msgid "workspace.tokens.unknown-token-type-message" +msgstr "Імпротування пройшло успішно. Деякі токени не були додані." + +#: src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:244, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:199 +msgid "workspace.tokens.use-reference" +msgstr "Використати посилання" + +#: src/app/main/data/workspace/tokens/errors.cljs:69 +msgid "workspace.tokens.value-with-percent" +msgstr "Недійсне значення: %s не дозволений." + +#: src/app/main/ui/workspace/tokens/management/forms/generic_form.cljs +#, unused +msgid "workspace.tokens.warning-name-change" +msgstr "Перейменування токену зламає усі посилання на старе імʼя" + +#: src/app/main/ui/workspace/top_toolbar.cljs:231 +msgid "workspace.toolbar.debug" +msgstr "Інструменти відлагодження" + +#, unused +msgid "workspace.versions.locked-by-other" +msgstr "Ця версія заблокована %s і не може бути змінена" + +#, unused +msgid "workspace.versions.locked-by-you" +msgstr "Ця версія заблокована Вами" + +#, unused +msgid "workspace.versions.tooltip.locked-version" +msgstr "Заблокована версія - тільки творець може вносити зміни" + +#: src/app/main/ui/settings/subscription.cljs:266 +msgid "subscription.settings.management.dialog.unlimited-capped-warning" +msgstr "" +"Порада: Кількість місць можна збільшити зараз, щоб бути попереду запрошень. " +"На 25+ редакторів поміж команд, Ви насолоджуватиметесь фіксованою платою у " +"$175 на місяць." diff --git a/frontend/translations/yo.po b/frontend/translations/yo.po index 5714d3f655..494b2d6ace 100644 --- a/frontend/translations/yo.po +++ b/frontend/translations/yo.po @@ -2151,10 +2151,6 @@ msgstr "" msgid "modals.update-remote-component.message" msgstr "Mú ẹ̀yà iyàrá ìkàwé pípín kan dójú ìwọ̀n" -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "Ẹ̀yà tuntun ti wà, jọ̀wọ́ tún sọ ọ́ jí" - #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" msgstr "Ìfipè tí a fi ránńṣẹ́ ti lọ dáadáa" diff --git a/frontend/translations/zh_CN.po b/frontend/translations/zh_CN.po index c32fd42b02..49dfdfabdc 100644 --- a/frontend/translations/zh_CN.po +++ b/frontend/translations/zh_CN.po @@ -1113,7 +1113,7 @@ msgid "ds.inputs.numeric-input.open-token-list-dropdown" msgstr "打开token列表" #: src/app/main/ui/ds/controls/utilities/token_field.cljs:91, src/app/main/ui/workspace/sidebar/options/rows/color_row.cljs:136 -msgid "ds.inputs.token-field.detach-token" +msgid "token-actions.detach-token" msgstr "分离token" #: src/app/main/data/auth.cljs:339 @@ -3140,10 +3140,6 @@ msgstr "要访问此项目,您可以询问团队拥有者。" msgid "notifications.by-code.maintenance" msgstr "维护中断:我们将在5分钟内进行短暂维护。" -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "有新版本可用,请刷新页面" - #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" msgstr "成功发送邀请" @@ -4322,11 +4318,11 @@ msgid "subscription.settings.subscribe" msgstr "订阅" #: src/app/main/ui/settings/subscription.cljs:347 -msgid "subscription.settings.sucess.dialog.footer" +msgid "subscription.settings.success.dialog.footer" msgstr "享受您的计划!" #: src/app/main/ui/settings/subscription.cljs:340 -msgid "subscription.settings.sucess.dialog.title" +msgid "subscription.settings.success.dialog.title" msgstr "你是 %s!" #: src/app/main/ui/settings/subscription.cljs:526 @@ -6907,7 +6903,7 @@ msgid "workspace.tokens.edit-themes" msgstr "编辑主题" #: src/app/main/data/workspace/tokens/errors.cljs:15 -msgid "workspace.tokens.error-parse" +msgid "errors.tokens.error-parse" msgstr "导入错误:不能解析 JSON。" #: src/app/main/ui/workspace/tokens/export/modal.cljs:49 @@ -6944,7 +6940,7 @@ msgid "workspace.tokens.import-button-prefix" msgstr "导入 %s" #: src/app/main/data/workspace/tokens/errors.cljs:32, src/app/main/data/workspace/tokens/errors.cljs:37 -msgid "workspace.tokens.import-error" +msgid "errors.tokens.import-error" msgstr "导入错误:" #: src/app/main/ui/workspace/tokens/import/modal.cljs:273 @@ -6977,37 +6973,37 @@ msgid "workspace.tokens.inactive-set-description" msgstr "此设置未启用。请更改主题或启用此设置以在视口中查看更改" #: src/app/main/data/workspace/tokens/errors.cljs:49 -msgid "workspace.tokens.invalid-color" +msgid "errors.tokens.invalid-color" msgstr "无效的颜色值:%s" #: src/app/main/data/workspace/tokens/errors.cljs:89 -msgid "workspace.tokens.invalid-font-weight-token-value" +msgid "errors.tokens.invalid-font-weight-token-value" msgstr "字体粗细值无效:使用数值(100-950)或标准名称(细、亮、常规、粗体等),后跟可选的“斜体”" #: src/app/main/data/workspace/tokens/errors.cljs:23 -msgid "workspace.tokens.invalid-json" +msgid "errors.tokens.invalid-json" msgstr "导入错误:JSON 中存在无效的令牌数据。" #: src/app/main/data/workspace/tokens/errors.cljs:27 -msgid "workspace.tokens.invalid-json-token-name" +msgid "errors.tokens.invalid-json-token-name" msgstr "导入错误;JSON中存在无效的令牌名。" #: src/app/main/data/workspace/tokens/errors.cljs:28 -msgid "workspace.tokens.invalid-json-token-name-detail" +msgid "errors.tokens.invalid-json-token-name-detail" msgstr "" "“%s” 不是有效的 token 名称。\n" "token 名称只能包含字母和数字,并以 . 字符分隔,并且不能以 $ 符号开头。" #: src/app/main/data/workspace/tokens/errors.cljs:81 -msgid "workspace.tokens.invalid-text-case-token-value" +msgid "errors.tokens.invalid-text-case-token-value" msgstr "无效的token值:仅接受无、大写、小写或大写" #: src/app/main/data/workspace/tokens/errors.cljs:85 -msgid "workspace.tokens.invalid-text-decoration-token-value" +msgid "errors.tokens.invalid-text-decoration-token-value" msgstr "无效的token值:仅接受无、下划线和删除线" #: src/app/main/data/workspace/tokens/errors.cljs:61, src/app/main/data/workspace/tokens/errors.cljs:73, src/app/main/data/workspace/tokens/errors.cljs:77 -msgid "workspace.tokens.invalid-value" +msgid "errors.tokens.invalid-value" msgstr "无效的令牌值:%s" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:205 @@ -7039,7 +7035,7 @@ msgid "workspace.tokens.min-size" msgstr "最小尺寸" #: src/app/main/data/workspace/tokens/errors.cljs:57 -msgid "workspace.tokens.missing-references" +msgid "errors.tokens.missing-references" msgstr "缺少token引用: " #: src/app/main/ui/workspace/tokens/management/token_pill.cljs:124 @@ -7079,7 +7075,7 @@ msgid "workspace.tokens.no-themes-currently" msgstr "您目前没有主题。" #: src/app/main/data/workspace/tokens/errors.cljs:19 -msgid "workspace.tokens.no-token-files-found" +msgid "errors.tokens.no-token-files-found" msgstr "在此文件中未找到任何标记、集合或主题。" #: src/app/main/ui/workspace/tokens/themes/create_modal.cljs:134 @@ -7122,7 +7118,7 @@ msgid "workspace.tokens.select-set" msgstr "选择集。" #: src/app/main/data/workspace/tokens/errors.cljs:45, src/app/main/ui/workspace/tokens/management/forms/shadow.cljs:299, src/app/main/ui/workspace/tokens/management/forms/typography.cljs:243 -msgid "workspace.tokens.self-reference" +msgid "errors.tokens.self-reference" msgstr "Token存在自我引用" #: src/app/main/ui/workspace/tokens/sets/lists.cljs:60 diff --git a/frontend/translations/zh_Hant.po b/frontend/translations/zh_Hant.po index d5861efe24..2620c9ec0d 100644 --- a/frontend/translations/zh_Hant.po +++ b/frontend/translations/zh_Hant.po @@ -2770,10 +2770,6 @@ msgstr "要存取該項目,您可以詢問團隊老大。" msgid "notifications.by-code.maintenance" msgstr "中斷維護:我們將在5分鐘內進行短暫維護。" -#: src/app/main/data/common.cljs:82 -msgid "notifications.by-code.upgrade-version" -msgstr "有新版本可用,請重新整理頁面" - #: src/app/main/ui/dashboard/team.cljs:170, src/app/main/ui/dashboard/team.cljs:867 msgid "notifications.invitation-email-sent" msgstr "邀請已成功發送" diff --git a/mcp/README.md b/mcp/README.md index f23af675e9..250bf792c5 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -4,7 +4,7 @@ Penpot integrates a LLM layer built on the Model Context Protocol (MCP) via Penpot's Plugin API to interact with a Penpot design -file. Penpot's MCP server enables LLMs to perfom data queries, +file. Penpot's MCP server enables LLMs to perform data queries, transformation and creation operations. Penpot's MCP Server is unlike any other you've seen. You get diff --git a/mcp/bin/mcp-local.js b/mcp/bin/mcp-local.js index 65a2b0f763..b77b245dea 100644 --- a/mcp/bin/mcp-local.js +++ b/mcp/bin/mcp-local.js @@ -5,6 +5,12 @@ const fs = require("fs"); const path = require("path"); const root = path.resolve(__dirname, ".."); +const pkg = require(path.join(root, "package.json")); + +function pnpmVersion() { + const match = (pkg.packageManager || "").match(/^pnpm@([^+]+)/); + return match ? match[1] : "latest"; +} function run(command) { execSync(command, { cwd: root, stdio: "inherit" }); @@ -19,13 +25,7 @@ if (fs.existsSync(distLock)) { } try { - run("corepack pnpm run bootstrap"); + run(`npx -y pnpm@${pnpmVersion()} run bootstrap`); } catch (error) { - if (error.code === "ENOENT") { - console.error( - "corepack is required but was not found. It ships with Node.js >= 16." - ); - process.exit(1); - } process.exit(error.status ?? 1); } diff --git a/mcp/package.json b/mcp/package.json index 49c13b6cf8..2318a80951 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "@penpot/mcp", - "version": "2.16.0-rc.1", + "version": "2.16.0-rc.1.206", "description": "MCP server for Penpot integration", "bin": { "penpot-mcp": "./bin/mcp-local.js" diff --git a/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts b/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts index dbbeba184c..3e56ea15bb 100644 --- a/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts +++ b/plugins/libs/plugins-runtime/src/lib/api/openUI.api.ts @@ -10,7 +10,27 @@ export const openUIApi = z z.enum(['dark', 'light']), openUISchema.optional(), z.boolean().optional(), + z.boolean().optional(), + z.boolean().optional(), ) - .implement((title, url, theme, options, allowDownloads) => { - return createModal(title, url, theme, options, allowDownloads); - }); + .implement( + ( + title, + url, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ) => { + return createModal( + title, + url, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ); + }, + ); diff --git a/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts b/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts index 80d28fe56c..4cefab63c2 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-modal.spec.ts @@ -104,4 +104,73 @@ describe('createModal', () => { expect(modal.wrapper.style.width).toEqual('200px'); expect(modal.wrapper.style.height).toEqual('200px'); }); + + it('should set allow-clipboard-read attribute when allowClipboardRead is true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + true, + false, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + expect(modalMock.setAttribute).not.toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + }); + + it('should set allow-clipboard-write attribute when allowClipboardWrite is true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + false, + true, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + expect(modalMock.setAttribute).not.toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + }); + + it('should set both clipboard attributes when both are true', () => { + const theme: Theme = 'light'; + + createModal( + 'Test Modal', + 'https://example.com', + theme, + undefined, + false, + true, + true, + ); + + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-read', + 'true', + ); + expect(modalMock.setAttribute).toHaveBeenCalledWith( + 'allow-clipboard-write', + 'true', + ); + }); }); diff --git a/plugins/libs/plugins-runtime/src/lib/create-modal.ts b/plugins/libs/plugins-runtime/src/lib/create-modal.ts index 01422c76b6..d6cd2d1623 100644 --- a/plugins/libs/plugins-runtime/src/lib/create-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/create-modal.ts @@ -10,6 +10,8 @@ export function createModal( theme: Theme, options?: OpenUIOptions, allowDownloads?: boolean, + allowClipboardRead?: boolean, + allowClipboardWrite?: boolean, ) { const modal = document.createElement('plugin-modal') as PluginModalElement; @@ -44,6 +46,14 @@ export function createModal( modal.setAttribute('allow-downloads', 'true'); } + if (allowClipboardRead) { + modal.setAttribute('allow-clipboard-read', 'true'); + } + + if (allowClipboardWrite) { + modal.setAttribute('allow-clipboard-write', 'true'); + } + document.body.appendChild(modal); return modal; diff --git a/plugins/libs/plugins-runtime/src/lib/drag-handler.spec.ts b/plugins/libs/plugins-runtime/src/lib/drag-handler.spec.ts index 18ec9ef75c..8989b55eab 100644 --- a/plugins/libs/plugins-runtime/src/lib/drag-handler.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/drag-handler.spec.ts @@ -1,11 +1,45 @@ import { expect, describe, vi } from 'vitest'; import { dragHandler } from './drag-handler.js'; +type PointerLikeEvent = MouseEvent & { pointerId: number }; + +function createPointerEvent( + type: string, + init: Partial = {}, +): PointerLikeEvent { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + button: init.button ?? 0, + }) as PointerLikeEvent; + + Object.defineProperty(event, 'pointerId', { + configurable: true, + value: init.pointerId ?? 1, + }); + + return event; +} + describe('dragHandler', () => { let element: HTMLElement; beforeEach(() => { element = document.createElement('div'); + Object.defineProperty(element, 'setPointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(element, 'releasePointerCapture', { + configurable: true, + value: vi.fn(), + }); + Object.defineProperty(element, 'hasPointerCapture', { + configurable: true, + value: vi.fn().mockReturnValue(true), + }); document.body.appendChild(element); }); @@ -14,65 +48,109 @@ describe('dragHandler', () => { vi.clearAllMocks(); }); - it('should attach mousedown event listener to the element', () => { + it('should attach pointerdown event listener to the element', () => { const addEventListenerMock = vi.spyOn(element, 'addEventListener'); dragHandler(element); expect(addEventListenerMock).toHaveBeenCalledWith( - 'mousedown', + 'pointerdown', expect.any(Function), ); }); - it('should update element transform on mousemove', () => { - const mouseDownEvent = new MouseEvent('mousedown', { + it('should update element transform on pointermove', () => { + const pointerDownEvent = createPointerEvent('pointerdown', { clientX: 100, clientY: 100, }); dragHandler(element); - element.dispatchEvent(mouseDownEvent); + element.dispatchEvent(pointerDownEvent); - const mouseMoveEvent = new MouseEvent('mousemove', { + const pointerMoveEvent = createPointerEvent('pointermove', { clientX: 150, clientY: 150, }); - document.dispatchEvent(mouseMoveEvent); + element.dispatchEvent(pointerMoveEvent); expect(element.style.transform).toBe('translate(50px, 50px)'); - const mouseMoveEvent2 = new MouseEvent('mousemove', { + const pointerMoveEvent2 = createPointerEvent('pointermove', { clientX: 200, clientY: 200, }); - document.dispatchEvent(mouseMoveEvent2); + element.dispatchEvent(pointerMoveEvent2); expect(element.style.transform).toBe('translate(100px, 100px)'); }); - it('should remove event listeners on mouseup', () => { - const removeEventListenerMock = vi.spyOn(document, 'removeEventListener'); - - const mouseDownEvent = new MouseEvent('mousedown', { + it('should run lifecycle callbacks on drag start/end', () => { + const start = vi.fn(); + const end = vi.fn(); + const pointerDownEvent = createPointerEvent('pointerdown', { clientX: 100, clientY: 100, + pointerId: 2, + }); + const pointerUpEvent = createPointerEvent('pointerup', { + pointerId: 2, }); - dragHandler(element); + dragHandler(element, element, undefined, { start, end }); + element.dispatchEvent(pointerDownEvent); + element.dispatchEvent(pointerUpEvent); - element.dispatchEvent(mouseDownEvent); + expect(start).toHaveBeenCalledTimes(1); + expect(end).toHaveBeenCalledTimes(1); + expect(element.releasePointerCapture).toHaveBeenCalledWith(2); + }); - const mouseUpEvent = new MouseEvent('mouseup'); - document.dispatchEvent(mouseUpEvent); + it('should ignore pointerdown events from button targets', () => { + const start = vi.fn(); + const button = document.createElement('button'); + const icon = document.createElement('span'); + button.appendChild(icon); + element.appendChild(button); + + dragHandler(element, element, undefined, { start }); + + icon.dispatchEvent( + createPointerEvent('pointerdown', { + pointerId: 5, + button: 0, + }), + ); + + expect(start).not.toHaveBeenCalled(); + expect(element.setPointerCapture).not.toHaveBeenCalled(); + }); + + it('should remove pointer listeners on teardown', () => { + const removeEventListenerMock = vi.spyOn(element, 'removeEventListener'); + + const cleanup = dragHandler(element); + cleanup(); expect(removeEventListenerMock).toHaveBeenCalledWith( - 'mousemove', + 'pointerdown', expect.any(Function), ); expect(removeEventListenerMock).toHaveBeenCalledWith( - 'mouseup', + 'pointermove', + expect.any(Function), + ); + expect(removeEventListenerMock).toHaveBeenCalledWith( + 'pointerup', + expect.any(Function), + ); + expect(removeEventListenerMock).toHaveBeenCalledWith( + 'pointercancel', + expect.any(Function), + ); + expect(removeEventListenerMock).toHaveBeenCalledWith( + 'lostpointercapture', expect.any(Function), ); }); diff --git a/plugins/libs/plugins-runtime/src/lib/drag-handler.ts b/plugins/libs/plugins-runtime/src/lib/drag-handler.ts index 9ee5c83c93..fd79f07d3e 100644 --- a/plugins/libs/plugins-runtime/src/lib/drag-handler.ts +++ b/plugins/libs/plugins-runtime/src/lib/drag-handler.ts @@ -1,14 +1,36 @@ import { parseTranslate } from './parse-translate'; +type DragHandlerLifecycle = { + start?: () => void; + end?: () => void; +}; + export const dragHandler = ( el: HTMLElement, target: HTMLElement = el, move?: () => void, + lifecycle?: DragHandlerLifecycle, ) => { let initialTranslate = { x: 0, y: 0 }; let initialClientPosition = { x: 0, y: 0 }; + let pointerId: number | null = null; + let dragging = false; + + const endDrag = () => { + if (!dragging) { + return; + } + + dragging = false; + pointerId = null; + lifecycle?.end?.(); + }; + + const handlePointerMove = (moveEvent: PointerEvent) => { + if (!dragging || moveEvent.pointerId !== pointerId) { + return; + } - const handleMouseMove = (moveEvent: MouseEvent) => { const { clientX: moveX, clientY: moveY } = moveEvent; const deltaX = moveX - initialClientPosition.x + initialTranslate.x; const deltaY = moveY - initialClientPosition.y + initialTranslate.y; @@ -18,19 +40,70 @@ export const dragHandler = ( move?.(); }; - const handleMouseUp = () => { - document.removeEventListener('mousemove', handleMouseMove); - document.removeEventListener('mouseup', handleMouseUp); + const handlePointerUp = (upEvent: PointerEvent) => { + if (upEvent.pointerId !== pointerId) { + return; + } + + if (el.hasPointerCapture(upEvent.pointerId)) { + el.releasePointerCapture(upEvent.pointerId); + } + + endDrag(); }; - const handleMouseDown = (e: MouseEvent) => { + const handlePointerCancel = (cancelEvent: PointerEvent) => { + if (cancelEvent.pointerId !== pointerId) { + return; + } + + endDrag(); + }; + + const handleLostPointerCapture = (lostCaptureEvent: PointerEvent) => { + if (lostCaptureEvent.pointerId !== pointerId) { + return; + } + + endDrag(); + }; + + const handlePointerDown = (e: PointerEvent) => { + if (e.button !== 0) { + return; + } + + const fromButton = + e.target instanceof Element && !!e.target.closest('button'); + + if (fromButton) { + return; + } + + e.preventDefault(); + initialClientPosition = { x: e.clientX, y: e.clientY }; initialTranslate = parseTranslate(target); - document.addEventListener('mousemove', handleMouseMove); - document.addEventListener('mouseup', handleMouseUp); + + dragging = true; + pointerId = e.pointerId; + lifecycle?.start?.(); + + el.setPointerCapture(e.pointerId); }; - el.addEventListener('mousedown', handleMouseDown); + el.addEventListener('pointerdown', handlePointerDown); + el.addEventListener('pointermove', handlePointerMove); + el.addEventListener('pointerup', handlePointerUp); + el.addEventListener('pointercancel', handlePointerCancel); + el.addEventListener('lostpointercapture', handleLostPointerCapture); - return handleMouseUp; + return () => { + el.removeEventListener('pointerdown', handlePointerDown); + el.removeEventListener('pointermove', handlePointerMove); + el.removeEventListener('pointerup', handlePointerUp); + el.removeEventListener('pointercancel', handlePointerCancel); + el.removeEventListener('lostpointercapture', handleLostPointerCapture); + endDrag(); + }; }; diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts new file mode 100644 index 0000000000..fb19f291ab --- /dev/null +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.spec.ts @@ -0,0 +1,154 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import './plugin-modal.js'; + +type PointerLikeEvent = MouseEvent & { pointerId: number }; + +function createPointerEvent( + type: string, + init: Partial = {}, +): PointerLikeEvent { + const event = new MouseEvent(type, { + bubbles: true, + cancelable: true, + clientX: init.clientX ?? 0, + clientY: init.clientY ?? 0, + button: init.button ?? 0, + }) as PointerLikeEvent; + + Object.defineProperty(event, 'pointerId', { + configurable: true, + value: init.pointerId ?? 1, + }); + + return event; +} + +describe('PluginModalElement', () => { + let setPointerCaptureSpy: ReturnType; + let releasePointerCaptureSpy: ReturnType; + let hasPointerCaptureSpy: ReturnType; + let originalSetPointerCapture: typeof HTMLElement.prototype.setPointerCapture; + let originalReleasePointerCapture: typeof HTMLElement.prototype.releasePointerCapture; + let originalHasPointerCapture: typeof HTMLElement.prototype.hasPointerCapture; + + beforeEach(() => { + originalSetPointerCapture = HTMLElement.prototype.setPointerCapture; + originalReleasePointerCapture = HTMLElement.prototype.releasePointerCapture; + originalHasPointerCapture = HTMLElement.prototype.hasPointerCapture; + + setPointerCaptureSpy = vi.fn(); + releasePointerCaptureSpy = vi.fn(); + hasPointerCaptureSpy = vi.fn().mockReturnValue(true); + + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: setPointerCaptureSpy, + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: releasePointerCaptureSpy, + }); + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: hasPointerCaptureSpy, + }); + }); + + afterEach(() => { + document.body.innerHTML = ''; + Object.defineProperty(HTMLElement.prototype, 'setPointerCapture', { + configurable: true, + value: originalSetPointerCapture, + }); + Object.defineProperty(HTMLElement.prototype, 'releasePointerCapture', { + configurable: true, + value: originalReleasePointerCapture, + }); + Object.defineProperty(HTMLElement.prototype, 'hasPointerCapture', { + configurable: true, + value: originalHasPointerCapture, + }); + vi.restoreAllMocks(); + }); + + it('should not start dragging on close button pointerdown', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + document.body.appendChild(modal); + + const shadow = modal.shadowRoot; + expect(shadow).toBeTruthy(); + + const wrapper = shadow?.querySelector('.wrapper'); + const closeButton = shadow?.querySelector('button'); + + expect(wrapper).toBeTruthy(); + expect(closeButton).toBeTruthy(); + + closeButton?.dispatchEvent( + createPointerEvent('pointerdown', { + pointerId: 11, + button: 0, + }), + ); + + expect(wrapper?.classList.contains('is-dragging')).toBe(false); + expect(setPointerCaptureSpy).not.toHaveBeenCalled(); + + modal.remove(); + }); + + it('should set iframe allow attribute for clipboard permissions', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + modal.setAttribute('allow-clipboard-read', 'true'); + modal.setAttribute('allow-clipboard-write', 'true'); + document.body.appendChild(modal); + + const iframe = modal.shadowRoot?.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.allow).toContain('clipboard-read'); + expect(iframe?.allow).toContain('clipboard-write'); + + modal.remove(); + }); + + it('should not set clipboard allow attributes when permissions are absent', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + document.body.appendChild(modal); + + const iframe = modal.shadowRoot?.querySelector('iframe'); + expect(iframe).toBeTruthy(); + expect(iframe?.allow).toBe(''); + + modal.remove(); + }); + + it('should dispatch close event when close button is clicked', () => { + const modal = document.createElement('plugin-modal'); + modal.setAttribute('title', 'Test modal'); + modal.setAttribute('iframe-src', 'about:blank'); + + const onClose = vi.fn(); + modal.addEventListener('close', onClose); + document.body.appendChild(modal); + + const closeButton = modal.shadowRoot?.querySelector('button'); + expect(closeButton).toBeTruthy(); + + closeButton?.dispatchEvent( + new MouseEvent('click', { + bubbles: true, + cancelable: true, + }), + ); + + expect(onClose).toHaveBeenCalledTimes(1); + + modal.remove(); + }); +}); diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts index 3346175212..53ea472494 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin-modal.ts @@ -52,6 +52,10 @@ export class PluginModalElement extends HTMLElement { const title = this.getAttribute('title'); const iframeSrc = this.getAttribute('iframe-src'); const allowDownloads = this.getAttribute('allow-downloads') || false; + const allowClipboardRead = + this.getAttribute('allow-clipboard-read') || false; + const allowClipboardWrite = + this.getAttribute('allow-clipboard-write') || false; if (!title || !iframeSrc) { throw new Error('title and iframe-src attributes are required'); @@ -67,11 +71,6 @@ export class PluginModalElement extends HTMLElement { this.wrapper.style.maxInlineSize = '90vw'; this.wrapper.style.maxBlockSize = '90vh'; - // move modal to the top - this.#dragEvents = dragHandler(this.#inner, this.wrapper, () => { - this.calculateZIndex(); - }); - const header = document.createElement('div'); header.classList.add('header'); @@ -100,7 +99,12 @@ export class PluginModalElement extends HTMLElement { const iframe = document.createElement('iframe'); iframe.src = iframeSrc; - iframe.allow = ''; + + const allowList: string[] = []; + if (allowClipboardRead) allowList.push('clipboard-read'); + if (allowClipboardWrite) allowList.push('clipboard-write'); + iframe.allow = allowList.join('; '); + iframe.sandbox.add( 'allow-scripts', 'allow-forms', @@ -124,6 +128,23 @@ export class PluginModalElement extends HTMLElement { ); }); + // move modal to the top + this.#dragEvents = dragHandler( + header, + this.wrapper, + () => { + this.calculateZIndex(); + }, + { + start: () => { + this.wrapper.classList.add('is-dragging'); + }, + end: () => { + this.wrapper.classList.remove('is-dragging'); + }, + }, + ); + this.addEventListener('message', (e: Event) => { if (!iframe.contentWindow) { return; diff --git a/plugins/libs/plugins-runtime/src/lib/modal/plugin.modal.css b/plugins/libs/plugins-runtime/src/lib/modal/plugin.modal.css index 4ac13c45d9..bd987442d2 100644 --- a/plugins/libs/plugins-runtime/src/lib/modal/plugin.modal.css +++ b/plugins/libs/plugins-runtime/src/lib/modal/plugin.modal.css @@ -42,6 +42,8 @@ min-inline-size: 25px; min-block-size: 200px; resize: both; + user-select: none; + -webkit-user-select: none; &:after { content: ''; cursor: se-resize; @@ -58,7 +60,6 @@ .inner { padding: 10px; - cursor: grab; box-sizing: border-box; display: flex; flex-direction: column; @@ -78,6 +79,12 @@ justify-content: space-between; border-block-end: 2px solid var(--color-background-quaternary); padding-block-end: var(--spacing-4); + cursor: grab; + touch-action: none; +} + +.wrapper.is-dragging .header { + cursor: grabbing; } button { @@ -92,7 +99,6 @@ h1 { font-weight: var(--font-weight-bold); margin: 0; margin-inline-end: var(--spacing-4); - user-select: none; } iframe { diff --git a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts index bd5895c02b..16d4fd5e28 100644 --- a/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts +++ b/plugins/libs/plugins-runtime/src/lib/models/manifest.schema.ts @@ -19,6 +19,8 @@ export const manifestSchema = z.object({ 'comment:write', 'allow:downloads', 'allow:localstorage', + 'clipboard:read', + 'clipboard:write', ]), ), }); 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 206f43ba34..d53f4f5296 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.spec.ts @@ -123,6 +123,8 @@ describe('createPluginManager', () => { 'light', { width: 400, height: 300 }, true, + false, + false, ); expect(mockModal.setTheme).toHaveBeenCalledWith('light'); expect(mockModal.addEventListener).toHaveBeenCalledWith( diff --git a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts index 0b4035794a..8b811f55eb 100644 --- a/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts +++ b/plugins/libs/plugins-runtime/src/lib/plugin-manager.ts @@ -27,6 +27,14 @@ export async function createPluginManager( (s) => s === 'allow:downloads', ); + const allowClipboardRead = !!manifest.permissions.find( + (s) => s === 'clipboard:read', + ); + + const allowClipboardWrite = !!manifest.permissions.find( + (s) => s === 'clipboard:write', + ); + const themeChangeId = context.addListener('themechange', (theme: Theme) => { modal?.setTheme(theme); }); @@ -91,7 +99,15 @@ export async function createPluginManager( return; } - modal = openUIApi(name, modalUrl, theme, options, allowDownloads); + modal = openUIApi( + name, + modalUrl, + theme, + options, + allowDownloads, + allowClipboardRead, + allowClipboardWrite, + ); modal.setTheme(theme);