├── .editorconfig ├── .gitignore ├── LICENSE ├── README.md ├── babel.config.js ├── bun.lock ├── features ├── columns-layout.md └── countdown.md ├── figma └── manifest.json ├── netlify.toml ├── package.json ├── penpot ├── icon.png └── manifest.json ├── secrets_default.json ├── src ├── App.css ├── App.js ├── Config.js ├── Core.js ├── payments │ ├── gate.js │ └── license.js ├── ui │ ├── Bulletproof.css │ ├── Element.js │ ├── FigmaUI.css │ ├── components │ │ ├── display │ │ │ ├── DisplayComponent.css │ │ │ └── DisplayComponent.js │ │ ├── select │ │ │ ├── SelectComponent.css │ │ │ └── SelectComponent.js │ │ └── toolbar │ │ │ ├── ToolbarComponent.css │ │ │ └── ToolbarComponent.js │ ├── icons │ │ ├── IconCheck.js │ │ └── IconShow.js │ └── views │ │ ├── countdown │ │ ├── AnalogChronometer.css │ │ ├── AnalogChronometer.js │ │ ├── CountdownView.css │ │ └── CountdownView.js │ │ ├── form │ │ ├── FormView.css │ │ └── FormView.js │ │ ├── license │ │ ├── LicenseView.css │ │ └── LicenseView.js │ │ └── preferences │ │ ├── PreferencesView.css │ │ └── PreferencesView.js └── utils │ ├── DisplayNetwork.js │ ├── FigPen.js │ ├── LayoutUtils.js │ ├── MessageBus.js │ ├── Router.js │ ├── Storage.js │ └── Tracking.js └── webpack.config.js /.editorconfig: -------------------------------------------------------------------------------- 1 | # top-most EditorConfig file 2 | root = true 3 | 4 | # Unix-style newlines with a newline ending every file 5 | [*] 6 | end_of_line = lf 7 | insert_final_newline = true 8 | charset = utf-8 9 | 10 | [*.{js, html, css}] 11 | indent_style = tab 12 | indent_size = 2 13 | 14 | [*.{yml, conf, json}] 15 | indent_style = space 16 | indent_size = 4 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .idea 3 | .nova 4 | secrets.json 5 | node_modules/ 6 | dist/ 7 | figma/dist/ 8 | penpot/dist/ 9 | # Local Netlify folder 10 | .netlify 11 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Ismael González 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Super Tidy 2 | *A Figma plugin to easily align, rename and reorder your frames based in their canvas position.* 3 | 4 | Super Tidy renames your frames and reorders them in the layers list by their position in the canvas. It also replicates the Figma Tidy feature so you can run it all at once: Rename, Reorder and Tidy. 5 | 6 | ## Tutorials & related articles 7 | * [How to use Figma Super Tidy](https://www.youtube.com/watch?v=i681_evMv2k) 8 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | [ 4 | '@babel/preset-env', 5 | { 6 | targets: { 7 | // Support modern browsers and Node.js 8 | browsers: ['> 1%', 'last 2 versions', 'not dead'], 9 | node: 'current' 10 | }, 11 | // Use modern JavaScript features 12 | useBuiltIns: 'usage', 13 | corejs: 3, 14 | // Enable modern syntax 15 | modules: false, // Let webpack handle modules 16 | debug: false, 17 | // Include more modern features 18 | include: [ 19 | '@babel/plugin-proposal-optional-chaining', 20 | '@babel/plugin-proposal-nullish-coalescing-operator', 21 | '@babel/plugin-proposal-logical-assignment-operators' 22 | ] 23 | } 24 | ] 25 | ], 26 | plugins: [ 27 | // Support for class properties and private methods (using assumptions instead of loose) 28 | '@babel/plugin-proposal-class-properties', 29 | '@babel/plugin-proposal-private-methods', 30 | '@babel/plugin-transform-private-property-in-object', 31 | // Support for decorators (if needed) 32 | ['@babel/plugin-proposal-decorators', { legacy: true }], 33 | // Modern JavaScript operators 34 | '@babel/plugin-proposal-optional-chaining', 35 | '@babel/plugin-proposal-nullish-coalescing-operator', 36 | '@babel/plugin-proposal-logical-assignment-operators' 37 | ], 38 | // Enable modern JavaScript features 39 | assumptions: { 40 | setPublicClassFields: true, 41 | privateFieldsAsSymbols: true 42 | } 43 | }; 44 | -------------------------------------------------------------------------------- /features/columns-layout.md: -------------------------------------------------------------------------------- 1 | # Columns Layout Paradigm Feature 2 | 3 | ### Overview 4 | Implemented a new layout paradigm option that allows users to organize frames in a **columns-based layout** instead of the traditional rows-based approach. This feature is particularly beneficial for presentation designs and wide-format layouts where vertical organization is more space-efficient and visually appealing. 5 | 6 | --- 7 | 8 | ### User Request 9 | > "Love Super Tidy, but wondering if you could have a toggle to do the same thing, but with a Columns paradigm, instead of just Rows. I and a lot of my colleagues use Figma to build presentations, which have a wide format. Dividing sections up in columns saves space, and feels nicer to me sometimes. Thanks for your time!" 10 | 11 | --- 12 | 13 | ### Feature Implementation 14 | 15 | #### **User Experience** 16 | - **New Preference**: "Layout paradigm" selector in Preferences tab 17 | - **Two Options**: 18 | - **Rows** (default): Traditional horizontal flow, good for mobile designs 19 | - **Columns**: Vertical flow, ideal for presentations and wide formats 20 | - **Universal Support**: All Super Tidy actions respect the selected paradigm: 21 | - Tidy (repositioning) 22 | - Rename (numbering) 23 | - Reorder (layer organization) 24 | - Pager (text variable replacement) 25 | 26 | #### **Layout Behavior Comparison** 27 | 28 | **Rows Layout (Original):** 29 | ``` 30 | Frame1 Frame2 Frame3 31 | Frame4 Frame5 Frame6 32 | ``` 33 | - Organizes left-to-right, then top-to-bottom 34 | - Numbers: 1, 2, 3, 4, 5, 6 35 | - Best for: Mobile designs, narrow layouts 36 | 37 | **Columns Layout (New):** 38 | ``` 39 | Frame1 Frame4 40 | Frame2 Frame5 41 | Frame3 Frame6 42 | ``` 43 | - Organizes top-to-bottom, then left-to-right 44 | - Numbers: 1, 2, 3, 4, 5, 6 (same sequence, different spatial arrangement) 45 | - Best for: Presentations, wide layouts, dashboard designs 46 | 47 | --- 48 | 49 | ### Technical Architecture 50 | 51 | #### **Code Refactoring** 52 | - **Extracted layout algorithms** from `Core.js` into `src/utils/LayoutUtils.js` 53 | - **Modularized functions** for better maintainability and testing 54 | - **Reduced Core.js complexity** as requested by user 55 | 56 | #### **New Utility Functions (`src/utils/LayoutUtils.js`)** 57 | 58 | **Core Functions:** 59 | - `getNodesGroupedbyPosition(nodes, layout)` - Groups nodes by rows or columns 60 | - `repositionNodes(groupedNodes, ...)` - Handles both layout paradigms 61 | - `reorderNodes(groupedNodes, layout, ...)` - Reorders based on paradigm 62 | - `applyPagerNumbers(groupedNodes, layout, ...)` - Numbers frames correctly 63 | - `applyRenameStrategy(groupedNodes, layout, ...)` - Renames with proper sequencing 64 | 65 | **Algorithm Differences:** 66 | - **Rows**: Sort by X → group by Y → process left-to-right, top-to-bottom 67 | - **Columns**: Sort by Y → group by X → process top-to-bottom, left-to-right 68 | 69 | #### **Preference Integration** 70 | - **Storage**: Added `layout_paradigm` to preferences object 71 | - **Default**: 'rows' for backward compatibility 72 | - **UI**: Select dropdown with clear descriptions 73 | - **Persistence**: Saved with other user preferences 74 | 75 | #### **Core.js Updates** 76 | - **Import**: Layout utility functions 77 | - **Parameters**: All command functions accept `layoutParadigm` parameter 78 | - **Defaults**: Fallback to 'rows' if preference not set 79 | - **Integration**: Both menu commands and UI actions use paradigm setting 80 | 81 | #### **UI Integration** 82 | - **PreferencesView.js**: Added layout paradigm selector 83 | - **App.js**: Pass layout paradigm attribute to preferences view 84 | - **Data Flow**: Core.js → App.js → PreferencesView.js → back to Core.js 85 | 86 | --- 87 | 88 | ### Implementation Details 89 | 90 | #### **Files Modified** 91 | 1. **`src/utils/LayoutUtils.js`** (NEW) 92 | - Pure layout algorithm functions 93 | - Support for both rows and columns paradigms 94 | - Modular, testable code structure 95 | 96 | 2. **`src/Core.js`** 97 | - Import layout utilities 98 | - Remove old algorithm code (cleaner codebase) 99 | - Add layout paradigm parameters to all commands 100 | - Update default preferences 101 | 102 | 3. **`src/ui/views/preferences/PreferencesView.js`** 103 | - Add layout paradigm selector UI 104 | - Update savePreferences() to capture new setting 105 | - Clear user-facing descriptions 106 | 107 | 4. **`src/App.js`** 108 | - Pass layoutparadigm attribute to preferences view 109 | - Ensure proper data binding 110 | 111 | #### **Backward Compatibility** 112 | - **Default behavior**: Unchanged for existing users 113 | - **Preference migration**: Automatic fallback to 'rows' 114 | - **No breaking changes**: All existing functionality preserved 115 | 116 | #### **Performance Considerations** 117 | - **Pure functions**: All layout algorithms are stateless 118 | - **Efficient sorting**: Optimized for typical Figma selection sizes 119 | - **Memory usage**: No additional overhead for rows layout 120 | 121 | --- 122 | 123 | ### Benefits & Use Cases 124 | 125 | #### **For Presentation Designers** 126 | - **Wide layouts**: Better organization for 16:9 and wider formats 127 | - **Space efficiency**: Vertical stacking saves horizontal real estate 128 | - **Visual flow**: More natural reading pattern for presentation content 129 | 130 | #### **For Dashboard Designers** 131 | - **Card layouts**: Column organization for dashboard components 132 | - **Responsive thinking**: Easier to visualize mobile-first approaches 133 | - **Content grouping**: Logical vertical groupings 134 | 135 | #### **For All Users** 136 | - **Choice**: Flexibility to pick the best paradigm per project 137 | - **Consistency**: Same numbering logic, different spatial arrangement 138 | - **Familiarity**: Easy toggle, no learning curve 139 | 140 | --- 141 | 142 | ### Quality Assurance 143 | 144 | #### **Testing Scenarios** 145 | - ✅ Rows layout maintains original behavior 146 | - ✅ Columns layout works with all commands (Tidy, Rename, Reorder, Pager) 147 | - ✅ Preference saving and loading 148 | - ✅ Default fallback for new/migrated users 149 | - ✅ Build process successful 150 | - ✅ No linting errors 151 | 152 | #### **Edge Cases Handled** 153 | - ✅ Missing layout_paradigm preference (defaults to 'rows') 154 | - ✅ Invalid preference values (fallback behavior) 155 | - ✅ Single frame selections 156 | - ✅ Complex nested groupings 157 | 158 | #### **Code Quality** 159 | - ✅ Pure functions for testability 160 | - ✅ Clear separation of concerns 161 | - ✅ Consistent naming conventions 162 | - ✅ Proper error handling 163 | 164 | --- 165 | 166 | ### Future Enhancements (Optional) 167 | 168 | #### **Potential Improvements** 169 | - [ ] **Grid layout**: Fixed grid with user-defined columns/rows 170 | - [ ] **Custom spacing**: Different X/Y spacing for columns vs rows 171 | - [ ] **Visual preview**: Show layout paradigm preview in preferences 172 | - [ ] **Smart detection**: Auto-suggest paradigm based on selection aspect ratio 173 | - [ ] **Mixed layouts**: Hybrid approaches for complex designs 174 | 175 | #### **Advanced Features** 176 | - [ ] **Layout templates**: Saved layout configurations 177 | - [ ] **Responsive breakpoints**: Different paradigms for different screen sizes 178 | - [ ] **Animation**: Smooth transitions between paradigms 179 | - [ ] **Batch operations**: Apply different paradigms to different selections 180 | 181 | --- 182 | 183 | ### Technical Notes 184 | 185 | #### **Architecture Decisions** 186 | - **Pure functions**: Easier to test and reason about 187 | - **Parameter passing**: Explicit layout paradigm parameter vs global state 188 | - **Fallback strategy**: Conservative defaults for reliability 189 | - **Code organization**: Separate utility file for better maintainability 190 | 191 | #### **Memory Considerations** 192 | - Follows project architecture principles: 193 | - Core.js handles pure Figma Canvas and Storage APIs 194 | - UI logic remains in appropriate components 195 | - No cross-contamination of concerns 196 | 197 | #### **Performance Impact** 198 | - **Minimal overhead**: Sorting algorithms are O(n log n) 199 | - **Memory efficient**: No additional data structures for rows mode 200 | - **Cache friendly**: Preference stored once, used multiple times 201 | 202 | --- 203 | 204 | ### Status: ✅ **Production Ready** 205 | 206 | The columns layout paradigm feature is fully implemented and ready for production use. It addresses the user's specific request while maintaining backward compatibility and improving overall code organization. The feature provides significant value for presentation designers and wide-format layouts while preserving the familiar experience for existing users. 207 | 208 | #### **Deployment Checklist** 209 | - [x] Feature implementation complete 210 | - [x] Code refactoring successful 211 | - [x] Build process verified 212 | - [x] No linting errors 213 | - [x] Backward compatibility maintained 214 | - [x] User interface updated 215 | - [x] Preference integration working 216 | 217 | The feature enhances Super Tidy's versatility and directly addresses the presentation design workflow pain point identified by the user community. 218 | -------------------------------------------------------------------------------- /features/countdown.md: -------------------------------------------------------------------------------- 1 | ### Super Tidy Pro: Countdown Monetization System 2 | 3 | #### Overview 4 | Implement a premium licensing system with countdown gates for free users and instant execution for licensed users. The system uses Gumroad for payments and license management, with a client-side verification approach optimized for performance and user experience. 5 | 6 | #### Product Goals 7 | - **Premium Experience**: Licensed users get instant command execution with zero friction 8 | - **Conversion Optimization**: Free users experience 6-15 second countdown with seamless upgrade path 9 | - **Reliable Architecture**: Robust data flow with comprehensive error tracking and observability 10 | - **Scalable Foundation**: Clean separation of concerns and reusable architectural patterns 11 | 12 | --- 13 | 14 | ### User Experience 15 | - **Licensed user** 16 | - Launch any command (including menu commands) → runs immediately, no countdown. 17 | - **Unlicensed user** 18 | - Launch any command → countdown appears within the Actions view showing: 19 | - Animated analog chronometer with dynamic ticks 20 | - Digital timer countdown 21 | - "Get Super Tidy Pro" button → navigate to License tab for purchase/activation 22 | - "Run Now" button (disabled until countdown completes) 23 | - Options: 24 | - Wait until countdown completes → "Run Now" button enables, click to execute command 25 | - Click "Get Super Tidy Pro" → go to License tab, purchase on Gumroad, activate with license key 26 | - If purchase is canceled → return to countdown state 27 | - **License Management** 28 | - Dedicated "License" tab in toolbar for activation, viewing license info, and unlinking 29 | - 2-device usage limit enforced via Gumroad's usage count system 30 | - License info displays email, license key, activation date, and device usage (X/2 devices) 31 | 32 | --- 33 | 34 | ### Trigger and Timing Guarantees 35 | - Countdown appears only after a user explicitly triggers a command (UI form submission or menu command). 36 | - Command execution is deferred until countdown completes and user manually clicks "Run Now". 37 | - Treat the countdown as an interstitial gate between invocation and execution; the command is queued and runs only upon manual proceed. 38 | - Closing the plugin/countdown before proceed means the command does not run. 39 | - Applies to all commands: UI form actions and direct menu commands. Licensed users bypass the gate entirely. 40 | 41 | --- 42 | 43 | ### Technical Architecture 44 | 45 | #### **Design Principles** 46 | - **Unidirectional Data Flow**: Data flows from Core → App → Components via props 47 | - **Separation of Concerns**: Core.js handles Figma APIs, UI handles presentation logic 48 | - **Centralized Storage**: Single utility manages all clientStorage operations with validation 49 | - **Observability First**: Comprehensive error tracking and analytics integration 50 | - **Memory Efficiency**: Proper cleanup patterns and singleton utilities 51 | 52 | #### **Component Responsibilities** 53 | 54 | **FormView.js** (Primary gating component): 55 | - Contains all countdown state and rendering logic 56 | - Handles form submission with license gate checks 57 | - Manages countdown timer and analog chronometer 58 | - Renders either form or countdown based on `showingCountdown` state 59 | - Supports both UI-initiated and direct (menu) countdowns via callback parameter 60 | - Imports and displays AnalogChronometer component 61 | 62 | **Core.js** (Figma API Layer): 63 | - Manages all Figma Canvas and Storage APIs exclusively 64 | - Handles license storage using centralized Storage utility 65 | - Implements command gating with `ensureDirectCommandGate` 66 | - Includes license data in all initialization messages 67 | - Processes license activation/removal via postMessage 68 | 69 | **App.js** (Data Orchestration): 70 | - Receives initialization data from Core.js including license status 71 | - Stores license and preferences in component state 72 | - Passes data to child components via HTML attributes 73 | - Handles direct countdown coordination for menu commands 74 | - Manages view routing and component lifecycle 75 | 76 | **FormView.js** (Primary UI & Gating): 77 | - Embedded countdown state within main form interface 78 | - Receives license data via props from App.js 79 | - Manages countdown timer and analog chronometer display 80 | - Handles both UI-initiated and menu-triggered countdowns 81 | - Updates license cache directly from props 82 | 83 | **LicenseView.js** (License Management): 84 | - Dual-state rendering: activation form vs license info display 85 | - Receives current license data via props from App.js 86 | - Integrates with Gumroad API for license verification 87 | - Manages license activation and device unlinking 88 | - Provides user support and troubleshooting access 89 | 90 | **AnalogChronometer.js** (Visual countdown): 91 | - Animated analog clock with red needle 92 | - Dynamic tick generation based on total seconds 93 | - Attribute-based updates (`total-seconds`, `current-seconds`) 94 | - Clean white circle design with red accents 95 | 96 | #### **Storage Architecture** 97 | 98 | **Centralized Storage Utility** (`src/utils/Storage.js`): 99 | - Constructor-based singleton pattern initialized from Core.js 100 | - Map-based key validation prevents runtime errors 101 | - Automatic error tracking for all storage operations 102 | - Promise-based API using then/catch for compatibility 103 | - Batch operations (getMultiple/setMultiple) for efficiency 104 | 105 | **Storage Key Management**: 106 | ```javascript 107 | // Defined in Core.js 108 | const STORAGE_KEYS = { 109 | UUID: 'UUID', 110 | PREFERENCES: 'preferences', 111 | LICENSE_V1: 'LICENSE_V1', 112 | AD_LAST_SHOWN_DATE: 'AD_LAST_SHOWN_DATE', 113 | AD_LAST_SHOWN_IMPRESSION: 'AD_LAST_SHOWN_IMPRESSION' 114 | } 115 | 116 | // Usage throughout application 117 | Storage.get(Storage.getKey('LICENSE_V1')) 118 | Storage.set(Storage.getKey('UUID'), value) 119 | ``` 120 | 121 | **Error Tracking Integration**: 122 | All storage failures automatically emit tracking events: 123 | ```javascript 124 | { 125 | type: 'tracking-event', 126 | event: 'storage-operation-failed', 127 | properties: { 128 | operation: 'get|set|remove', 129 | key: 'LICENSE_V1', 130 | error: 'Detailed error message', 131 | timestamp: Date.now() 132 | } 133 | } 134 | ``` 135 | 136 | #### **Data Flow Architecture** 137 | 138 | **Initialization Flow**: 139 | ``` 140 | Core.js → Storage.getMultiple() → Include in init messages → App.js → Props → Components 141 | ``` 142 | 143 | **License Activation Flow**: 144 | ``` 145 | LicenseView → Gumroad API → activateLicense() → Core.js → Storage.set() → Cache Update 146 | ``` 147 | 148 | **Command Execution Flow**: 149 | ``` 150 | User Action → Gate Check → [Licensed: Execute] | [Unlicensed: Countdown → Manual Execute] 151 | ``` 152 | 153 | #### **Gating Implementation** 154 | 1. **User triggers command** (UI form or menu) 155 | 2. **License check** via cached status in `shouldShowCountdown()` 156 | 3. **Licensed path**: Execute command immediately 157 | 4. **Unlicensed path**: 158 | - FormView shows countdown state (embedded in current view) 159 | - User remains on current tab throughout countdown 160 | - After countdown completion: "Run Now" button enables 161 | - Manual execution trigger required 162 | 163 | #### **Menu Command Integration** 164 | - Core.js wraps all menu commands with `ensureDirectCommandGate` 165 | - Shows UI and includes license data in `init-direct` message 166 | - App.js coordinates FormView countdown with execution callback 167 | - Licensed users bypass countdown entirely with immediate execution 168 | 169 | --- 170 | 171 | ### Gumroad Integration (Client + Netlify Proxy) 172 | 173 | #### **Product Setup** 174 | - One-time product on Gumroad with Software Licensing enabled 175 | - 2-device usage limit per license 176 | - OAuth access token stored in `secrets.json` (server-side only) 177 | 178 | #### **License Verification** 179 | - **Endpoint**: `POST https://api.gumroad.com/v2/licenses/verify` 180 | - **Client-side call** with form-encoded body: 181 | - `product_id`: Gumroad product ID 182 | - `license_key`: user-entered license key 183 | - `increment_uses_count`: 'true' (for activation) 184 | - **Validation criteria**: 185 | - `success === true` 186 | - `purchase.refunded !== true` 187 | - `purchase.chargebacked !== true` 188 | - `uses < 2` (2-device limit) 189 | 190 | #### **Usage Count Management** 191 | - **Increment**: Done during license verification (`increment_uses_count: 'true'`) 192 | - **Decrement**: Via Netlify Function proxy at `https://figma-plugins-display-network.netlify.app/api/licenses/decrement_uses_count` 193 | - **Method**: PUT with JSON body 194 | - **Auth**: Server-side OAuth token (secure) 195 | - **Used for**: License unlinking to free up device slots 196 | 197 | #### **Local Storage Schema** 198 | - **Key**: `LICENSE_V1` in `figma.clientStorage` 199 | - **Value**: 200 | ```json 201 | { 202 | "licensed": true, 203 | "productId": "string", 204 | "licenseKeyHash": "string", 205 | "deviceId": "string", 206 | "activatedAt": 1234567890, 207 | "purchase": { 208 | "email": "user@example.com", 209 | "id": "purchase_id" 210 | }, 211 | "uses": 1 212 | } 213 | ``` 214 | 215 | --- 216 | 217 | ### Network Access & Security 218 | 219 | #### **Manifest Configuration** 220 | ```json 221 | { 222 | "networkAccess": { 223 | "allowedDomains": [ 224 | "https://api.gumroad.com", 225 | "https://*.amplitude.com", 226 | "https://figma-plugins-display-network.netlify.app/" 227 | ] 228 | } 229 | } 230 | ``` 231 | 232 | #### **Security Measures** 233 | - OAuth access token never exposed to client-side code 234 | - License keys hashed before storage (`hashLicenseKey` function) 235 | - Sensitive API calls proxied through Netlify Functions 236 | - 2-device limit prevents unlimited license sharing 237 | 238 | --- 239 | 240 | ### UI/UX Implementation Details 241 | 242 | #### **Countdown Visual Design** 243 | - **Container**: Centered layout within FormView, maintains "Actions" tab active state 244 | - **Analog Chronometer**: White circle, red needle, dynamic ticks for each second 245 | - **Digital Timer**: Tabular numbers, positioned over chronometer 246 | - **Buttons**: "Run Now" (disabled until complete), "Get Super Tidy Pro" (always enabled) 247 | - **Copy**: "Get Super Tidy Pro to skip the countdown" with lifetime purchase messaging 248 | 249 | #### **License Tab** 250 | - **Unlicensed State**: Input field, "Activate" button, validation messaging 251 | - **Licensed State**: License info display, device usage counter, "Unlink License" button 252 | - **Support**: Link to Google Form for assistance 253 | 254 | #### **Navigation Behavior** 255 | - Countdown embedded in FormView keeps user on "Actions" tab 256 | - No "ghost screen" - user always sees familiar navigation 257 | - License tab accessible during countdown for immediate activation 258 | 259 | --- 260 | 261 | ### Implementation History & Lessons Learned 262 | 263 | #### **Architecture Evolution** 264 | 1. **Initial**: Countdown as separate route with Router navigation 265 | 2. **Refined**: Gating moved from Core.js to FormView.js for clean separation 266 | 3. **Final**: Countdown embedded within FormView as state, not separate route 267 | 4. **Latest**: Removed Router dependency, App.js directly calls FormView countdown methods 268 | 269 | #### **API Integration Challenges** 270 | 1. **Gumroad CORS**: Client-side `decrement_uses_count` blocked by security policies 271 | 2. **Solution**: Netlify Function proxy for authenticated endpoints 272 | 3. **Content-Type**: Some endpoints require `application/x-www-form-urlencoded` 273 | 274 | #### **License Storage & Reliability Issues** 275 | 1. **Race Conditions**: FormView event listeners were accumulating without cleanup 276 | 2. **Data Consistency**: Usage count not properly stored during license activation 277 | 3. **Silent Failures**: Storage operations lacked error handling and user feedback 278 | 4. **Complex Timeout Patterns**: Duplicated postMessage request/response cycles with timeout cleanup 279 | 5. **Memory Leaks**: Event listeners not properly cleaned up in license retrieval 280 | 6. **Solution**: Implemented proper event listener cleanup, comprehensive error handling, consistent data format, and eliminated postMessage complexity 281 | 282 | #### **Key Technical Decisions** 283 | - **Memory over Storage**: License status cached in `gate.js` for performance 284 | - **Direct Method Calls**: Eliminated internal UI postMessage for simplicity 285 | - **Callback Pattern**: Unified interface for UI and menu-initiated countdowns 286 | - **State Management**: LEO Element data properties for countdown state 287 | - **Event Listener Management**: Proper cleanup to prevent memory leaks and race conditions 288 | - **Error Handling**: Comprehensive error handling for all storage operations with user feedback 289 | - **Centralized Storage**: Storage utility with automatic error tracking and validation 290 | - **Data Down, Events Up**: License data flows from Core → App → Components via props 291 | - **Unidirectional Data Flow**: Eliminated complex postMessage request/response cycles 292 | 293 | --- 294 | 295 | ### Current Status (Fully Implemented) 296 | 297 | #### **✅ Completed Features** 298 | - [x] Basic countdown with random 6-15s timing 299 | - [x] Embedded countdown within FormView (no separate route) 300 | - [x] Animated analog chronometer with dynamic ticks 301 | - [x] Manual "Run Now" button after countdown completion 302 | - [x] License tab with dual-state rendering 303 | - [x] Gumroad license verification and activation 304 | - [x] ~~2-device usage limit with Netlify Function proxy~~ (Temporarily disabled for easier management) 305 | - [x] License info display ~~and device usage tracking~~ (Device tracking temporarily hidden) 306 | - [x] License unlinking with usage count decrement 307 | - [x] Menu command gating for direct Figma menu access 308 | - [x] License status caching for fast gate decisions 309 | - [x] Clean architecture with proper separation of concerns 310 | - [x] Removed Router dependency for countdown navigation 311 | - [x] Fixed license storage/retrieval race conditions and reliability issues 312 | - [x] Comprehensive error handling with user feedback for all storage operations 313 | - [x] Proper event listener cleanup to prevent memory leaks 314 | - [x] **NEW**: Centralized Storage utility with automatic error tracking 315 | - [x] **NEW**: Eliminated postMessage timeout patterns for license retrieval 316 | - [x] **NEW**: Unidirectional data flow (Core → App → Components) 317 | - [x] **NEW**: Comprehensive storage operation tracking and analytics 318 | 319 | #### **✅ Tested Scenarios** 320 | - [x] Licensed user: immediate command execution 321 | - [x] Unlicensed user: countdown → manual execution 322 | - [x] License activation: Gumroad API integration 323 | - [x] Menu commands: gating works from Figma menu 324 | - [x] ~~Device limits: 2-device enforcement~~ (Temporarily disabled) 325 | - [x] License unlinking: usage count decremented 326 | - [x] Error handling: invalid keys, API failures 327 | - [x] License storage reliability: consistent save/load across plugin sessions 328 | - [x] Memory management: no event listener leaks or race conditions 329 | - [x] Error recovery: proper user feedback for storage failures 330 | - [x] **NEW**: Storage error tracking and analytics integration 331 | - [x] **NEW**: License data propagation through init messages 332 | - [x] **NEW**: Props-based license data flow without postMessage complexity 333 | 334 | #### **🎯 Production Ready** 335 | The countdown monetization system is fully implemented and functional, with: 336 | - Clean user experience (no ghost screens, embedded countdown) 337 | - Robust and reliable license management with comprehensive error handling 338 | - Secure API integration via Netlify proxy 339 | - Proper error handling and edge cases with user feedback 340 | - Analytics integration (Amplitude) 341 | - Professional UI/UX matching plugin design system 342 | - Memory-efficient architecture with proper cleanup 343 | - Consistent license storage/retrieval across all scenarios 344 | 345 | --- 346 | 347 | ### Deployment Checklist 348 | 349 | #### **Pre-Release** 350 | - [ ] Test with real Gumroad product and licenses 351 | - [ ] Verify Netlify Function deployment and access token 352 | - [ ] Confirm analytics tracking for conversion funnel 353 | - [ ] Test edge cases: offline, invalid keys, API timeouts 354 | 355 | #### **Release Notes** 356 | - Introduce Super Tidy Pro with one-time license purchase 357 | - 2-device usage limit per license 358 | - Countdown for free users with manual execution 359 | - Dedicated license management tab 360 | - Seamless experience for licensed users 361 | 362 | #### **Support Documentation** 363 | - Purchase flow: Gumroad → license key → activation 364 | - Device management: viewing usage, unlinking devices 365 | - Troubleshooting: invalid keys, usage limits, API errors 366 | - Contact: Google Form for license support 367 | 368 | --- 369 | 370 | ### Development Best Practices 371 | 372 | #### **Storage Operations** 373 | - Always use centralized Storage utility with key validation 374 | - Handle both success and failure cases with user feedback 375 | - Use batch operations for related data updates 376 | - Initialize all keys in Core.js, avoid hardcoded strings 377 | 378 | #### **Data Flow Patterns** 379 | - Prefer "data down, events up" architecture 380 | - Include data in initialization messages rather than separate requests 381 | - Use component props/attributes instead of complex postMessage cycles 382 | - Maintain single source of truth for application state 383 | 384 | #### **Error Handling & Observability** 385 | - Emit tracking events for all error conditions 386 | - Provide specific, actionable error messages to users 387 | - Log contextual information for effective debugging 388 | - Distinguish between recoverable and fatal error types 389 | 390 | #### **Memory & Performance** 391 | - Clean up event listeners in component lifecycle methods 392 | - Use singleton patterns to prevent resource duplication 393 | - Prefer direct method calls over complex async coordination 394 | - Implement proper component cleanup patterns 395 | 396 | #### **Code Organization** 397 | - Separate Figma API operations from UI logic 398 | - Create reusable utilities for common patterns 399 | - Use constructor-based classes for stateful services 400 | - Implement comprehensive input validation with clear error messages 401 | 402 | --- 403 | 404 | ### Implementation Phases 405 | 406 | #### **Phase 1: Core Infrastructure** 407 | - Centralized Storage utility implementation with key validation 408 | - Basic countdown timer and gating logic 409 | - License data flow architecture via props 410 | - Error tracking and analytics integration 411 | 412 | #### **Phase 2: Gumroad Integration** 413 | - License verification API integration 414 | - Secure key management and hashing 415 | - Purchase flow and activation process 416 | - Device management capabilities 417 | 418 | #### **Phase 3: User Experience Polish** 419 | - Analog chronometer animation implementation 420 | - Visual design and interaction refinement 421 | - Comprehensive error handling and user messaging 422 | - Performance optimization and testing 423 | 424 | #### **Phase 4: Analytics & Optimization** 425 | - Conversion funnel implementation 426 | - A/B testing framework setup 427 | - Performance monitoring and alerting 428 | - User feedback collection and analysis 429 | 430 | ### Testing Strategy 431 | 432 | #### **Automated Testing** 433 | - Unit tests for Storage utility and core functions 434 | - Integration tests for license verification flow 435 | - Performance tests for countdown and activation timing 436 | - Error scenario testing for network and storage failures 437 | 438 | #### **Manual Testing Scenarios** 439 | - Licensed user: instant command execution across all entry points 440 | - Unlicensed user: countdown completion and manual execution 441 | - License activation: Gumroad integration and key validation 442 | - Menu commands: gating functionality from Figma menu 443 | - Error recovery: invalid keys, network failures, storage issues 444 | 445 | #### **User Acceptance Testing** 446 | - Conversion flow optimization and usability 447 | - User interface accessibility and cross-platform compatibility 448 | - Performance under various system conditions 449 | - Edge case handling and error recovery 450 | 451 | --- 452 | 453 | ### Future Enhancements 454 | 455 | #### **Advanced Features** 456 | - Server-side license validation for enhanced security 457 | - Team license management and bulk operations 458 | - License transfer capabilities between devices 459 | - Advanced usage analytics and reporting 460 | - Multiple license tiers and feature sets 461 | 462 | #### **Technical Improvements** 463 | - Automated testing framework expansion 464 | - Performance optimization for large selections 465 | - Modern framework migration considerations 466 | - Enhanced retry mechanisms for critical operations 467 | - Storage integrity verification systems 468 | 469 | #### **Business Expansion** 470 | - Subscription model exploration 471 | - Partner integration opportunities 472 | - International market expansion 473 | - Advanced conversion optimization 474 | 475 | ### Success Metrics 476 | 477 | #### **Primary KPIs** 478 | - License conversion rate from free to premium users 479 | - User retention and engagement metrics 480 | - Revenue per user and lifetime value 481 | - Customer satisfaction and support efficiency 482 | 483 | #### **Technical KPIs** 484 | - System uptime and reliability metrics 485 | - License activation success rate 486 | - Storage operation performance and error rates 487 | - User experience quality indicators 488 | 489 | --- 490 | 491 | ### References & Resources 492 | - **LEO UI Library**: https://github.com/basiclines/leo 493 | - **Gumroad API Documentation**: https://gumroad.com/api#verify-license-key 494 | - **Netlify Functions**: https://docs.netlify.com/functions/overview/ 495 | - **Figma Plugin API**: https://www.figma.com/plugin-docs/ 496 | - **Project Repository**: Private - contact for access -------------------------------------------------------------------------------- /figma/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Super Tidy", 3 | "id": "731260060173130163", 4 | "api": "1.0.0", 5 | "ui": "dist/index.html", 6 | "main": "dist/core.js", 7 | "documentAccess": "dynamic-page", 8 | "networkAccess": { 9 | "allowedDomains": [ 10 | "https://*.amplitude.com", 11 | "https://*.netlify.app/", 12 | "https://rsms.me/", 13 | "https://api.gumroad.com" 14 | ] 15 | }, 16 | "menu": [ 17 | {"name": "Rename", "command": "rename"}, 18 | {"name": "Reorder", "command": "reorder"}, 19 | {"name": "Tidy", "command": "tidy"}, 20 | {"name": "Pager", "command": "pager"}, 21 | {"name": "Run all", "command": "all"}, 22 | {"separator": true}, 23 | {"name": "Open plugin (custom run)...", "command": "options"} 24 | ], 25 | "editorType": ["figma", "figjam"] 26 | } 27 | -------------------------------------------------------------------------------- /netlify.toml: -------------------------------------------------------------------------------- 1 | [build] 2 | 3 | # Directory that contains the deploy-ready HTML files and assets 4 | publish = "penpot/" 5 | 6 | # Default build command 7 | command = "bun run build:penpot" 8 | 9 | [build.environment] 10 | NODE_VERSION = "20" 11 | 12 | # Headers for security and performance 13 | [[headers]] 14 | for = "/*" 15 | [headers.values] 16 | Access-Control-Allow-Origin = "*" 17 | Access-Control-Allow-Methods = "GET, POST, PUT, DELETE, OPTIONS" 18 | Access-Control-Allow-Headers = "Content-Type, Authorization, X-Requested-With" 19 | 20 | # Cache static assets 21 | [[headers]] 22 | for = "/assets/*" 23 | [headers.values] 24 | Cache-Control = "public, max-age=31536000, immutable" 25 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "version": "2.2.1", 3 | "scripts": { 4 | "dev:figma": "NODE_ENV=development DESIGN_TOOL=figma npx webpack --mode=development --watch", 5 | "build:figma": "NODE_ENV=production DESIGN_TOOL=figma npx webpack --mode=production", 6 | "dev:penpot": "NODE_ENV=development DESIGN_TOOL=penpot npx webpack --mode=development --watch & npx serve penpot --cors", 7 | "build:penpot": "NODE_ENV=production DESIGN_TOOL=penpot npx webpack --mode=production", 8 | "deploy:prod:penpot": "netlify deploy --prod", 9 | "test": "echo \"Error: no test specified\" && exit 1" 10 | }, 11 | "devDependencies": { 12 | "@babel/core": "^7.23.0", 13 | "@babel/plugin-proposal-class-properties": "^7.18.6", 14 | "@babel/plugin-proposal-decorators": "^7.23.0", 15 | "@babel/plugin-proposal-logical-assignment-operators": "^7.20.7", 16 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.18.6", 17 | "@babel/plugin-proposal-optional-chaining": "^7.21.0", 18 | "@babel/plugin-proposal-private-methods": "^7.18.6", 19 | "@babel/plugin-transform-private-property-in-object": "^7.21.0", 20 | "@babel/preset-env": "^7.23.0", 21 | "babel-loader": "^9.1.3", 22 | "css-loader": "^6.8.1", 23 | "html-inline-script-webpack-plugin": "^3.2.1", 24 | "html-webpack-plugin": "^5.5.3", 25 | "style-loader": "^3.3.3", 26 | "webpack": "^5.89.0", 27 | "webpack-cli": "^5.1.4" 28 | }, 29 | "dependencies": { 30 | "@basiclines/leo": "^0.6.4", 31 | "core-js": "^3.33.0", 32 | "netlify-cli": "^23.9.1", 33 | "ua-parser-js": "^0.7.24", 34 | "webpack-dev-server": "^4.15.1" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /penpot/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/basiclines/plugin-super-tidy/144d3aae2bbea1cca74e3d39d2dbea0ae5f65da3/penpot/icon.png -------------------------------------------------------------------------------- /penpot/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Super Tidy", 3 | "description": "Rename, reorder and tidy all your nodes in 1 click", 4 | "code": "./dist/core.js", 5 | "icon": "./icon.png", 6 | "permissions": [ 7 | "content:read", 8 | "content:write", 9 | "allow:localstorage" 10 | ] 11 | } -------------------------------------------------------------------------------- /secrets_default.json: -------------------------------------------------------------------------------- 1 | { 2 | "README": "DUPLICATE ME AS secrets.json", 3 | "AMPLITUDE_KEY": "" 4 | } 5 | -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | @font-face { 2 | font-family:'Inter'; 3 | font-style: normal; 4 | font-weight: 400; 5 | src: url("https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.7") format("woff2"), url("https://rsms.me/inter/font-files/Inter-Regular.woff?v=3.7") format("woff") 6 | } 7 | 8 | @font-face { 9 | font-family:'Inter'; 10 | font-style: normal; 11 | font-weight: 500; 12 | src: url("https://rsms.me/inter/font-files/Inter-Medium.woff2?v=3.7") format("woff2"), url("https://rsms.me/inter/font-files/Inter-Medium.woff2?v=3.7") format("woff") 13 | } 14 | 15 | @font-face { 16 | font-family: 'Inter'; 17 | font-style: normal; 18 | font-weight: 600; 19 | src: url("https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=3.7") format("woff2"), url("https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=3.7") format("woff") 20 | } 21 | 22 | :root { 23 | --type-small-normal: 400 11px/16px "Inter", sans-serif; 24 | --type-small-medium: 500 11px/16px "Inter", sans-serif; 25 | --type-small-bold: 600 11px/16px "Inter", sans-serif; 26 | --type-medium-normal: 400 12px/16px "Inter", sans-serif; 27 | --type-medium-medium: 500 12px/16px "Inter", sans-serif; 28 | --type-medium-bold: 600 12px/16px "Inter", sans-serif; 29 | --type-large-normal: 400 13px/24px "Inter", sans-serif; 30 | --type-large-medium: 500 13px/24px "Inter", sans-serif; 31 | --type-large-bold: 600 13px/24px "Inter", sans-serif; 32 | --type-xlarge-normal: 400 14px/24px "Inter", sans-serif; 33 | --type-xlarge-medium: 500 14px/24px "Inter", sans-serif; 34 | --type-xlarge-bold: 600 14px/24px "Inter", sans-serif; 35 | 36 | --color-interactive-positive: #18A0FB; 37 | --color-interactive-negative: #E41761; 38 | --color-text-primary: #03121C; 39 | --color-text-secondary: #64646C; 40 | --color-text-tertiary: #AAAAB2; 41 | --color-background-primary: #FFFFFF; 42 | --color-decorator-strong: #C5CED2; 43 | --color-decorator-regular: #D5DDE2; 44 | --color-decorator-soft: #F1F4F4; 45 | --color-decorator-soft-invert: rgb(255, 255, 255, 0.48); 46 | --color-separator: rgba(0, 0, 0, 0.08); 47 | } 48 | 49 | /* Penpot Background */ 50 | root-ui[theme="dark"] { --color-background-primary: #18181a; } 51 | 52 | /* Figma Background */ 53 | .figma-dark body { --color-background-primary: var(--figma-color-bg); } 54 | 55 | /* Dark theme for both Penpot and Figma */ 56 | root-ui[theme="dark"], .figma-dark body { 57 | 58 | --color-interactive-positive: #18A0FB; 59 | --color-interactive-negative: #E41761; 60 | --color-text-primary: rgba(255, 255, 255, 0.9); 61 | --color-text-secondary: rgba(255, 255, 255, 0.75); 62 | --color-text-tertiary: rgba(255, 255, 255, 0.6); 63 | --color-decorator-strong: rgba(255, 255, 255, 0.32); 64 | --color-decorator-regular: rgba(255, 255, 255, 0.24); 65 | --color-decorator-soft: rgba(255, 255, 255, 0.16); 66 | --color-decorator-soft-invert: rgb(0, 0, 0, 0.48); 67 | --color-separator: rgba(255, 255, 255, 0.08); 68 | 69 | } 70 | 71 | /* Make FigmaUI.css icons dark in dark mode */ 72 | root-ui[theme="dark"] .icon, .figma-dark body .icon { filter: invert(1); } 73 | 74 | body { text-align: left; overflow: hidden; color: var(--color-text-primary); } 75 | root-ui { transition: opacity 0.05s linear; display: block; position: relative; height: 539px; background-color: var(--color-background-primary); } 76 | root-ui[notready] { opacity: 0; pointer-events: none; } 77 | 78 | .c-icon { 79 | display: inline-flex; 80 | align-items: center; 81 | justify-content: center; 82 | vertical-align: middle; 83 | width: 16px; 84 | height: 16px; 85 | pointer-events: none; 86 | } 87 | .c-icon img { width: 100%; height: 100%; } 88 | 89 | .view { 90 | display: block; 91 | position: absolute; 92 | top: 40px; 93 | left: 0; 94 | right: 0; 95 | bottom: 0; 96 | overflow-y: auto; 97 | } 98 | 99 | .switch .switch__container { flex-shrink: 0; align-self: flex-start; margin-top: 4px; } 100 | .switch__label p { color: var(--color-text-secondary); } 101 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import 'src/ui/Bulletproof.css' 2 | import 'src/App.css' 3 | import 'src/ui/FigmaUI.css' 4 | 5 | import Tracking from 'src/utils/Tracking' 6 | import Router from 'src/utils/Router' 7 | import FigPen from 'src/utils/FigPen' 8 | import Element from 'src/ui/Element' 9 | import { setCachedLicenseStatus } from 'src/payments/gate' 10 | import CONFIG from 'src/Config' 11 | 12 | import 'src/ui/components/toolbar/ToolbarComponent' 13 | import 'src/ui/views/form/FormView' 14 | import 'src/ui/views/preferences/PreferencesView' 15 | import 'src/ui/views/license/LicenseView' 16 | import 'src/ui/components/display/DisplayComponent' 17 | 18 | let FP = new FigPen(CONFIG) 19 | 20 | class ui extends Element { 21 | 22 | beforeMount() { 23 | 24 | FP.onEditorMessage(msg => { 25 | if (msg.type == 'init-hidden' || msg.type == 'init' || msg.type == 'init-direct') { 26 | this.data.preferences = msg.preferences 27 | this.data.license = msg.license 28 | this.attrs.theme = msg.theme 29 | // Update UI context license cache (separate from Core.js context) 30 | setCachedLicenseStatus(msg.license) 31 | 32 | Tracking.setup(WP_AMPLITUDE_KEY, msg.UUID) 33 | Tracking.track('openPlugin', { cmd: msg.cmd }) 34 | } 35 | 36 | if (msg.type == 'init') { 37 | this.insertDisplay(msg.AD_LAST_SHOWN_DATE, msg.AD_LAST_SHOWN_IMPRESSION) 38 | } 39 | 40 | // Handle direct countdown from Core.js (for menu commands) 41 | if (msg.type == 'start-direct-countdown') { 42 | this.handleDirectCountdown(msg.seconds, msg.commandName) 43 | } 44 | 45 | if (msg.type == 'tracking-event') { 46 | Tracking.track(msg.event, msg.properties) 47 | } 48 | }) 49 | 50 | Router.setup({ 51 | index: '#index', 52 | preferences: '#preferences', 53 | license: '#license' 54 | }) 55 | 56 | FP.initializeUI() 57 | } 58 | 59 | bind() { 60 | Router.on('change:url', url => this.showActiveView(url)) 61 | } 62 | 63 | insertDisplay(lastShownDate, lastShownImpression) { 64 | let elem = document.createElement('c-display') 65 | elem.setAttribute('lastshowndate', lastShownDate) 66 | elem.setAttribute('lastshownimpression', lastShownImpression) 67 | elem.setAttribute('hidden', '') 68 | document.body.insertBefore(elem, document.body.querySelector('root-ui')) 69 | } 70 | 71 | handleDirectCountdown(seconds, commandName) { 72 | // Navigate to index view (FormView) and call startCountdown directly 73 | Router.navigate(Router.routes.index) 74 | 75 | // Wait for the view to be rendered, then start countdown 76 | setTimeout(() => { 77 | const formView = document.querySelector('[data-view="index"]') 78 | if (formView && formView.startCountdown) { 79 | formView.startCountdown(seconds, commandName, () => { 80 | // Send completion message back to Core.js 81 | FP.notifyEditor({ 82 | type: 'direct-countdown-complete' 83 | }) 84 | }) 85 | } else { 86 | console.error('[App] FormView not found or startCountdown method not available') 87 | } 88 | }, 100) 89 | } 90 | 91 | 92 | 93 | showActiveView(url) { 94 | let viewName = url.replace('#', '') 95 | this.findAll('[data-view]').forEach(view => view.setAttribute('hidden', '')) 96 | const targetView = this.find(`[data-view="${viewName}"]`) 97 | if (targetView) { 98 | targetView.removeAttribute('hidden') 99 | } 100 | } 101 | 102 | escapeAttribute(str) { 103 | return str.replace(/"/g, '"').replace(/'/g, ''') 104 | } 105 | 106 | render() { 107 | if (!this.data.preferences) return ''; 108 | return` 109 | 110 | 111 | 120 | 121 | ` 122 | } 123 | } 124 | 125 | customElements.define('root-ui', ui) 126 | -------------------------------------------------------------------------------- /src/Config.js: -------------------------------------------------------------------------------- 1 | export default { 2 | name: 'Super Tidy', 3 | url: WP_PLUGIN_URL, 4 | designTool: WP_DESIGN_TOOL, 5 | width: 360, 6 | height: 540 7 | } -------------------------------------------------------------------------------- /src/Core.js: -------------------------------------------------------------------------------- 1 | import Tracking from 'src/utils/Tracking' 2 | import Storage from 'src/utils/Storage' 3 | 4 | import FigPen from 'src/utils/FigPen' 5 | import MessageBus from 'src/utils/MessageBus' 6 | 7 | import { shouldShowCountdown, getCountdownSeconds, setCachedLicenseStatus } from 'src/payments/gate' 8 | import { 9 | getNodesGroupedbyPosition, 10 | getNameByPosition, 11 | repositionNodes, 12 | reorderNodes, 13 | applyPagerNumbers, 14 | applyRenameStrategy 15 | } from 'src/utils/LayoutUtils' 16 | import CONFIG from 'src/Config' 17 | 18 | const UI_HEIGHT = 540 19 | 20 | // Initialize Storage with valid keys 21 | const STORAGE_KEYS = { 22 | UUID: 'UUID', 23 | PREFERENCES: 'preferences', 24 | AD_LAST_SHOWN_DATE: 'AD_LAST_SHOWN_DATE', 25 | AD_LAST_SHOWN_IMPRESSION: 'AD_LAST_SHOWN_IMPRESSION', 26 | LICENSE_V1: 'LICENSE_V1', 27 | SPACING: 'spacing' // legacy key 28 | } 29 | 30 | Storage.init(STORAGE_KEYS) 31 | const FP = new FigPen(CONFIG) 32 | const cmd = FP.currentCommand() 33 | const theme = FP.currentTheme() 34 | 35 | // Simple hash function for license keys (don't store raw keys) 36 | function hashLicenseKey(key) { 37 | try { 38 | return String(key).split('').reduce((a,c) => ((a<<5)-a+c.charCodeAt(0))|0, 0).toString(16) 39 | } catch (e) { 40 | return 'hash-error' 41 | } 42 | } 43 | 44 | // Helper function to validate selection for direct commands 45 | function validateSelectionForCommand(commandName) { 46 | const selection = FP.currentSelection() 47 | if (selection.length === 0) { 48 | FP.showNotification('Select some layers first to start using Super Tidy.') 49 | FP.closePlugin() 50 | return false 51 | } 52 | return true 53 | } 54 | 55 | // Helper function to handle gating for direct menu commands 56 | function ensureDirectCommandGate(commandName, executeCommand, preferences, UUID, license) { 57 | // First validate selection - skip everything if no selection 58 | if (!validateSelectionForCommand(commandName)) { 59 | return // Exit early, user already notified 60 | } 61 | 62 | if (shouldShowCountdown()) { 63 | // Show UI with countdown for unlicensed users 64 | FP.openUI() 65 | 66 | const seconds = getCountdownSeconds() 67 | 68 | // Send init message first so UI components are ready 69 | FP.notifyUI({ 70 | type: 'init-direct', 71 | UUID: UUID, 72 | cmd: commandName, 73 | preferences: preferences, 74 | license: license 75 | }) 76 | 77 | // Then send countdown start message 78 | setTimeout(() => { 79 | FP.notifyUI({ 80 | type: 'start-direct-countdown', 81 | seconds: seconds, 82 | commandName: commandName 83 | }) 84 | }, 50) 85 | 86 | 87 | // Bind direct countdown completion handler 88 | MessageBus.bind('direct-countdown-complete', (msg) => { 89 | let historyId = FP.startUndoBlock() 90 | executeCommand() 91 | FP.finishUndoBlock(historyId) 92 | FP.closePlugin() 93 | }) 94 | } else { 95 | // Execute immediately if licensed 96 | let historyId = FP.startUndoBlock() 97 | executeCommand() 98 | FP.finishUndoBlock(historyId) 99 | FP.closePlugin() 100 | } 101 | } 102 | 103 | 104 | 105 | function cmdRename(renameStrategy, startName, layoutParadigm = 'rows') { 106 | var selection = FP.currentSelection() 107 | var parent = (selection[0].type == 'PAGE') ? FP.currentPage() : selection[0].parent 108 | var allNodes = parent.children 109 | var groupedNodes = getNodesGroupedbyPosition(selection, layoutParadigm) 110 | 111 | applyRenameStrategy(groupedNodes, layoutParadigm, renameStrategy, startName, allNodes) 112 | } 113 | 114 | function cmdReorder(layoutParadigm = 'rows') { 115 | var selection = FP.currentSelection() 116 | var parent = (selection[0].type == 'PAGE') ? FP.currentPage() : selection[0].parent 117 | var allNodes = parent.children 118 | var groupedNodes = getNodesGroupedbyPosition(selection, layoutParadigm) 119 | 120 | reorderNodes(groupedNodes, layoutParadigm, parent, allNodes) 121 | } 122 | 123 | function cmdTidy(xSpacing, ySpacing, wrapInstances, layoutParadigm = 'rows') { 124 | var selection = FP.currentSelection() 125 | var parent = (selection[0].type == 'PAGE') ? FP.currentPage() : selection[0].parent 126 | var allNodes = parent.children 127 | var groupedNodes = getNodesGroupedbyPosition(selection, layoutParadigm) 128 | 129 | repositionNodes(groupedNodes, xSpacing, ySpacing, wrapInstances, layoutParadigm, allNodes) 130 | } 131 | 132 | function cmdPager(pager_variable, layoutParadigm = 'rows') { 133 | var selection = FP.currentSelection() 134 | var parent = (selection[0].type == 'PAGE') ? FP.currentPage() : selection[0].parent 135 | var allNodes = parent.children 136 | var groupedNodes = getNodesGroupedbyPosition(selection, layoutParadigm) 137 | 138 | applyPagerNumbers(groupedNodes, layoutParadigm, pager_variable, allNodes) 139 | } 140 | 141 | // Obtain UUID, preferences, and license then trigger init event 142 | Storage.getMultiple([ 143 | Storage.getKey('UUID'), 144 | Storage.getKey('PREFERENCES'), 145 | Storage.getKey('AD_LAST_SHOWN_DATE'), 146 | Storage.getKey('AD_LAST_SHOWN_IMPRESSION'), 147 | Storage.getKey('SPACING'), // legacy 148 | Storage.getKey('LICENSE_V1') // license data 149 | ]).then(storageData => { 150 | let UUID = storageData[Storage.getKey('UUID')] 151 | let preferencesSaved = storageData[Storage.getKey('PREFERENCES')] 152 | let AD_LAST_SHOWN_DATE = storageData[Storage.getKey('AD_LAST_SHOWN_DATE')] || 572083200 // initial date, if no date was saved previously 153 | let AD_LAST_SHOWN_IMPRESSION = storageData[Storage.getKey('AD_LAST_SHOWN_IMPRESSION')] || 0 // initial impressions 154 | let license = storageData[Storage.getKey('LICENSE_V1')] // license data 155 | 156 | let SPACING = { x: 100, y: 200 } 157 | let START_NAME = '000' 158 | let PAGER_VARIABLE = '{current}' 159 | let WRAP_INSTANCES = true 160 | let RENAME_STRATEGY_REPLACE = 'replace' 161 | let RENAME_STRATEGY_MERGE = 'merge' 162 | let LAYOUT_PARADIGM = 'rows' 163 | let DEFAULT_PREFERENCES = { 164 | spacing: SPACING, 165 | start_name: START_NAME, 166 | pager_variable: PAGER_VARIABLE, 167 | wrap_instances: WRAP_INSTANCES, 168 | rename_strategy: RENAME_STRATEGY_REPLACE, 169 | layout_paradigm: LAYOUT_PARADIGM 170 | } 171 | let preferences = preferencesSaved || DEFAULT_PREFERENCES 172 | 173 | if (!UUID) { 174 | UUID = Tracking.createUUID() 175 | Storage.set(Storage.getKey('UUID'), UUID) 176 | } 177 | 178 | // Cache license status for gate decisions 179 | setCachedLicenseStatus(license) 180 | 181 | FP.waitForUIReady().then(() => { 182 | FP.notifyUI({ 183 | type: 'init-hidden', 184 | theme: theme, 185 | UUID: UUID, 186 | cmd: cmd, 187 | preferences: preferences, 188 | license: license 189 | }) 190 | // Make sure initial selection is sent to the UI 191 | FP.notifyUI({ type: 'selection', selection: FP.currentSelection() }) 192 | }) 193 | 194 | // Set up selection change listener using FigPen 195 | FP.onSelectionChange((selection) => { 196 | FP.notifyUI({ type: 'selection', selection: selection }) 197 | }) 198 | 199 | // Bind all message handlers using MessageBus 200 | MessageBus.bind('tidy', (msg) => { 201 | var RENAMING_ENABLED = msg.options.renaming 202 | var REORDER_ENABLED = msg.options.reorder 203 | var TIDY_ENABLED = msg.options.tidy 204 | var PAGER_ENABLED = msg.options.pager 205 | 206 | let historyId = FP.startUndoBlock() 207 | if (TIDY_ENABLED) cmdTidy(preferences.spacing.x, preferences.spacing.y, preferences.wrap_instances, preferences.layout_paradigm || 'rows') 208 | if (RENAMING_ENABLED) cmdRename(preferences.rename_strategy, preferences.start_name, preferences.layout_paradigm || 'rows') 209 | if (REORDER_ENABLED) cmdReorder(preferences.layout_paradigm || 'rows') 210 | if (PAGER_ENABLED) cmdPager(preferences.pager_variable, preferences.layout_paradigm || 'rows') 211 | FP.finishUndoBlock(historyId) 212 | FP.showNotification('Super Tidy') 213 | setTimeout(() => FP.closePlugin(), 100) 214 | }) 215 | 216 | MessageBus.bind('preferences', (msg) => { 217 | preferences = msg.preferences 218 | Storage.set(Storage.getKey('PREFERENCES'), preferences).then((success) => { 219 | if (success) { 220 | FP.showNotification('Preferences saved') 221 | } else { 222 | FP.showNotification('Failed to save preferences. Please try again.') 223 | } 224 | }) 225 | }) 226 | 227 | MessageBus.bind('displayImpression', (msg) => { 228 | FP.resizeUI(320, 540+124) 229 | Storage.setMultiple({ 230 | [Storage.getKey('AD_LAST_SHOWN_DATE')]: Date.now(), 231 | [Storage.getKey('AD_LAST_SHOWN_IMPRESSION')]: parseInt(AD_LAST_SHOWN_IMPRESSION)+1 232 | }) 233 | }) 234 | 235 | MessageBus.bind('resetImpression', (msg) => { 236 | Storage.set(Storage.getKey('AD_LAST_SHOWN_IMPRESSION'), 0) 237 | }) 238 | 239 | MessageBus.bind('activate-license', (msg) => { 240 | // Store license data from UI 241 | const licenseData = { 242 | licensed: true, 243 | productId: msg.productId || 'gumroad', 244 | licenseKeyHash: msg.licenseKey ? hashLicenseKey(msg.licenseKey) : null, 245 | licenseKey: msg.licenseKey, // Store actual key for display 246 | deviceId: UUID, 247 | activatedAt: Date.now(), 248 | purchase: msg.purchase || {}, 249 | uses: msg.uses || 1 // Include usage count from activation 250 | } 251 | 252 | Storage.set(Storage.getKey('LICENSE_V1'), licenseData) 253 | .then((success) => { 254 | if (success) { 255 | setCachedLicenseStatus(licenseData) // Update cache 256 | FP.showNotification('You now have Super Tidy Pro') 257 | } else { 258 | FP.showNotification('Failed to save license. Please try again.') 259 | } 260 | }) 261 | }) 262 | 263 | MessageBus.bind('remove-license', (msg) => { 264 | // Remove stored license 265 | Storage.remove(Storage.getKey('LICENSE_V1')) 266 | .then((success) => { 267 | if (success) { 268 | setCachedLicenseStatus(null) // Update cache 269 | FP.showNotification('License unlinked from this device') 270 | } else { 271 | FP.showNotification('Failed to unlink license. Please try again.') 272 | } 273 | }) 274 | }) 275 | // Command triggered by user 276 | if (cmd == 'rename') { 277 | // RUNS WITH COUNTDOWN GATE 278 | ensureDirectCommandGate('rename', () => { 279 | cmdRename(preferences.rename_strategy, preferences.start_name, preferences.layout_paradigm || 'rows') 280 | FP.showNotification('Super Tidy: Rename') 281 | }, preferences, UUID, license) 282 | } else 283 | if (cmd == 'reorder') { 284 | // RUNS WITH COUNTDOWN GATE 285 | ensureDirectCommandGate('reorder', () => { 286 | cmdReorder(preferences.layout_paradigm || 'rows') 287 | FP.showNotification('Super Tidy: Reorder') 288 | }, preferences, UUID, license) 289 | } else 290 | if (cmd == 'tidy') { 291 | // RUNS WITH COUNTDOWN GATE 292 | ensureDirectCommandGate('tidy', () => { 293 | cmdTidy(preferences.spacing.x, preferences.spacing.y, preferences.wrap_instances, preferences.layout_paradigm || 'rows') 294 | FP.showNotification('Super Tidy: Tidy') 295 | }, preferences, UUID, license) 296 | } else 297 | if (cmd == 'pager') { 298 | // RUNS WITH COUNTDOWN GATE 299 | ensureDirectCommandGate('pager', () => { 300 | cmdPager(preferences.pager_variable, preferences.layout_paradigm || 'rows') 301 | FP.showNotification('Super Tidy: Pager') 302 | }, preferences, UUID, license) 303 | } else 304 | if (cmd == 'all') { 305 | // RUNS WITH COUNTDOWN GATE 306 | ensureDirectCommandGate('all', () => { 307 | cmdTidy(preferences.spacing.x, preferences.spacing.y, preferences.wrap_instances, preferences.layout_paradigm || 'rows') 308 | cmdReorder(preferences.layout_paradigm || 'rows') 309 | cmdRename(preferences.rename_strategy, preferences.start_name, preferences.layout_paradigm || 'rows') 310 | cmdPager(preferences.pager_variable, preferences.layout_paradigm || 'rows') 311 | FP.showNotification('Super Tidy') 312 | }, preferences, UUID, license) 313 | } else 314 | if (cmd == 'options' || cmd == null) { 315 | // OPEN UI 316 | FP.openUI() 317 | FP.notifyUI({ 318 | type: 'init', 319 | UUID: UUID, 320 | cmd: cmd, 321 | preferences: preferences, 322 | license: license, 323 | AD_LAST_SHOWN_DATE: AD_LAST_SHOWN_DATE, 324 | AD_LAST_SHOWN_IMPRESSION: AD_LAST_SHOWN_IMPRESSION 325 | }) 326 | FP.notifyUI({ type: 'selection', selection: FP.currentSelection() }) 327 | } 328 | }) 329 | -------------------------------------------------------------------------------- /src/payments/gate.js: -------------------------------------------------------------------------------- 1 | // License gate - Manages license status in current JavaScript context 2 | // Note: Core.js (main thread) and UI (iframe) have separate instances of this cache 3 | let cachedLicenseStatus = null 4 | 5 | function getRandomIntInclusive(min, max) { 6 | const mi = Math.ceil(min), ma = Math.floor(max) 7 | return Math.floor(Math.random() * (ma - mi + 1)) + mi 8 | } 9 | 10 | export function setCachedLicenseStatus(license) { 11 | cachedLicenseStatus = license 12 | const context = typeof figma !== 'undefined' ? 'Core' : 'UI' 13 | console.log(`[Gate:${context}] License status updated:`, license ? 'LICENSED' : 'UNLICENSED') 14 | } 15 | 16 | export function shouldShowCountdown() { 17 | // Check actual license status in current context 18 | const isLicensed = cachedLicenseStatus && cachedLicenseStatus.licensed 19 | const context = typeof figma !== 'undefined' ? 'Core' : 'UI' 20 | console.log(`[Gate:${context}] shouldShowCountdown:`, !isLicensed, 'cached license:', cachedLicenseStatus) 21 | return !isLicensed 22 | } 23 | 24 | export function getCountdownSeconds() { 25 | return getRandomIntInclusive(6, 15) 26 | } 27 | -------------------------------------------------------------------------------- /src/payments/license.js: -------------------------------------------------------------------------------- 1 | import FigPen from 'src/utils/FigPen' 2 | import CONFIG from 'src/Config' 3 | // License verification and management API module 4 | // Handles all Gumroad API interactions and license operations 5 | 6 | const GUMROAD_PRODUCT_ID = WP_GUMROAD_PRODUCT_ID 7 | // const MAX_USAGE_LIMIT = 2 // Temporarily disabled - will re-add in future 8 | 9 | let FP = new FigPen(CONFIG) 10 | 11 | /** 12 | * Manually encodes form data for x-www-form-urlencoded requests 13 | * @param {object} data - Key-value pairs to encode 14 | * @returns {string} Encoded form data string 15 | */ 16 | function encodeFormData(data) { 17 | return Object.keys(data) 18 | .map(key => encodeURIComponent(key) + '=' + encodeURIComponent(data[key])) 19 | .join('&') 20 | } 21 | 22 | /** 23 | * Validates a Gumroad license key (shared validation logic) 24 | * @param {string} licenseKey - The license key to validate 25 | * @param {boolean} incrementUsage - Whether to increment usage count 26 | * @returns {Promise<{ok: boolean, error?: string, purchase?: object, uses?: number}>} 27 | */ 28 | export function validateGumroadLicense(licenseKey, incrementUsage = false) { 29 | return fetch('https://api.gumroad.com/v2/licenses/verify', { 30 | method: 'POST', 31 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, 32 | body: encodeFormData({ 33 | product_id: GUMROAD_PRODUCT_ID, 34 | license_key: licenseKey, 35 | increment_uses_count: incrementUsage.toString() 36 | }) 37 | }) 38 | .then(response => { 39 | // Check if response is ok (status 200-299) 40 | if (!response.ok) { 41 | throw new Error(`HTTP error! status: ${response.status}`) 42 | } 43 | return response.json() 44 | }) 45 | .then(data => { 46 | // We got a valid JSON response from the API 47 | if (!data.success) { 48 | return { ok: false, error: 'Invalid license key', isLicenseError: true } 49 | } 50 | 51 | const purchase = data.purchase || {} 52 | if (purchase.refunded || purchase.chargebacked || purchase.license_disabled) { 53 | return { ok: false, error: 'License is no longer valid', isLicenseError: true } 54 | } 55 | 56 | // Check usage limit (max 2 devices: work and personal) - TEMPORARILY DISABLED 57 | // const currentUses = data.uses || 0 58 | // if (currentUses >= MAX_USAGE_LIMIT) { 59 | // return { 60 | // ok: false, 61 | // error: 'License limit reached. Maximum 2 devices allowed (work and personal). Unlink from another device first.', 62 | // isLicenseError: true 63 | // } 64 | // } 65 | 66 | return { ok: true, purchase: purchase, uses: data.uses } 67 | }) 68 | .catch(error => { 69 | console.warn('[License] Network/API error during validation:', error) 70 | // This is a network error, not a license validation error 71 | // Don't invalidate the license, just return a network error 72 | return { 73 | ok: false, 74 | error: 'Unable to verify license. Please check your connection.', 75 | isNetworkError: true 76 | } 77 | }) 78 | } 79 | 80 | /** 81 | * Verifies a Gumroad license key and manages usage count (for user activation) 82 | * @param {string} licenseKey - The license key to verify 83 | * @returns {Promise<{ok: boolean, error?: string, purchase?: object, uses?: number}>} 84 | */ 85 | export function verifyGumroadLicense(licenseKey) { 86 | // First check usage without incrementing 87 | return validateGumroadLicense(licenseKey, false) 88 | .then(checkResult => { 89 | if (!checkResult.ok) { 90 | return checkResult // Return validation error 91 | } 92 | 93 | // If validation passes, increment usage and return success 94 | return validateGumroadLicense(licenseKey, true) 95 | .then(incrementResult => { 96 | if (incrementResult.ok) { 97 | return { ok: true, purchase: incrementResult.purchase, uses: incrementResult.uses } 98 | } else { 99 | return { ok: false, error: 'Failed to activate license. Please try again.' } 100 | } 101 | }) 102 | }) 103 | } 104 | 105 | /** 106 | * Decrements the usage count for a license key using Netlify proxy 107 | * @param {string} licenseKey - The license key to decrement usage for 108 | * @returns {Promise<{ok: boolean, error?: string, uses?: number}>} 109 | */ 110 | export function decrementLicenseUsage(licenseKey) { 111 | return fetch('https://figma-plugins-display-network.netlify.app/api/licenses/decrement_uses_count', { 112 | method: 'PUT', 113 | headers: { 114 | 'Content-Type': 'application/json' 115 | }, 116 | body: JSON.stringify({ 117 | license_key: licenseKey, 118 | product_id: GUMROAD_PRODUCT_ID 119 | }) 120 | }) 121 | .then(response => response.json()) 122 | .then(data => { 123 | if (data.success) { 124 | console.log('[License] Usage count decremented successfully, uses now:', data.uses) 125 | return { ok: true, uses: data.uses } 126 | } else { 127 | console.warn('[License] Failed to decrement usage count:', data.message || data.error) 128 | return { ok: false, error: data.message || data.error } 129 | } 130 | }) 131 | .catch(error => { 132 | console.warn('[License] Failed to decrement usage count:', error) 133 | return { ok: false, error: 'Network error' } 134 | }) 135 | } 136 | 137 | /** 138 | * Validates a license key without incrementing usage count (for startup validation) 139 | * Includes device usage validation as requested 140 | * @param {string} licenseKey - The license key to validate 141 | * @returns {Promise<{ok: boolean, error?: string, purchase?: object, uses?: number}>} 142 | */ 143 | export function validateLicenseOnly(licenseKey) { 144 | return validateGumroadLicense(licenseKey, false) // Use shared validation logic without incrementing 145 | } 146 | 147 | 148 | /** 149 | * Activates a license by storing it via postMessage to Core.js 150 | * @param {string} licenseKey - The license key 151 | * @param {object} purchase - Purchase data from Gumroad 152 | * @param {number} uses - Current usage count 153 | */ 154 | export function activateLicense(licenseKey, purchase, uses) { 155 | FP.notifyEditor({ 156 | type: 'activate-license', 157 | licenseKey: licenseKey, 158 | purchase: purchase, 159 | uses: uses 160 | }) 161 | } 162 | 163 | /** 164 | * Removes/unlinks a license by sending message to Core.js 165 | */ 166 | export function removeLicense() { 167 | FP.notifyEditor({ type: 'remove-license' }) 168 | } 169 | 170 | /** 171 | * Creates license info object for UI display 172 | * @param {string} licenseKey - The license key 173 | * @param {object} purchase - Purchase data from Gumroad 174 | * @param {number} uses - Current usage count 175 | * @returns {object} License info object 176 | */ 177 | export function createLicenseInfo(licenseKey, purchase, uses) { 178 | return { 179 | licensed: true, 180 | purchase: purchase, 181 | activatedAt: Date.now(), 182 | licenseKey: licenseKey, 183 | uses: uses || 1 // Default to 1 if not specified 184 | } 185 | } 186 | -------------------------------------------------------------------------------- /src/ui/Bulletproof.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Bulletproof 3 | * All base styles that we need to normalize the starting point for cross-browser development. 4 | */ 5 | 6 | 7 | /*Base font */ 8 | html { min-height: 100%; position: relative; overflow-x: hidden; } 9 | body { margin: 0; padding: 0; text-align: center; vertical-align: middle; overflow-x: hidden; } 10 | 11 | 12 | /* Text conventions */ 13 | h1, h2, h3, h4, h5, h6 { margin: 0; padding: 0; font-weight: normal; } 14 | ul, ol, dl, dt, dd { padding: 0; margin: 0; list-style-type: none; } 15 | p, li { 16 | word-wrap: break-word; 17 | margin: 0; 18 | padding: 0; 19 | } 20 | q { quotes: none; display: block; } 21 | pre, blockquote { padding: 0; margin: 0; } 22 | em { font-style: italic; } 23 | address { font-style: normal; display: inline; } 24 | abbr, acronym { cursor: help; border: none; } 25 | 26 | 27 | /* Headings */ 28 | h2 { font-weight: bold; } 29 | 30 | 31 | /* Links */ 32 | a { text-decoration: none; outline: none; } 33 | a:hover { text-decoration: underline; } 34 | a img { border: none; } 35 | a abbr { cursor: pointer; } 36 | 37 | 38 | /* Tables */ 39 | table { border-collapse: collapse; border-spacing: 0; width: 100%; } 40 | td { vertical-align: top; } 41 | caption, th { text-align: left; } 42 | 43 | 44 | /* Forms */ 45 | form { margin: 0; } 46 | fieldset { margin: 0; padding: 0; border: none; } 47 | legend { padding: 0; display: block; } 48 | 49 | button { height: auto; width: auto; overflow: visible; display: inline-block; vertical-align: middle; background: none; border: none; outline: none; white-space: nowrap; cursor: pointer; } 50 | button::-moz-focus-inner { padding: 0; border: none; } 51 | 52 | input, textarea, select, button { margin: 0; padding: 0; vertical-align: middle; } 53 | input:focus, textarea:focus { outline: none!important; } 54 | textarea { resize: none; overflow: auto; } 55 | 56 | input[type="text"], 57 | input[type="email"], 58 | input[type="number"], 59 | textarea { } 60 | 61 | input[type="text"]:focus, 62 | input[type="email"]:focus, 63 | input[type="number"]:focus, 64 | textarea:focus { } 65 | 66 | input:invalid { box-shadow: none; } 67 | input:-moz-submit-invalid { box-shadow: none; } 68 | input:-moz-ui-invalid { box-shadow:none; } 69 | 70 | input[type=number]::-webkit-outer-spin-button, 71 | input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } 72 | input[type=number] { -moz-appearance:textfield; } 73 | 74 | [contenteditable] { outline: none; word-wrap: break-word; } 75 | textarea::-webkit-input-placeholder, 76 | input::-webkit-input-placeholder { color: inherit; opacity: 1; } 77 | 78 | textarea::-moz-placeholder, 79 | input::-moz-placeholder { color: inherit; opacity: 1; } 80 | 81 | textarea:focus::-webkit-input-placeholder, 82 | input:focus::-webkit-input-placeholder { color: inherit; } 83 | 84 | textareafir:focus::-moz-placeholder, 85 | input:focus::-moz-placeholder { color: inherit; } 86 | 87 | input:-webkit-autofill { 88 | -webkit-box-shadow: 0 0 0 50px white inset; 89 | -webkit-text-fill-color: inherit; 90 | } 91 | input:-webkit-autofill:focus { 92 | -webkit-box-shadow: 0 0 0 50px white inset; 93 | -webkit-text-fill-color: inherit; 94 | } 95 | 96 | 97 | /* Media */ 98 | img { display: block; padding: 0; margin: 0; } 99 | img:-moz-broken { line-height: inherit; overflow: hidden; } 100 | 101 | 102 | /* HTML 5 */ 103 | article, aside, details, figcaption, figure, footer, header, hgroup, 104 | menu, nav, section, video, audio, canvas, progress, meter, time 105 | { display: block; padding: 0; margin: 0; } 106 | 107 | [hidden] { display: none!important; } 108 | 109 | /* Clearfix */ 110 | .cf { *zoom: 1; } .cf:before,.cf:after { content: ""; display: table; } .cf:after { clear: both; } 111 | -------------------------------------------------------------------------------- /src/ui/Element.js: -------------------------------------------------------------------------------- 1 | import LEOElement from 'leo/element' 2 | 3 | class Element extends LEOElement { 4 | } 5 | 6 | export default Element 7 | -------------------------------------------------------------------------------- /src/ui/FigmaUI.css: -------------------------------------------------------------------------------- 1 | /* FIGMA UI */ 2 | @font-face{font-family:Inter;font-style:normal;font-weight:400;src:url(https://rsms.me/inter/font-files/Inter-Regular.woff2?v=3.7) format("woff2"),url(https://rsms.me/inter/font-files/Inter-Regular.woff?v=3.7) format("woff")}@font-face{font-family:Inter;font-style:normal;font-weight:500;src:url(https://rsms.me/inter/font-files/Inter-Medium.woff2?v=3.7) format("woff2"),url(https://rsms.me/inter/font-files/Inter-Medium.woff2?v=3.7) format("woff")}@font-face{font-family:Inter;font-style:normal;font-weight:600;src:url(https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=3.7) format("woff2"),url(https://rsms.me/inter/font-files/Inter-SemiBold.woff2?v=3.7) format("woff")}.icon{width:32px;height:32px;cursor:default;color: var(--color-text-primary);background-repeat:no-repeat;background-position:0 0}.icon--blue{color:var(--color-interactive-positive);background-position:0 -64px}.icon--black-3{color:var(--color-text-tertiary);background-position:0 -32px}.icon--button{border:2px solid transparent;border-radius:2px;outline:0;background-position:-2px -2px}.icon--button:hover{background-color:rgba(0,0,0,.06)}.icon--button:active{border:2px solid var(--color-interactive-positive);background-color:rgba(0,0,0,.06)}.icon--button:disabled{opacity:.37}.icon--text{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;font-family:Inter,sans-serif;font-size:11px}.icon--adjust{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m12%2016.05v-7.05h1v7.05c1.1411.2316%202%201.2405%202%202.45s-.8589%202.2184-2%202.45v2.05h-1v-2.05c-1.1411-.2316-2-1.2405-2-2.45s.8589-2.2184%202-2.45zm2%202.45c0%20.8284-.6716%201.5-1.5%201.5s-1.5-.6716-1.5-1.5.6716-1.5%201.5-1.5%201.5.6716%201.5%201.5zm5%204.5h1v-7.05c1.1411-.2316%202-1.2405%202-2.45s-.8589-2.2184-2-2.45v-2.05h-1v2.05c-1.1411.2316-2%201.2405-2%202.45s.8589%202.2184%202%202.45zm2-9.5c0-.8284-.6716-1.5-1.5-1.5s-1.5.6716-1.5%201.5.6716%201.5%201.5%201.5%201.5-.6716%201.5-1.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m12%2048.05v-7.05h1v7.05c1.1411.2316%202%201.2405%202%202.45s-.8589%202.2184-2%202.45v2.05h-1v-2.05c-1.1411-.2316-2-1.2405-2-2.45s.8589-2.2184%202-2.45zm2%202.45c0%20.8284-.6716%201.5-1.5%201.5s-1.5-.6716-1.5-1.5.6716-1.5%201.5-1.5%201.5.6716%201.5%201.5zm5%204.5h1v-7.05c1.1411-.2316%202-1.2405%202-2.45s-.8589-2.2184-2-2.45v-2.05h-1v2.05c-1.1411.2316-2%201.2405-2%202.45s.8589%202.2184%202%202.45zm2-9.5c0-.8284-.6716-1.5-1.5-1.5s-1.5.6716-1.5%201.5.6716%201.5%201.5%201.5%201.5-.6716%201.5-1.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m12%2080.05v-7.05h1v7.05c1.1411.2316%202%201.2405%202%202.45s-.8589%202.2184-2%202.45v2.05h-1v-2.05c-1.1411-.2316-2-1.2405-2-2.45s.8589-2.2184%202-2.45zm2%202.45c0%20.8284-.6716%201.5-1.5%201.5s-1.5-.6716-1.5-1.5.6716-1.5%201.5-1.5%201.5.6716%201.5%201.5zm5%204.5h1v-7.05c1.1411-.2316%202-1.2405%202-2.45s-.8589-2.2184-2-2.45v-2.05h-1v2.05c-1.1411.2316-2%201.2405-2%202.45s.8589%202.2184%202%202.45zm2-9.5c0-.8284-.6716-1.5-1.5-1.5s-1.5.6716-1.5%201.5.6716%201.5%201.5%201.5%201.5-.6716%201.5-1.5z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--angle{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m12%2012v7.5.5h.5%207.5v-1h-3c0-2.2091-1.7909-4-4-4v-3zm1%204v3h3c0-1.6569-1.3431-3-3-3z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m12%2044v7.5.5h.5%207.5v-1h-3c0-2.2091-1.7909-4-4-4v-3zm1%204v3h3c0-1.6569-1.3431-3-3-3z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m12%2076v7.5.5h.5%207.5v-1h-3c0-2.2091-1.7909-4-4-4v-3zm1%204v3h3c0-1.6569-1.3431-3-3-3z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--break{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m13.0002%209v3h1v-3zm9.1031.89644c-1.1617-1.16176-3.0453-1.16176-4.2071.00002l-2.7499%202.74994.7071.7071%202.7499-2.7499c.7712-.77128%202.0217-.77129%202.7929%200%20.7712.7712.7713%202.0216%200%202.7928l-2.7499%202.75.7071.7071%202.7499-2.75c1.1618-1.1617%201.1618-3.0453%200-4.20706zm-12.20691%2012.20706c-1.16176-1.1617-1.16177-3.0453-.00001-4.2071l2.75002-2.75.7071.7071-2.75%202.75c-.77124.7713-.77124%202.0217%200%202.7929.7712.7713%202.0216.7713%202.7929%200l2.75-2.75.7071.7071-2.75%202.75c-1.1618%201.1618-3.0454%201.1618-4.20711%200zm13.10341-3.1035h-3v-1h3zm-3.9994%201v3h-1v-3zm-7.0006-7h-3.00004v1h3.00004z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%20opacity%3D%22.9%22%2F%3E%3Cpath%20d%3D%22m13.0002%2041v3h1v-3zm9.1031.8964c-1.1617-1.1617-3.0453-1.1617-4.2071.0001l-2.7499%202.7499.7071.7071%202.7499-2.7499c.7712-.7713%202.0217-.7713%202.7929%200%20.7712.7712.7713%202.0216%200%202.7928l-2.7499%202.75.7071.7071%202.7499-2.75c1.1618-1.1617%201.1618-3.0453%200-4.2071zm-12.20691%2012.2071c-1.16176-1.1617-1.16177-3.0453-.00001-4.2071l2.75002-2.75.7071.7071-2.75%202.75c-.77124.7713-.77124%202.0217%200%202.7929.7712.7713%202.0216.7713%202.7929%200l2.75-2.75.7071.7071-2.75%202.75c-1.1618%201.1618-3.0454%201.1618-4.20711%200zm13.10341-3.1035h-3v-1h3zm-3.9994%201v3h-1v-3zm-7.0006-7h-3.00004v1h3.00004z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%20opacity%3D%22.9%22%2F%3E%3Cpath%20d%3D%22m13.0002%2073v3h1v-3zm9.1031.8965c-1.1617-1.1618-3.0453-1.1618-4.2071%200l-2.7499%202.7499.7071.7071%202.7499-2.7499c.7712-.7713%202.0217-.7713%202.7929%200%20.7712.7712.7713%202.0216%200%202.7928l-2.7499%202.75.7071.7071%202.7499-2.7499c1.1618-1.1618%201.1618-3.0454%200-4.2071zm-12.20691%2012.207c-1.16176-1.1617-1.16177-3.0453-.00001-4.2071l2.75002-2.75.7071.7071-2.75%202.75c-.77124.7713-.77124%202.0217%200%202.7929.7712.7713%202.0216.7713%202.7929%200l2.75-2.75.7071.7071-2.75%202.75c-1.1618%201.1618-3.0454%201.1618-4.20711%200zm13.10341-3.1035h-3v-1h3zm-3.9994%201v3h-1v-3zm-7.0006-7h-3.00004v1h3.00004z%22%20fill%3D%22%2318a0fb%22%20opacity%3D%22.9%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--close{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m16%2015.293%204.6465-4.6464.7071.7071-4.6465%204.6464%204.6465%204.6465-.7071.7071-4.6465-4.6464-4.6464%204.6464-.7071-.7071%204.6464-4.6465-4.6464-4.6463.7071-.7071z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m16%2047.293%204.6465-4.6464.7071.7071-4.6465%204.6464%204.6465%204.6465-.7071.7071-4.6465-4.6464-4.6464%204.6464-.7071-.7071%204.6464-4.6465-4.6464-4.6463.7071-.7071z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m16%2079.293%204.6465-4.6464.7071.7071-4.6465%204.6464%204.6465%204.6465-.7071.7071-4.6465-4.6464-4.6464%204.6464-.7071-.7071%204.6464-4.6465-4.6464-4.6463.7071-.7071z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--ellipses{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m11.5%2016c0%20.8284-.6716%201.5-1.5%201.5-.82843%200-1.5-.6716-1.5-1.5s.67157-1.5%201.5-1.5c.8284%200%201.5.6716%201.5%201.5zm6%200c0%20.8284-.6716%201.5-1.5%201.5s-1.5-.6716-1.5-1.5.6716-1.5%201.5-1.5%201.5.6716%201.5%201.5zm4.5%201.5c.8284%200%201.5-.6716%201.5-1.5s-.6716-1.5-1.5-1.5-1.5.6716-1.5%201.5.6716%201.5%201.5%201.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m11.5%2048c0%20.8284-.6716%201.5-1.5%201.5-.82843%200-1.5-.6716-1.5-1.5s.67157-1.5%201.5-1.5c.8284%200%201.5.6716%201.5%201.5zm6%200c0%20.8284-.6716%201.5-1.5%201.5s-1.5-.6716-1.5-1.5.6716-1.5%201.5-1.5%201.5.6716%201.5%201.5zm4.5%201.5c.8284%200%201.5-.6716%201.5-1.5s-.6716-1.5-1.5-1.5-1.5.6716-1.5%201.5.6716%201.5%201.5%201.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m11.5%2080c0%20.8284-.6716%201.5-1.5%201.5-.82843%200-1.5-.6716-1.5-1.5s.67157-1.5%201.5-1.5c.8284%200%201.5.6716%201.5%201.5zm6%200c0%20.8284-.6716%201.5-1.5%201.5s-1.5-.6716-1.5-1.5.6716-1.5%201.5-1.5%201.5.6716%201.5%201.5zm4.5%201.5c.8284%200%201.5-.6716%201.5-1.5s-.6716-1.5-1.5-1.5-1.5.6716-1.5%201.5.6716%201.5%201.5%201.5z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--eyedropper{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20d%3D%22m22.4473%209.6c-.8-.8-2-.8-2.8%200l-2.8001%202.8-.8-.7c-.4-.4-1-.4-1.4%200s-.4%201%200%201.4l.7.7-5.79995%205.8c-.4.4-1%201.9%200%202.9.99995%201%202.49995.4%202.89995%200l5.8-5.8.7001.7c.4.4%201%20.4%201.4%200s.4-1%200-1.4l-.7-.7%202.8-2.8c.8-.9.8-2.1%200-2.9zm-10.9001%2011.9h-1v-1l5.8-5.8%201%201c-.1%200-5.8%205.8-5.8%205.8z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m22.4473%2041.6c-.8-.8-2-.8-2.8%200l-2.8001%202.8-.8-.7c-.4-.4-1-.4-1.4%200s-.4%201%200%201.4l.7.7-5.79995%205.8c-.4.4-1%201.9%200%202.9.99995%201%202.49995.4%202.89995%200l5.8-5.8.7001.7c.4.4%201%20.4%201.4%200s.4-1%200-1.4l-.7-.7%202.8-2.8c.8-.9.8-2.1%200-2.9zm-10.9001%2011.9h-1v-1l5.8-5.8%201%201c-.1%200-5.8%205.8-5.8%205.8z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m22.4473%2073.6c-.8-.8-2-.8-2.8%200l-2.8001%202.8-.8-.7c-.4-.4-1-.4-1.4%200s-.4%201%200%201.4l.7.7-5.79995%205.8c-.4.4-1%201.9%200%202.9.99995%201%202.49995.4%202.89995%200l5.8-5.8.7001.7c.4.4%201%20.4%201.4%200s.4-1%200-1.4l-.7-.7%202.8-2.8c.8-.9.8-2.1%200-2.9zm-10.9001%2011.9h-1v-1l5.8-5.8%201%201c-.1%200-5.8%205.8-5.8%205.8z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fsvg%3E\a")}.icon--hidden{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m21.5085%2015.8012c.5554-.5276%201.0351-1.134%201.421-1.8012h-1.1842c-1.2655%201.8142-3.3673%203-5.7454%203-2.3782%200-4.48-1.1858-5.7454-3h-1.18428c.38597.6673.86567%201.2737%201.42108%201.8013l-1.59482%201.5949.70712.7071%201.6573-1.6574c.7108.5234%201.5112.9321%202.3742%201.1988l-.6171%202.2213.9636.2676.6262-2.2543c.452.0793.9172.1207%201.3921.1207.4748%200%20.9399-.0414%201.392-.1207l.6261%202.2543.9636-.2676-.617-2.2213c.863-.2666%201.6635-.6754%202.3743-1.1989l1.6576%201.6575.7071-.7071z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m21.5085%2047.8012c.5554-.5276%201.0351-1.134%201.421-1.8012h-1.1842c-1.2655%201.8142-3.3673%203-5.7454%203-2.3782%200-4.48-1.1858-5.7454-3h-1.18428c.38597.6673.86567%201.2737%201.42108%201.8013l-1.59482%201.5949.70712.7071%201.6573-1.6574c.7108.5234%201.5112.9321%202.3742%201.1988l-.6171%202.2213.9636.2676.6262-2.2543c.452.0793.9172.1207%201.3921.1207.4748%200%20.9399-.0414%201.392-.1207l.6261%202.2543.9636-.2676-.617-2.2213c.863-.2666%201.6635-.6754%202.3743-1.1989l1.6576%201.6575.7071-.7071z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m21.5085%2079.8012c.5554-.5276%201.0351-1.134%201.421-1.8012h-1.1842c-1.2655%201.8142-3.3673%203-5.7454%203-2.3782%200-4.48-1.1858-5.7454-3h-1.18428c.38597.6673.86567%201.2737%201.42108%201.8013l-1.59482%201.5949.70712.7071%201.6573-1.6574c.7108.5234%201.5112.9321%202.3742%201.1988l-.6171%202.2213.9636.2676.6262-2.2543c.452.0793.9172.1207%201.3921.1207.4748%200%20.9399-.0414%201.392-.1207l.6261%202.2543.9636-.2676-.617-2.2213c.863-.2666%201.6635-.6754%202.3743-1.1989l1.6576%201.6575.7071-.7071z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--hyperlink{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m13.5%2018c1.9593%200%203.6262-1.2522%204.2439-3h1.0491c-.653%202.3085-2.7754%204-5.293%204-3.0376%200-5.5-2.4624-5.5-5.5s2.4624-5.5%205.5-5.5c2.5176%200%204.64%201.6915%205.293%204h-1.0491c-.6177-1.7478-2.2846-3-4.2439-3-2.4853%200-4.5%202.0147-4.5%204.5s2.0147%204.5%204.5%204.5zm5%205c2.4853%200%204.5-2.0147%204.5-4.5s-2.0147-4.5-4.5-4.5c-1.9593%200-3.6262%201.2522-4.2439%203h-1.0491c.653-2.3085%202.7754-4%205.293-4%203.0376%200%205.5%202.4624%205.5%205.5s-2.4624%205.5-5.5%205.5c-2.5176%200-4.64-1.6915-5.293-4h1.0491c.6177%201.7478%202.2846%203%204.2439%203z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m13.5%2050c1.9593%200%203.6262-1.2522%204.2439-3h1.0491c-.653%202.3085-2.7754%204-5.293%204-3.0376%200-5.5-2.4624-5.5-5.5s2.4624-5.5%205.5-5.5c2.5176%200%204.64%201.6915%205.293%204h-1.0491c-.6177-1.7478-2.2846-3-4.2439-3-2.4853%200-4.5%202.0147-4.5%204.5s2.0147%204.5%204.5%204.5zm5%205c2.4853%200%204.5-2.0147%204.5-4.5s-2.0147-4.5-4.5-4.5c-1.9593%200-3.6262%201.2522-4.2439%203h-1.0491c.653-2.3085%202.7754-4%205.293-4%203.0376%200%205.5%202.4624%205.5%205.5s-2.4624%205.5-5.5%205.5c-2.5176%200-4.64-1.6915-5.293-4h1.0491c.6177%201.7478%202.2846%203%204.2439%203z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m13.5%2082c1.9593%200%203.6262-1.2522%204.2439-3h1.0491c-.653%202.3085-2.7754%204-5.293%204-3.0376%200-5.5-2.4624-5.5-5.5s2.4624-5.5%205.5-5.5c2.5176%200%204.64%201.6915%205.293%204h-1.0491c-.6177-1.7478-2.2846-3-4.2439-3-2.4853%200-4.5%202.0147-4.5%204.5s2.0147%204.5%204.5%204.5zm5%205c2.4853%200%204.5-2.0147%204.5-4.5s-2.0147-4.5-4.5-4.5c-1.9593%200-3.6262%201.2522-4.2439%203h-1.0491c.653-2.3085%202.7754-4%205.293-4%203.0376%200%205.5%202.4624%205.5%205.5s-2.4624%205.5-5.5%205.5c-2.5176%200-4.64-1.6915-5.293-4h1.0491c.6177%201.7478%202.2846%203%204.2439%203z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--link-broken{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m18%2014v-2c0-1.1046-.8954-2-2-2s-2%20.8954-2%202v2h-1v-2c0-1.6569%201.3431-3%203-3s3%201.3431%203%203v2zm1%204h-1v2c0%201.1046-.8954%202-2%202s-2-.8954-2-2v-2h-1v2c0%201.6569%201.3431%203%203%203s3-1.3431%203-3z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m18%2046v-2c0-1.1046-.8954-2-2-2s-2%20.8954-2%202v2h-1v-2c0-1.6569%201.3431-3%203-3s3%201.3431%203%203v2zm1%204h-1v2c0%201.1046-.8954%202-2%202s-2-.8954-2-2v-2h-1v2c0%201.6569%201.3431%203%203%203s3-1.3431%203-3z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m18%2078v-2c0-1.1046-.8954-2-2-2s-2%20.8954-2%202v2h-1v-2c0-1.6569%201.3431-3%203-3s3%201.3431%203%203v2zm1%204h-1v2c0%201.1046-.8954%202-2%202s-2-.8954-2-2v-2h-1v2c0%201.6569%201.3431%203%203%203s3-1.3431%203-3z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--link{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m16%2010c1.1046%200%202%20.8954%202%202v2h1v-2c0-1.6569-1.3431-3-3-3s-3%201.3431-3%203v2h1v-2c0-1.1046.8954-2%202-2zm2%208h1v2c0%201.6569-1.3431%203-3%203s-3-1.3431-3-3v-2h1v2c0%201.1046.8954%202%202%202s2-.8954%202-2zm-2.5-5v6h1v-6z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m16%2042c1.1046%200%202%20.8954%202%202v2h1v-2c0-1.6569-1.3431-3-3-3s-3%201.3431-3%203v2h1v-2c0-1.1046.8954-2%202-2zm2%208h1v2c0%201.6569-1.3431%203-3%203s-3-1.3431-3-3v-2h1v2c0%201.1046.8954%202%202%202s2-.8954%202-2zm-2.5-5v6h1v-6z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m16%2074c1.1046%200%202%20.8954%202%202v2h1v-2c0-1.6569-1.3431-3-3-3s-3%201.3431-3%203v2h1v-2c0-1.1046.8954-2%202-2zm2%208h1v2c0%201.6569-1.3431%203-3%203s-3-1.3431-3-3v-2h1v2c0%201.1046.8954%202%202%202s2-.8954%202-2zm-2.5-5v6h1v-6z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--lock{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m17.5%2013.5v1.5h-3v-1.5c0-.8284.6716-1.5%201.5-1.5s1.5.6716%201.5%201.5zm-4%201.5v-1.5c0-1.3807%201.1193-2.5%202.5-2.5s2.5%201.1193%202.5%202.5v1.5h.5c.2761%200%20.5.2239.5.5v5c0%20.2761-.2239.5-.5.5h-6c-.2761%200-.5-.2239-.5-.5v-5c0-.2761.2239-.5.5-.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m17.5%2045.5v1.5h-3v-1.5c0-.8284.6716-1.5%201.5-1.5s1.5.6716%201.5%201.5zm-4%201.5v-1.5c0-1.3807%201.1193-2.5%202.5-2.5s2.5%201.1193%202.5%202.5v1.5h.5c.2761%200%20.5.2239.5.5v5c0%20.2761-.2239.5-.5.5h-6c-.2761%200-.5-.2239-.5-.5v-5c0-.2761.2239-.5.5-.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m17.5%2077.5v1.5h-3v-1.5c0-.8284.6716-1.5%201.5-1.5s1.5.6716%201.5%201.5zm-4%201.5v-1.5c0-1.3807%201.1193-2.5%202.5-2.5s2.5%201.1193%202.5%202.5v1.5h.5c.2761%200%20.5.2239.5.5v5c0%20.2761-.2239.5-.5.5h-6c-.2761%200-.5-.2239-.5-.5v-5c0-.2761.2239-.5.5-.5z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--minus{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m21.5%2016.5h-11v-1h11z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m21.5%2048.5h-11v-1h11z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m21.5%2080.5h-11v-1h11z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--play{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m13%2010.0979.765.4781%208%205%20.6784.424-.6784.424-8%205-.765.4781v-.9021-10zm1%201.8042v8.1958l6.5566-4.0979z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m13%2042.0979.765.4781%208%205%20.6784.424-.6784.424-8%205-.765.4781v-.9021-10zm1%201.8042v8.1958l6.5566-4.0979z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m13%2074.0979.765.4781%208%205%20.6784.424-.6784.424-8%205-.765.4781v-.9021-10zm1%201.8042v8.1958l6.5566-4.0979z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--plus{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m15.5%2015.5v-5h1v5h5v1h-5v5h-1v-5h-5v-1z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m15.5%2047.5v-5h1v5h5v1h-5v5h-1v-5h-5v-1z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m15.5%2079.5v-5h1v5h5v1h-5v5h-1v-5h-5v-1z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--recent{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m23%2016c0%203.866-3.134%207-7%207s-7-3.134-7-7%203.134-7%207-7%207%203.134%207%207zm1%200c0%204.4183-3.5817%208-8%208s-8-3.5817-8-8%203.5817-8%208-8%208%203.5817%208%208zm-9-4v4.5.5h.5%204.5v-1h-4v-4z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m23%2048c0%203.866-3.134%207-7%207s-7-3.134-7-7%203.134-7%207-7%207%203.134%207%207zm1%200c0%204.4183-3.5817%208-8%208s-8-3.5817-8-8%203.5817-8%208-8%208%203.5817%208%208zm-9-4v4.5.5h.5%204.5v-1h-4v-4z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m23%2080c0%203.866-3.134%207-7%207s-7-3.134-7-7%203.134-7%207-7%207%203.134%207%207zm1%200c0%204.4183-3.5817%208-8%208s-8-3.5817-8-8%203.5817-8%208-8%208%203.5817%208%208zm-9-4v4.5.5h.5%204.5v-1h-4v-4z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--recent{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m16%2023.9999c4.4183%200%208-3.5817%208-8s-3.5817-8.00002-8-8.00002-8%203.58172-8%208.00002%203.5817%208%208%208zm-.0889-5.1346%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m16%2055.9999c4.4183%200%208-3.5817%208-8s-3.5817-8-8-8-8%203.5817-8%208%203.5817%208%208%208zm-.0889-5.1346%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m16%2087.9999c4.4183%200%208-3.5817%208-8s-3.5817-8-8-8-8%203.5817-8%208%203.5817%208%208%208zm-.0889-5.1346%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--resolve-filled{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m16%2023.9999c4.4183%200%208-3.5817%208-8s-3.5817-8.00002-8-8.00002-8%203.58172-8%208.00002%203.5817%208%208%208zm-.0889-5.1346%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m16%2055.9999c4.4183%200%208-3.5817%208-8s-3.5817-8-8-8-8%203.5817-8%208%203.5817%208%208%208zm-.0889-5.1346%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m16%2087.9999c4.4183%200%208-3.5817%208-8s-3.5817-8-8-8-8%203.5817-8%208%203.5817%208%208%208zm-.0889-5.1346%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--resolve{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m23%2015.9999c0%203.866-3.134%207-7%207s-7-3.134-7-7%203.134-7.00002%207-7.00002%207%203.13402%207%207.00002zm1%200c0%204.4183-3.5817%208-8%208s-8-3.5817-8-8%203.5817-8.00002%208-8.00002%208%203.58172%208%208.00002zm-8.0889%202.8654%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m23%2047.9999c0%203.866-3.134%207-7%207s-7-3.134-7-7%203.134-7%207-7%207%203.134%207%207zm1%200c0%204.4183-3.5817%208-8%208s-8-3.5817-8-8%203.5817-8%208-8%208%203.5817%208%208zm-8.0889%202.8654%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m23%2079.9999c0%203.866-3.134%207-7%207s-7-3.134-7-7%203.134-7%207-7%207%203.134%207%207zm1%200c0%204.4183-3.5817%208-8%208s-8-3.5817-8-8%203.5817-8%208-8%208%203.5817%208%208zm-8.0889%202.8654%204-4.4999-.8222-.7308-3.6125%204.0639-2.5875-2.5874-.7778.7778%203%202.9999.4125.4124z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--search{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m20%2015c0%202.7614-2.2386%205-5%205s-5-2.2386-5-5%202.2386-5%205-5%205%202.2386%205%205zm-1.1256%204.5815c-1.0453.8849-2.3975%201.4185-3.8744%201.4185-3.3137%200-6-2.6863-6-6s2.6863-6%206-6%206%202.6863%206%206c0%201.4769-.5336%202.8291-1.4185%203.8744l4.2721%204.272-.7072.7072z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m20%2047c0%202.7614-2.2386%205-5%205s-5-2.2386-5-5%202.2386-5%205-5%205%202.2386%205%205zm-1.1256%204.5815c-1.0453.8849-2.3975%201.4185-3.8744%201.4185-3.3137%200-6-2.6863-6-6s2.6863-6%206-6%206%202.6863%206%206c0%201.4769-.5336%202.8291-1.4185%203.8744l4.2721%204.272-.7072.7072z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m20%2079c0%202.7614-2.2386%205-5%205s-5-2.2386-5-5%202.2386-5%205-5%205%202.2386%205%205zm-1.1256%204.5815c-1.0453.8849-2.3975%201.4185-3.8744%201.4185-3.3137%200-6-2.6863-6-6s2.6863-6%206-6%206%202.6863%206%206c0%201.4769-.5336%202.8291-1.4185%203.8744l4.2721%204.272-.7072.7072z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--trash{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m15%209.5c-.5523%200-1%20.44772-1%201h4c0-.55228-.4477-1-1-1zm4%201c0-1.10457-.8954-2-2-2h-2c-1.1046%200-2%20.89543-2%202h-1.5-1.5v1h1v10c0%201.1046.8954%202%202%202h6c1.1046%200%202-.8954%202-2v-10h1v-1h-1.5zm1%201h-1.5-5-1.5v10c0%20.5523.4477%201%201%201h6c.5523%200%201-.4477%201-1zm-6%207v-4h1v4zm3%200v-4h1v4z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m15%2041.5c-.5523%200-1%20.4477-1%201h4c0-.5523-.4477-1-1-1zm4%201c0-1.1046-.8954-2-2-2h-2c-1.1046%200-2%20.8954-2%202h-1.5-1.5v1h1v10c0%201.1046.8954%202%202%202h6c1.1046%200%202-.8954%202-2v-10h1v-1h-1.5zm1%201h-1.5-5-1.5v10c0%20.5523.4477%201%201%201h6c.5523%200%201-.4477%201-1zm-6%207v-4h1v4zm3%200v-4h1v4z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m15%2073.5c-.5523%200-1%20.4477-1%201h4c0-.5523-.4477-1-1-1zm4%201c0-1.1046-.8954-2-2-2h-2c-1.1046%200-2%20.8954-2%202h-1.5-1.5v1h1v10c0%201.1046.8954%202%202%202h6c1.1046%200%202-.8954%202-2v-10h1v-1h-1.5zm1%201h-1.5-5-1.5v10c0%20.5523.4477%201%201%201h6c.5523%200%201-.4477%201-1zm-6%207v-4h1v4zm3%200v-4h1v4z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--unlock{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m18%2014v1h.5c.2761%200%20.5.2239.5.5v5c0%20.2761-.2239.5-.5.5h-6c-.2761%200-.5-.2239-.5-.5v-5c0-.2761.2239-.5.5-.5h4.5v-2.5c0-1.3807%201.1193-2.5%202.5-2.5s2.5%201.1193%202.5%202.5v1.5h-1v-1.5c0-.8284-.6716-1.5-1.5-1.5s-1.5.6716-1.5%201.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m18%2046v1h.5c.2761%200%20.5.2239.5.5v5c0%20.2761-.2239.5-.5.5h-6c-.2761%200-.5-.2239-.5-.5v-5c0-.2761.2239-.5.5-.5h4.5v-2.5c0-1.3807%201.1193-2.5%202.5-2.5s2.5%201.1193%202.5%202.5v1.5h-1v-1.5c0-.8284-.6716-1.5-1.5-1.5s-1.5.6716-1.5%201.5z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m18%2078v1h.5c.2761%200%20.5.2239.5.5v5c0%20.2761-.2239.5-.5.5h-6c-.2761%200-.5-.2239-.5-.5v-5c0-.2761.2239-.5.5-.5h4.5v-2.5c0-1.3807%201.1193-2.5%202.5-2.5s2.5%201.1193%202.5%202.5v1.5h-1v-1.5c0-.8284-.6716-1.5-1.5-1.5s-1.5.6716-1.5%201.5z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.icon--visible{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2296%22%20viewBox%3D%220%200%2032%2096%22%20width%3D%2232%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cg%20clip-rule%3D%22evenodd%22%20fill-rule%3D%22evenodd%22%3E%3Cpath%20d%3D%22m16.0001%2019c-2.2999%200-4.3222-1.1942-5.4784-3%201.1562-1.8058%203.1785-3%205.4784-3%202.2998%200%204.3221%201.1942%205.4783%203-1.1562%201.8058-3.1785%203-5.4783%203zm0-7c2.878%200%205.3774%201.6211%206.6349%204-1.2575%202.3789-3.7569%204-6.6349%204-2.8781%200-5.3775-1.6211-6.63499-4%201.25749-2.3789%203.75689-4%206.63499-4zm.0003%206c1.1045%200%202-.8954%202-2s-.8955-2-2-2c-1.1046%200-2%20.8954-2%202s.8954%202%202%202z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.8%22%2F%3E%3Cpath%20d%3D%22m16.0001%2051c-2.2999%200-4.3222-1.1942-5.4784-3%201.1562-1.8058%203.1785-3%205.4784-3%202.2998%200%204.3221%201.1942%205.4783%203-1.1562%201.8058-3.1785%203-5.4783%203zm0-7c2.878%200%205.3774%201.6211%206.6349%204-1.2575%202.3789-3.7569%204-6.6349%204-2.8781%200-5.3775-1.6211-6.63499-4%201.25749-2.3789%203.75689-4%206.63499-4zm.0003%206c1.1045%200%202-.8954%202-2s-.8955-2-2-2c-1.1046%200-2%20.8954-2%202s.8954%202%202%202z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%2F%3E%3Cpath%20d%3D%22m16.0001%2083c-2.2999%200-4.3222-1.1942-5.4784-3%201.1562-1.8058%203.1785-3%205.4784-3%202.2998%200%204.3221%201.1942%205.4783%203-1.1562%201.8058-3.1785%203-5.4783%203zm0-7c2.878%200%205.3774%201.6211%206.6349%204-1.2575%202.3789-3.7569%204-6.6349%204-2.8781%200-5.3775-1.6211-6.63499-4%201.25749-2.3789%203.75689-4%206.63499-4zm.0003%206c1.1045%200%202-.8954%202-2s-.8955-2-2-2c-1.1046%200-2%20.8954-2%202s.8954%202%202%202z%22%20fill%3D%22%2318a0fb%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E\a")}.label{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;height:32px;padding:8px 4px 8px 8px;color:var(--color-text-tertiary);background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.section-title{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;height:32px;padding:8px 4px 8px 8px;color:var(--color-text-secondary);background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:600;font-size:11px;letter-spacing:.005em}.type{margin:0;padding:0}.type--11-pos{color:var(--color-text-secondary);font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.type--11-pos-medium{color:var(--color-text-secondary);font-family:Inter,sans-serif;line-height:16px;font-weight:500;font-size:11px;letter-spacing:.005em}.type--11-pos-bold{color:var(--color-text-secondary);font-family:Inter,sans-serif;line-height:16px;font-weight:600;font-size:11px;letter-spacing:.005em}.type--12-pos{color:var(--color-text-secondary);font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:12px;letter-spacing:0}.type--12-pos-medium{color:var(--color-text-secondary);font-family:Inter,sans-serif;line-height:16px;font-weight:500;font-size:12px;letter-spacing:0}.type--12-pos-bold{font-family:Inter,sans-serif;line-height:16px;font-weight:600;font-size:12px;letter-spacing:0}.type--11-neg{color:#fff;font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.01em}.type--11-neg-medium{color:#fff;font-family:Inter,sans-serif;line-height:16px;font-weight:500;font-size:11px;letter-spacing:.01em}.type--11-neg-bold{color:#fff;font-family:Inter,sans-serif;line-height:16px;font-weight:600;font-size:11px;letter-spacing:.01em}.type--12-neg{color:#fff;font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:12px;letter-spacing:.005em}.type--12-neg-medium{color:#fff;font-family:Inter,sans-serif;line-height:16px;font-weight:500;font-size:12px;letter-spacing:.005em}.type--12-neg-bold{color:#fff;font-family:Inter,sans-serif;line-height:16px;font-weight:600;font-size:12px;letter-spacing:.005em}.button{display:inline-block;-ms-flex-negative:0;flex-shrink:0;margin:1px 0 1px 0;padding:5px 16px 5px 16px;border:2px solid transparent;border-radius:6px;outline:0}.button--primary{color:#fff;background-color:var(--color-interactive-positive);font-family:Inter,sans-serif;line-height:16px;font-weight:500;font-size:11px;letter-spacing:.01em}.button--primary:active,.button--primary:focus{border:2px solid var(--color-separator)}.button--primary:disabled{background-color:var(--color-text-tertiary)}.button--primary-destructive{color:#fff;background-color:#f24822}.button--primary-destructive:active,.button--primary-destructive:focus{border:2px solid var(--color-separator)}.button--primary-destructive:disabled{opacity:.4}.button--secondary{color:var(--color-text-secondary);border:1px solid var(--color-separator);background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:500;font-size:11px;letter-spacing:.005em}.button--secondary:active,.button--secondary:focus{padding:4px 23px 4px 23px;border:2px solid var(--color-interactive-positive)}.button--secondary:disabled{color:var(--color-text-tertiary);border:1px solid var(--color-separator)}.button--secondary-destructive{color:#f24822;border:1px solid #f24822;background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:500;font-size:11px;letter-spacing:.005em}.button--secondary-destructive:active,.button--secondary-destructive:focus{padding:4px 23px 4px 23px;border:2px solid #f24822}.button--secondary-destructive:disabled{opacity:.4}.input{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;width:100%;height:30px;margin:1px 0 1px 0;padding:8px 4px 8px 7px;color:var(--color-text-secondary);border:1px solid transparent;border-radius:2px;outline:0;background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.input:hover{color:var(--color-text-secondary);border:1px solid rgba(0,0,0,.1)}.input:active,.input:focus{padding:8px 4px 8px 6px;color: var(--color-text-primary);border:2px solid var(--color-interactive-positive);border-radius:2px}.input::-moz-selection{color: var(--color-text-primary);background-color:rgba(24,145,251,.3)}.input::selection{color: var(--color-text-primary);background-color:rgba(24,145,251,.3)}.input::-webkit-input-placeholder{color:var(--color-text-tertiary)}.input:-ms-input-placeholder{color:var(--color-text-tertiary)}.input::-ms-input-placeholder{color:var(--color-text-tertiary)}.input::placeholder{color:var(--color-text-tertiary)}.input:disabled{color:var(--color-text-tertiary)}.input-icon{position:relative;width:100%}.input-icon__icon{position:absolute;top:-1px;left:0;width:32px;height:32px}.input-icon__input{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;width:100%;height:30px;margin:1px 0 1px 0;padding:8px 4px 8px 0;text-indent:32px;color:var(--color-text-secondary);border:1px solid transparent;border-radius:2px;outline:0;background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.input-icon__input:hover{color:var(--color-text-secondary);border:1px solid rgba(0,0,0,.1)}.input-icon__input:active,.input-icon__input:focus{margin-left:-1px;padding:8px 4px 8px 0;color: var(--color-text-primary);border:2px solid var(--color-interactive-positive);border-radius:2px}.input-icon__input::-moz-selection{color: var(--color-text-primary);background-color:rgba(24,145,251,.3)}.input-icon__input::selection{color: var(--color-text-primary);background-color:rgba(24,145,251,.3)}.input-icon__input::-webkit-input-placeholder{color:var(--color-text-tertiary)}.input-icon__input:-ms-input-placeholder{color:var(--color-text-tertiary)}.input-icon__input::-ms-input-placeholder{color:var(--color-text-tertiary)}.input-icon__input::placeholder{color:var(--color-text-tertiary)}.input-icon__input:disabled{color:var(--color-text-tertiary)}.textarea{display:-webkit-box;display:-ms-flexbox;display:flex;overflow:hidden;-webkit-box-align:center;-ms-flex-align:center;align-items:center;min-height:62px;margin:1px 8px 1px 8px;padding:7px 4px 7px 7px;resize:none;color:var(--color-text-secondary);border:1px solid rgba(0,0,0,.1);border-radius:2px;outline:0;background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.textarea:active,.textarea:focus{padding:6px 4px 6px 6px;color: var(--color-text-primary);border:2px solid var(--color-interactive-positive);border-radius:2px}.textarea::-moz-selection{color: var(--color-text-primary);background-color:rgba(24,145,251,.3)}.textarea::selection{color: var(--color-text-primary);background-color:rgba(24,145,251,.3)}.textarea::-webkit-input-placeholder{color:var(--color-text-tertiary)}.textarea:-ms-input-placeholder{color:var(--color-text-tertiary)}.textarea::-ms-input-placeholder{color:var(--color-text-tertiary)}.textarea::placeholder{color:var(--color-text-tertiary)}.textarea:disabled{color:var(--color-text-tertiary)}.textarea:disabled:focus{border:1px solid rgba(0,0,0,.1)}.select-dropdown{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-webkit-box-flex:2;-ms-flex-positive:2;flex-grow:2;-ms-flex-wrap:nowrap;flex-wrap:nowrap;width:100%;font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.select-dropdown:last-child{margin-right:0}.select-dropdown__button{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;width:100%;height:30px;margin:1px 0 1px 0!important;padding:0 8px 0 8px;text-align:left;cursor:pointer;color:var(--color-text-secondary);border:1px solid transparent;border-radius:2px;background-color: var(--color-background-primary);font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.select-dropdown__button span:after{display:inline-block;width:7px;height:5px;margin-top:6px;margin-left:6px;content:'';background-color:transparent;background-image:url(data:image/svg+xml;utf8,%3Csvg%20fill%3D%22none%22%20height%3D%225%22%20viewBox%3D%220%200%207%205%22%20width%3D%227%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20clip-rule%3D%22evenodd%22%20d%3D%22m3%203.70711-3-3.000003.707107-.707107%202.646443%202.64645%202.64645-2.64645.70711.707107-3%203.000003-.35356.35355z%22%20fill%3D%22%23000%22%20fill-opacity%3D%22.3%22%20fill-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E)}.select-dropdown__button:hover{padding:0 8px 0 8px;border:1px solid rgba(0,0,0,.1)}.select-dropdown__button:hover .chevron-down{opacity:1}.select-dropdown__button:hover span:after{opacity:0}.select-dropdown__button .chevron-down{position:absolute;top:1px;right:0;width:30px;height:30px;opacity:0;background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2230%22%20viewBox%3D%220%200%2030%2030%22%20width%3D%2230%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20clip-rule%3D%22evenodd%22%20d%3D%22m15%2016.7071-3-3%20.7071-.7071%202.6465%202.6464%202.6464-2.6464.7071.7071-3%203-.3535.3536z%22%20fill%3D%22%23000%22%20fill-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E\a");background-repeat:no-repeat;background-position:0 0}.select-dropdown__button--active,.select-dropdown__button:focus{width:100%;padding:0 7px 0 7px;border:2px solid var(--color-interactive-positive);outline:0}.select-dropdown__button--active .chevron-down,.select-dropdown__button:focus .chevron-down{opacity:1}.select-dropdown__button--active span:after,.select-dropdown__button:focus span:after{opacity:0}.select-dropdown__list{position:absolute;z-index:2;top:31px;right:0;left:0;display:block;overflow:auto;width:100%;margin:0;padding:0;list-style-type:none;pointer-events:none;opacity:0;-webkit-box-shadow:0 5px 17px rgba(0,0,0,.2),0 2px 7px rgba(0,0,0,.15);box-shadow:0 5px 17px rgba(0,0,0,.2),0 2px 7px rgba(0,0,0,.15)}.select-dropdown__list:before{display:block;height:8px;content:'';border-top-left-radius:2px;border-top-right-radius:2px;background-color:#222}.select-dropdown__list:after{display:block;height:8px;content:'';border-bottom-right-radius:2px;border-bottom-left-radius:2px;background-color:#222}.select-dropdown__list.active{pointer-events:auto;opacity:1}.select-dropdown__list-item{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;width:100%;height:24px;padding:0 16px 0 32px;list-style-type:none;text-align:left;cursor:pointer;color:#fff;background-color:#222;font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:12px;letter-spacing:.005em}.select-dropdown__list-item:hover{color:#fff;background-color:var(--color-interactive-positive)}.select-dropdown__list-item--selected{background-image:url("data:image/svg+xml;utf8,\a %3Csvg%20fill%3D%22none%22%20height%3D%2216%22%20viewBox%3D%220%200%2016%2016%22%20width%3D%2216%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20clip-rule%3D%22evenodd%22%20d%3D%22m13.2069%205.20724-5.50002%205.49996-.70711.7072-.70711-.7072-3-2.99996%201.41422-1.41421%202.29289%202.29289%204.79293-4.79289z%22%20fill%3D%22%23fff%22%20fill-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E\a");background-repeat:no-repeat;background-position:8px 4px}.select-dropdown__list-item--initial{background-color:var(--color-interactive-positive)}.select-dropdown__divider{margin:0;padding:8px 0 8px 0;background-color:#222}.select-dropdown__line{display:block;height:1px;background-color:rgba(255,255,255,.2)}.switch{position:relative;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-ms-flex-item-align:1;align-self:1;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;cursor:default}.switch__container{position:relative;width:24px;height:12px;margin:10px 16px 10px 8px}.switch__label{font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.switch__checkbox{width:0;height:0;opacity:0}.switch__checkbox:checked+.switch__slider{background-color: var(--color-interactive-positive)}.switch__checkbox:focus+.switch__slider{-webkit-box-shadow:0 0 1px var(--color-interactive-positive);box-shadow:0 0 1px var(--color-interactive-positive)}.switch__checkbox:checked+.switch__slider:before{-webkit-transform:translateX(12px);transform:translateX(12px)}.switch__slider{position:absolute;top:0;right:0;bottom:0;left:0;-webkit-transition:-webkit-transform .2s;transition:-webkit-transform .2s;transition:transform .2s;transition:transform .2s,-webkit-transform .2s;-webkit-transition:background-color 0 .2s;transition:background-color 0 .2s;border:1px solid var(--color-separator);border-radius:12px;background-color: var(--color-decorator-regular)}.switch__slider::before{position:absolute;top:-1px;left:-1px;width:10px;height:10px;content:'';-webkit-transition:-webkit-transform .2s;transition:-webkit-transform .2s;transition:transform .2s;transition:transform .2s,-webkit-transform .2s;-webkit-transition:background-color 0 .2s;transition:background-color 0 .2s;border:1px solid var(--color-separator);border-radius:50%;background-color: var(--color-decorator-soft-invert)}.checkbox{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;height:28px;cursor:default}.checkbox__container{position:relative;width:32px;height:32px}.checkbox__label{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding-top:4px;font-family:Inter,sans-serif;line-height:16px;font-weight:400;font-size:11px;letter-spacing:.005em}.checkbox__box{position:absolute;width:0;height:0;opacity:0}.checkbox__box:checked~.checkbox__mark{border:1px solid var(--color-interactive-positive);background-color:var(--color-interactive-positive)}.checkbox__box:checked~.checkbox__mark:after{display:block}.checkbox__mark{position:absolute;top:10px;left:10px;width:12px;height:12px;border:1px solid var(--color-separator);border-radius:2px;background-color: var(--color-background-primary)}.checkbox__mark:after{position:absolute;width:12px;height:12px;content:'';background-image:url(data:image/svg+xml;utf8,%3Csvg%20fill%3D%22none%22%20height%3D%227%22%20viewBox%3D%220%200%208%207%22%20width%3D%228%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%3E%3Cpath%20clip-rule%3D%22evenodd%22%20d%3D%22m1.17647%201.88236%201.88235%201.88236%203.76471-3.76472%201.17647%201.17648-4.94118%204.9412-3.05882-3.05884z%22%20fill%3D%22%23fff%22%20fill-rule%3D%22evenodd%22%2F%3E%3C%2Fsvg%3E);background-repeat:no-repeat;background-position:1px 2px}.divider{display:block;width:100%;height:1px;margin:8px 0 8px 0;padding:0;background-color:#e5e5e5} 3 | 4 | /* NEW FIGMA UI PARTIALS */ 5 | .icon--distribute-horizontal-spacing { 6 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg fill='none' height='32' width='32' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23000'%3E%3Cpath d='M11 22.5v-13h-1v13zM22 9.5v13h-1v-13zM17 12.5v7h-2v-7z'/%3E%3C/g%3E%3C/svg%3E"); 7 | } 8 | 9 | .icon--distribute-vertical-spacing { 10 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg fill='none' height='32' width='32' xmlns='http://www.w3.org/2000/svg'%3E%3Cg fill='%23000'%3E%3Cpath d='M9.5 10h13v1h-13zM12.5 15h7v2h-7zM22.5 21h-13v1h13z'/%3E%3C/g%3E%3C/svg%3E"); 11 | } 12 | 13 | .icon--frame { 14 | background-image: url("data:image/svg+xml;charset=utf8,%3Csvg fill='none' height='32' width='32' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath clip-rule='evenodd' d='M11 24v-3H8v-1h3v-8H8v-1h3V8h1v3h8V8h1v3h3v1h-3v8h3v1h-3v3h-1v-3h-8v3zm9-4v-8h-8v8z' fill='%23000' fill-rule='evenodd'/%3E%3C/svg%3E"); 15 | } 16 | -------------------------------------------------------------------------------- /src/ui/components/display/DisplayComponent.css: -------------------------------------------------------------------------------- 1 | c-display[hidden] { display: block!important; height: 0; padding: 0 16px; opacity: 0.4; border: none; pointer-events: none; } 2 | 3 | c-display { display: block; padding: 8px 16px 16px; background: var(--color-decorator-soft); border-bottom: solid 1px var(--color-decorator-regular); transition: all 0.25s ease-out; } 4 | c-display picture { position: relative; width: 48px; height: 48px; border-radius: 8px; margin-right: 16px; float: left; overflow: hidden; } 5 | c-display picture img { width: 100%; } 6 | c-display picture:after { content: ""; position: absolute; left: 0; top: 0; right: 0; bottom: 0; border: solid 1px var(--color-decorator-regular); border-radius: 8px; } 7 | 8 | c-display section { overflow: hidden; } 9 | c-display em { display: block; font: var(--type-small-normal); color: var(--color-text-tertiary); margin: 0 0 8px; } 10 | c-display h1 { font: var(--type-large-bold); line-height: 16px; color: var(--color-text-primary); margin-bottom: 4px; } 11 | c-display p { font: var(--type-medium-normal); color: var(--color-text-secondary); margin-bottom: 8px; } 12 | c-display a { font: var(--type-medium-medium); color: var(--color-interactive-positive); } 13 | -------------------------------------------------------------------------------- /src/ui/components/display/DisplayComponent.js: -------------------------------------------------------------------------------- 1 | import './DisplayComponent.css' 2 | import Element from 'src/ui/Element' 3 | import DisplayNetwork from 'src/utils/DisplayNetwork' 4 | import Tracking from 'src/utils/Tracking' 5 | import FigPen from 'src/utils/FigPen' 6 | import CONFIG from 'src/Config' 7 | 8 | const PLUGIN_NAME = 'super_tidy' 9 | 10 | class DisplayComponent extends Element { 11 | 12 | beforeMount() { 13 | // avoid display without proper data 14 | if (this.attrs.lastshowndate == 'undefined') return 15 | 16 | this.FP = new FigPen(CONFIG) 17 | 18 | DisplayNetwork.getAvailableAd(this.attrs.lastshowndate, this.attrs.lastshownimpression) 19 | .then(ad => { 20 | // if we have an available ad, then render and display it 21 | if (!!ad) { 22 | ad.link = ad.link + '&utm_campaign='+PLUGIN_NAME 23 | this.data.ad = ad 24 | this.showDisplay() 25 | } 26 | }) 27 | } 28 | 29 | showDisplay() { 30 | this.removeAttribute('hidden') 31 | Tracking.track('displayImpression', { campaign: this.data.ad.tracking }) 32 | this.FP.notifyEditor({ type: 'displayImpression' }) 33 | } 34 | 35 | bind() { 36 | this.addEventListener('click', e => { 37 | if (e.target.getAttribute('data-trigger') == 'cta') { 38 | Tracking.track('displayClick', { campaign: this.data.ad.tracking }) 39 | } 40 | }) 41 | } 42 | 43 | render() { 44 | if (!this.data.ad) return 45 | 46 | let ad = this.data.ad 47 | return ` 48 | Sponsored 49 | 50 |
51 |

${ad.headline}

52 |

${ad.description}

53 | ${this.data.ad.cta} 54 |
55 | ` 56 | } 57 | } 58 | 59 | customElements.define('c-display', DisplayComponent) 60 | -------------------------------------------------------------------------------- /src/ui/components/select/SelectComponent.css: -------------------------------------------------------------------------------- 1 | c-select { 2 | position: relative; 3 | display: block; 4 | -webkit-box-sizing: border-box; 5 | box-sizing: border-box; 6 | width: 100%; 7 | cursor: default; 8 | } 9 | 10 | .select-menu[disabled] { 11 | opacity: 0.3; 12 | } 13 | 14 | c-select button { 15 | font: var(--type-small-normal); 16 | position: relative; 17 | display: flex; 18 | justify-content: flex-start; 19 | align-items: center; 20 | width: 100%; 21 | height: 30px; 22 | margin: 1px 0 1px 0; 23 | padding: 6px 0 6px 8px; 24 | cursor: default; 25 | color: var(--color-text-secondary); 26 | border-radius: 2px; 27 | } 28 | 29 | c-select button:hover { box-shadow: 0 0 0 1px var(--color-separator) inset; } 30 | c-select button:focus, c-select button:active { 31 | box-shadow: 0 0 0 2px var(--color-interactive-positive) inset; 32 | outline: none; 33 | } 34 | c-select button .c-icon { pointer-events: none; } 35 | c-select button .c-icon.default path { fill: var(--color-text-tertiary); } 36 | c-select button .c-icon.hover { opacity: 0; position: absolute; right: 4px; top: 7px; } 37 | c-select button .c-icon.hover path { fill: var(--color-text-primary); } 38 | c-select:hover button .c-icon.default { opacity: 0; } 39 | c-select:hover button .c-icon.hover { opacity: 1; } 40 | 41 | c-select[reverse] button { flex-direction: row-reverse; padding: 6px 8px 6px 0; } 42 | c-select[reverse] button .c-icon.default { margin: 0 4px 0 0; } 43 | c-select[reverse] button .c-icon.hover { right: auto; left: 4px; } 44 | 45 | c-select ul { 46 | position: absolute; 47 | z-index: 2; 48 | display: flex; 49 | flex-direction: column; 50 | width: 100%; 51 | margin: 0; 52 | padding: 8px 0 8px 0; 53 | border-radius: 2px; 54 | background-color: var(--color-background-primary); 55 | box-shadow: 0 5px 17px rgba(0, 0, 0, 0.2), 0 2px 7px rgba(0, 0, 0, 0.15); 56 | } 57 | 58 | c-select li { 59 | font: var(--type-medium-normal); 60 | display: flex; 61 | align-items: center; 62 | height: 24px; 63 | padding: 0 8px; 64 | color: var(--color-text-primary); 65 | } 66 | c-select li:hover { background-color: var(--color-interactive-positive); } 67 | 68 | c-select li.separator { pointer-events: none; height: 1px; margin: 8px 0; background-color: rgba(255, 255, 255, 0.2); } 69 | c-select li .c-icon { display: inline-block; opacity: 0; margin-right: 8px; pointer-events: none; } 70 | c-select li .c-icon path { fill: #FFFFFF; stroke: #FFFFFF; } 71 | c-select li[selected] .c-icon { opacity: 1; } 72 | -------------------------------------------------------------------------------- /src/ui/components/select/SelectComponent.js: -------------------------------------------------------------------------------- 1 | import './SelectComponent.css' 2 | import Element from 'src/ui/Element' 3 | import 'src/ui/icons/IconShow' 4 | import 'src/ui/icons/IconCheck' 5 | 6 | class SelectComponent extends Element { 7 | 8 | toggleList() { 9 | let list = this.find('[data-select=list]'); 10 | (list.hasAttribute('hidden')) ? list.removeAttribute('hidden') : list.setAttribute('hidden', '') 11 | } 12 | 13 | selectItem(item) { 14 | let value = '' 15 | let group = item.getAttribute('data-group') 16 | let isToggle = (item.hasAttribute('data-toggle')) 17 | 18 | if (!isToggle) { 19 | let list = this.findAll(`[data-select=item][data-group=${group}]`) 20 | value = item.getAttribute('data-value') 21 | list.forEach(node => node.removeAttribute('selected')) 22 | item.setAttribute('selected', '') 23 | if (!this.attrs.label) this.find('[data-select=label]').innerHTML = item.getAttribute('data-label') 24 | } else { 25 | let values = JSON.parse(item.getAttribute('data-toggle')) 26 | let isSelected = (item.hasAttribute('selected')); 27 | value = (isSelected) ? `${values[0]}` : `${values[1]}`; 28 | (isSelected) ? item.removeAttribute('selected') : item.setAttribute('selected', ''); 29 | } 30 | this.toggleList() 31 | this.dispatchEvent(new CustomEvent('change', { bubbles: true, detail: { value: value, group: group } })) 32 | } 33 | 34 | onClick(e) { 35 | if (e.target.getAttribute('data-trigger') == 'open') { 36 | this.toggleList() 37 | } 38 | 39 | if (e.target.getAttribute('data-select') == 'item') { 40 | this.selectItem(e.target) 41 | } 42 | } 43 | 44 | renderItem(item) { 45 | let isSelected = (item.hasAttribute('selected')) 46 | let hasGroup = (item.hasAttribute('group')) 47 | let hasToggle = (item.hasAttribute('toggle')) 48 | let toggleValues = (item.getAttribute('toggle')) 49 | let groupName = item.getAttribute('group') 50 | let valueAttr = (hasToggle) ? `data-toggle="${toggleValues}"` : `data-value="${item.value}"`; 51 | let putSeparator = (hasGroup && this.lastGroup != '' && this.lastGroup != groupName) 52 | if (hasGroup) this.lastGroup = groupName 53 | 54 | return ` 55 | ${(putSeparator) ? `
  • ` : ''} 56 |
  • 57 | 58 | ${item.innerText} 59 |
  • 60 | ` 61 | } 62 | 63 | render() { 64 | this.lastGroup = '' 65 | let defaults = Array.from(this.findAll('option')) 66 | let defaultSelection = defaults.reduce((buffer, item) => { 67 | if (item.hasAttribute('selected')) buffer += item.innerText 68 | return buffer 69 | }, '') 70 | 71 | return` 72 | 77 | 83 | ` 84 | } 85 | } 86 | 87 | customElements.define('c-select', SelectComponent) 88 | -------------------------------------------------------------------------------- /src/ui/components/toolbar/ToolbarComponent.css: -------------------------------------------------------------------------------- 1 | c-toolbar { 2 | position: relative; 3 | z-index: 5; 4 | display: flex; 5 | align-items: center; 6 | justify-content: space-between; 7 | padding: 0 16px 0 8px; 8 | overflow: hidden; 9 | border-bottom: solid 1px var(--color-separator); 10 | } 11 | c-toolbar nav, c-toolbar header { overflow: hidden; } 12 | 13 | c-toolbar nav ul { display: flex; flex-direction: row; } 14 | c-toolbar nav li a { 15 | display: block; 16 | padding: 12px 8px; 17 | font: var(--type-medium-bold); 18 | color: var(--color-text-tertiary); 19 | transition: 0.05s ease-in color; 20 | } 21 | c-toolbar nav .active a, c-toolbar nav li a:hover { color: var(--color-text-primary); text-decoration: none; } 22 | 23 | c-toolbar .version { 24 | font: var(--type-medium-normal); 25 | color: var(--color-text-tertiary); 26 | } 27 | -------------------------------------------------------------------------------- /src/ui/components/toolbar/ToolbarComponent.js: -------------------------------------------------------------------------------- 1 | import './ToolbarComponent.css' 2 | import Router from 'src/utils/Router' 3 | import Tracking from 'src/utils/Tracking' 4 | import Element from 'src/ui/Element' 5 | import packageJson from '@/package.json' 6 | 7 | class ToolbarComponent extends Element { 8 | 9 | beforeMount() { 10 | this.data.currentView = Router.url 11 | } 12 | 13 | onClick(e) { 14 | Tracking.track('clickToolbarLink', { url: event.target.getAttribute('href') }) 15 | } 16 | 17 | bind() { 18 | super.bind() 19 | Router.on('change:url', url => this.data.currentView = url) 20 | } 21 | 22 | render() { 23 | let currentView = this.data.currentView 24 | let isIndex = (currentView === Router.routes.index || currentView === '' || typeof currentView === 'undefined') 25 | let isPreferences = (currentView === Router.routes.preferences) 26 | let isLicense = (currentView === Router.routes.license) 27 | 28 | return` 29 | 36 |
    v${packageJson.version}
    37 | ` 38 | } 39 | } 40 | 41 | customElements.define('c-toolbar', ToolbarComponent) 42 | -------------------------------------------------------------------------------- /src/ui/icons/IconCheck.js: -------------------------------------------------------------------------------- 1 | import Element from 'src/ui/Element' 2 | 3 | class IconCheck extends Element { 4 | render() { 5 | return` 6 | 7 | 8 | 9 | ` 10 | } 11 | } 12 | 13 | customElements.define('i-check', IconCheck) 14 | -------------------------------------------------------------------------------- /src/ui/icons/IconShow.js: -------------------------------------------------------------------------------- 1 | import Element from 'src/ui/Element' 2 | 3 | class IconShow extends Element { 4 | render() { 5 | return` 6 | 7 | 8 | 9 | 10 | ` 11 | } 12 | } 13 | 14 | customElements.define('i-show', IconShow) 15 | -------------------------------------------------------------------------------- /src/ui/views/countdown/AnalogChronometer.css: -------------------------------------------------------------------------------- 1 | /* Simple Analog Chronometer - Circle with red needle */ 2 | 3 | .analog-chrono { 4 | position: relative; 5 | width: 80px; 6 | height: 80px; 7 | margin: 0 auto; 8 | } 9 | 10 | .chrono-face { 11 | position: relative; 12 | width: 100%; 13 | height: 100%; 14 | border-radius: 50%; 15 | background: var(--color-background-primary); 16 | border: 2px solid var(--color-separator); 17 | } 18 | 19 | .chrono-face::before { 20 | content: ''; 21 | position: absolute; 22 | top: 50%; 23 | left: 50%; 24 | width: 4px; 25 | height: 4px; 26 | background: #ff0000; 27 | border-radius: 50%; 28 | transform: translate(-50%, -50%); 29 | z-index: 2; 30 | } 31 | 32 | .chrono-hand { 33 | position: absolute; 34 | top: 50%; 35 | left: 50%; 36 | width: 1px; 37 | height: 35px; 38 | background: #ff0000; 39 | box-shadow: 0 0 0 1px var(--color-background-primary); 40 | transform-origin: bottom center; 41 | transform: translate(-50%, -100%) rotate(0deg); 42 | transition: transform 1s ease-in-out; 43 | z-index: 1; 44 | } 45 | 46 | /* Tick marks for each second */ 47 | .chrono-tick { 48 | position: absolute; 49 | width: 1px; 50 | height: 6px; 51 | background: var(--color-text-tertiary); 52 | top: 2px; 53 | left: 50%; 54 | transform-origin: 50% 37px; 55 | opacity: 0.4; 56 | } 57 | 58 | -------------------------------------------------------------------------------- /src/ui/views/countdown/AnalogChronometer.js: -------------------------------------------------------------------------------- 1 | import './AnalogChronometer.css' 2 | import Element from 'src/ui/Element' 3 | 4 | class AnalogChronometer extends Element { 5 | 6 | beforeMount() { 7 | this.data.totalSeconds = 0 8 | this.data.currentSeconds = 0 9 | } 10 | 11 | mount() { 12 | // Update hand position when component mounts 13 | this.updateFromAttrs() 14 | this.updateHand() 15 | } 16 | 17 | 18 | updateFromAttrs() { 19 | this.data.totalSeconds = parseInt(this.attrs['total-seconds']) || 0 20 | this.data.currentSeconds = parseInt(this.attrs['current-seconds']) || 0 21 | } 22 | 23 | updateHand() { 24 | // Calculate rotation: starts at 0deg (12 o'clock), completes full circle 25 | const progress = this.data.totalSeconds > 0 ? 26 | 1 - (this.data.currentSeconds / this.data.totalSeconds) : 0 27 | const rotation = 0 + (progress * 360) 28 | 29 | const hand = this.find('.chrono-hand') 30 | if (hand) { 31 | hand.style.transform = `translate(-50%, -100%) rotate(${rotation}deg)` 32 | } 33 | } 34 | 35 | generateTicks() { 36 | const totalSeconds = this.data.totalSeconds || 15 37 | let ticks = '' 38 | 39 | // Generate one tick for each second position where the needle will stop 40 | for (let i = 0; i < totalSeconds; i++) { 41 | const angle = (i / totalSeconds) * 360 // Evenly distribute around circle 42 | ticks += `
    ` 43 | } 44 | 45 | return ticks 46 | } 47 | 48 | render() { 49 | return ` 50 |
    51 |
    52 | ${this.generateTicks()} 53 |
    54 |
    55 |
    56 | ` 57 | } 58 | } 59 | 60 | customElements.define('analog-chrono', AnalogChronometer) 61 | 62 | -------------------------------------------------------------------------------- /src/ui/views/countdown/CountdownView.css: -------------------------------------------------------------------------------- 1 | /* COUNTDOWN VIEW - Following project styling patterns */ 2 | 3 | v-countdown { 4 | display: block; 5 | padding: 16px; 6 | } 7 | 8 | v-countdown .countdown-container { 9 | text-align: center; 10 | padding: 24px 0; 11 | } 12 | 13 | v-countdown h1 { 14 | font: var(--type-large-bold); 15 | color: var(--color-text-primary); 16 | margin-bottom: 4px; 17 | } 18 | 19 | v-countdown p { 20 | font: var(--type-medium-normal); 21 | color: var(--color-text-secondary); 22 | margin-bottom: 20px; 23 | line-height: 1.4; 24 | } 25 | 26 | v-countdown .countdown-timer { 27 | font: var(--type-small-bold); 28 | color: var(--color-text-secondary); 29 | margin: 0; 30 | position: relative; 31 | top: -32px; 32 | left: 4px; 33 | font-variant-numeric: tabular-nums; 34 | } 35 | 36 | v-countdown .countdown-timer-hint { 37 | font: var(--type-small-normal); 38 | color: var(--color-text-tertiary); 39 | line-height: 1.4; 40 | text-align: center; 41 | max-width: 200px; 42 | margin: 0 auto 20px; 43 | } 44 | 45 | v-countdown .button { 46 | margin: 8px 4px 0; 47 | min-width: 120px; 48 | } 49 | 50 | v-countdown .button:first-of-type { 51 | margin-left: 0; 52 | } 53 | 54 | v-countdown .button:last-of-type { 55 | margin-right: 0; 56 | } 57 | -------------------------------------------------------------------------------- /src/ui/views/countdown/CountdownView.js: -------------------------------------------------------------------------------- 1 | import './CountdownView.css' 2 | import Element from 'src/ui/Element' 3 | import Tracking from 'src/utils/Tracking' 4 | import './AnalogChronometer' 5 | 6 | class CountdownView extends Element { 7 | 8 | beforeMount() { 9 | // Initialize countdown state 10 | this.data.seconds = 0 11 | this.data.commandName = '' 12 | this.data.intervalId = null 13 | this.data.onComplete = null 14 | this.data.countdownFinished = false 15 | } 16 | 17 | startCountdown(seconds, commandName, onComplete) { 18 | this.data.seconds = seconds 19 | this.data.totalSeconds = seconds 20 | this.data.commandName = commandName 21 | this.data.onComplete = onComplete 22 | this.data.countdownFinished = false 23 | this.render() 24 | 25 | // Clear any existing interval 26 | if (this.data.intervalId) { 27 | clearInterval(this.data.intervalId) 28 | } 29 | 30 | // Start countdown timer 31 | this.data.intervalId = setInterval(() => { 32 | this.data.seconds-- 33 | this.render() // This will update the chronometer via attributes 34 | 35 | if (this.data.seconds <= 0) { 36 | this.finishCountdown() 37 | } 38 | }, 1000) 39 | } 40 | 41 | finishCountdown() { 42 | // Track countdown completion 43 | Tracking.track('countdownCompleted', { 44 | commandName: this.data.commandName, 45 | totalSeconds: this.data.totalSeconds, 46 | completionRate: 1.0 47 | }) 48 | 49 | // Clear interval 50 | if (this.data.intervalId) { 51 | clearInterval(this.data.intervalId) 52 | this.data.intervalId = null 53 | } 54 | 55 | // Mark countdown as finished and show button 56 | this.data.countdownFinished = true 57 | this.data.seconds = 0 58 | this.render() 59 | } 60 | 61 | executeCommand() { 62 | // Track "Run Now" button click 63 | Tracking.track('runNowClicked', { 64 | commandName: this.data.commandName, 65 | waitTime: this.data.totalSeconds 66 | }) 67 | 68 | // Call the completion callback directly 69 | if (this.data.onComplete) { 70 | this.data.onComplete() 71 | } 72 | } 73 | 74 | handleGetPro() { 75 | // Track "Get Super Tidy Pro" click from countdown 76 | Tracking.track('getProClickedFromCountdown', { 77 | commandName: this.data.commandName, 78 | secondsRemaining: this.data.seconds, 79 | source: 'countdown' 80 | }) 81 | 82 | window.open('https://basiclines.gumroad.com/l/super-tidy-pro', '_blank') 83 | } 84 | 85 | bind() { 86 | // Handle button clicks 87 | this.addEventListener('click', (e) => { 88 | if (e.target.id === 'run-now') { 89 | this.executeCommand() 90 | } 91 | if (e.target.id === 'get-pro') { 92 | this.handleGetPro() 93 | } 94 | }) 95 | } 96 | 97 | dismount() { 98 | // Clean up interval if component is removed 99 | if (this.data.intervalId) { 100 | clearInterval(this.data.intervalId) 101 | } 102 | } 103 | 104 | render() { 105 | if (!this.data.seconds && !this.data.commandName && !this.data.countdownFinished) { 106 | return '
    Loading...
    ' 107 | } 108 | 109 | return ` 110 |
    111 |

    Get Super Tidy Pro to skip the countdown

    112 |

    113 | Super Tidy Pro is a lifetime one-time purchase. No recurring charges or subscriptions. 114 |

    115 | 118 | 119 | ${this.data.seconds > 0 ? this.data.seconds + 's' : 'Ready'} 120 |

    121 | You are on the free plan, you need to wait before running your command. 122 |

    123 | 124 | 125 |
    126 | ` 127 | } 128 | } 129 | 130 | customElements.define('v-countdown', CountdownView) 131 | -------------------------------------------------------------------------------- /src/ui/views/form/FormView.css: -------------------------------------------------------------------------------- 1 | /* PLUGIN UI */ 2 | * { border: 0; padding: 0; margin: 0; } 3 | .hidden { display: none!important; } 4 | form { padding: 16px; } 5 | fieldset { margin-bottom: 0px; } 6 | p.type { margin-bottom: 16px; } 7 | p.type--11-pos-bold { margin-bottom: 8px; } 8 | 9 | .extra-options { padding-left: 40px; margin-bottom: 24px; } 10 | .extra-options input { width: 128px; } 11 | 12 | .button { margin-top: 16px; } 13 | 14 | v-form .empty-selection { 15 | text-align: center; 16 | padding: 0 24px; 17 | text-align: center; 18 | padding: 0 24px; 19 | display: flex; 20 | flex-direction: column; 21 | height: 100%; 22 | justify-content: center; 23 | } 24 | v-form .empty-selection h1 { margin-bottom: 8px; } 25 | 26 | 27 | 28 | .show-preferences { margin: -4px; } 29 | .show-preferences a { display: block; border-radius: 4px; padding: 8px; border: solid 2px transparent; } 30 | .show-preferences a:hover { background: var(--color-decorator-soft); text-decoration: none; } 31 | .show-preferences a:active { border-color: var(--color-interactive-positive); } 32 | .show-preferences .icon { display: block; float: left; margin-right: 12px; } 33 | .show-preferences h1, .show-preferences p { overflow: hidden; } 34 | .show-preferences h1 { font: var(--type-small-bold); color: var(--color-text-primary); } 35 | .show-preferences p { font: var(--type-small-normal); color: var(--color-text-secondary); margin: 0; } 36 | -------------------------------------------------------------------------------- /src/ui/views/form/FormView.js: -------------------------------------------------------------------------------- 1 | import './FormView.css' 2 | 3 | import Element from 'src/ui/Element' 4 | import Tracking from "src/utils/Tracking" 5 | import { shouldShowCountdown, getCountdownSeconds } from 'src/payments/gate' 6 | import '../countdown/CountdownView' 7 | import FigPen from 'src/utils/FigPen' 8 | import CONFIG from 'src/Config' 9 | 10 | class FormView extends Element { 11 | 12 | beforeMount() { 13 | this.FP = new FigPen(CONFIG) 14 | // Initialize pending command state 15 | this.data.pendingCommand = null 16 | this.data.showingCountdown = false 17 | 18 | // License status is managed by Core.js during initialization 19 | // No need to update cache from FormView 20 | } 21 | 22 | handleEmptyState(selection) { 23 | if (selection.length == 0) { 24 | this.find('[data-select=empty]').removeAttribute('hidden') 25 | this.find('[data-select=form]').setAttribute('hidden', '') 26 | } else { 27 | this.find('[data-select=empty]').setAttribute('hidden', '') 28 | this.find('[data-select=form]').removeAttribute('hidden') 29 | } 30 | } 31 | 32 | bind() { 33 | this.FP.onEditorMessage(msg => { 34 | if (msg.type == 'selection') { 35 | let selection = msg.selection 36 | this.handleEmptyState(selection) 37 | } 38 | }) 39 | 40 | // PLUGIN UI CONTROLS 41 | var form = document.getElementById('actions') 42 | var renaming_check = document.getElementById('renaming_check') 43 | var reorder_check = document.getElementById('reorder_check') 44 | var tidy_check = document.getElementById('tidy_check') 45 | var pager_check = document.getElementById('pager_check') 46 | var tidy = document.getElementById('tidy') 47 | 48 | const applySuperTidy = () => { 49 | var renamingEnabled = renaming_check.checked; 50 | var reorderEnabled = reorder_check.checked; 51 | var tidyEnabled = tidy_check.checked; 52 | var pagerEnabled = pager_check.checked; 53 | var options = { 54 | renaming: renamingEnabled, 55 | reorder: reorderEnabled, 56 | tidy: tidyEnabled, 57 | pager: pagerEnabled 58 | } 59 | 60 | Tracking.track('clickApply', options) 61 | this.handleCommandRequest('tidy', options) 62 | } 63 | 64 | 65 | form.onsubmit = (e) => { 66 | applySuperTidy() 67 | e.preventDefault() 68 | } 69 | } 70 | 71 | handleCommandRequest(commandName, options) { 72 | if (shouldShowCountdown()) { 73 | // Store the command for later execution 74 | this.data.pendingCommand = { commandName, options } 75 | 76 | // Start countdown 77 | const seconds = getCountdownSeconds() 78 | 79 | this.startCountdown(seconds, commandName, () => { 80 | this.executePendingCommand() 81 | }) 82 | } else { 83 | // Execute immediately if licensed 84 | this.executeCommand(commandName, options) 85 | } 86 | } 87 | 88 | startCountdown(seconds, commandName, onComplete) { 89 | // Track countdown shown 90 | Tracking.track('countdownShown', { 91 | commandName: commandName, 92 | seconds: seconds, 93 | trigger: this.data.pendingCommand ? 'ui-form' : 'menu-command' 94 | }) 95 | 96 | this.data.showingCountdown = true 97 | this.render() 98 | 99 | // Wait for the view to be rendered, then start countdown 100 | setTimeout(() => { 101 | const countdownView = this.querySelector('v-countdown') 102 | if (countdownView && countdownView.startCountdown) { 103 | countdownView.startCountdown(seconds, commandName, onComplete) 104 | } 105 | }, 100) 106 | } 107 | 108 | executePendingCommand() { 109 | if (this.data.pendingCommand) { 110 | // Track command executed after countdown 111 | Tracking.track('commandExecutedAfterCountdown', { 112 | commandName: this.data.pendingCommand.commandName, 113 | options: this.data.pendingCommand.options, 114 | userType: 'free' 115 | }) 116 | 117 | this.executeCommand(this.data.pendingCommand.commandName, this.data.pendingCommand.options) 118 | this.data.pendingCommand = null 119 | this.data.showingCountdown = false 120 | this.render() 121 | } 122 | } 123 | 124 | executeCommand(commandName, options) { 125 | // Send command to Core.js 126 | this.FP.notifyEditor({ 127 | type: commandName, 128 | options: options 129 | }) 130 | } 131 | 132 | 133 | render() { 134 | if (this.data.showingCountdown) { 135 | return '' 136 | } 137 | 138 | return ` 139 | 145 | 208 | ` 209 | } 210 | } 211 | 212 | customElements.define('v-form', FormView) 213 | -------------------------------------------------------------------------------- /src/ui/views/license/LicenseView.css: -------------------------------------------------------------------------------- 1 | /* LICENSE VIEW - Following project styling patterns */ 2 | 3 | v-license { 4 | display: block; 5 | padding: 16px; 6 | } 7 | 8 | v-license .license-container { 9 | text-align: center; 10 | padding: 24px 0; 11 | } 12 | 13 | v-license h1 { 14 | font: var(--type-large-bold); 15 | color: var(--color-text-primary); 16 | margin-bottom: 8px; 17 | } 18 | 19 | v-license p { 20 | font: var(--type-medium-normal); 21 | color: var(--color-text-secondary); 22 | margin-bottom: 12px; 23 | line-height: 1.4; 24 | } 25 | 26 | v-license .license-form { 27 | max-width: 280px; 28 | margin: 0 auto; 29 | padding-bottom: 0; 30 | } 31 | 32 | v-license .license-input { 33 | margin-bottom: 4px; 34 | } 35 | v-license .license-input input { 36 | border-color: var(--color-separator); 37 | } 38 | 39 | v-license .license-status { 40 | font: var(--type-small-normal); 41 | margin: 8px 0; 42 | min-height: 16px; 43 | } 44 | 45 | v-license .license-status.success { 46 | color: var(--color-text-success, #00aa00); 47 | } 48 | 49 | v-license .license-status.error { 50 | color: var(--color-text-danger, #ff0000); 51 | } 52 | 53 | v-license .button { 54 | width: 100%; 55 | margin-top: 8px; 56 | } 57 | 58 | v-license .button:disabled { 59 | opacity: 0.5; 60 | cursor: not-allowed; 61 | } 62 | 63 | v-license .license-info { 64 | background: var(--color-decorator-soft); 65 | border-radius: 4px; 66 | padding: 16px; 67 | margin: 20px 0 0; 68 | text-align: left; 69 | } 70 | 71 | v-license .license-detail { 72 | font: var(--type-small-normal); 73 | color: var(--color-text-primary); 74 | margin-bottom: 8px; 75 | } 76 | 77 | v-license .license-detail:last-child { 78 | margin-bottom: 0; 79 | } 80 | 81 | v-license .license-detail strong { 82 | font-weight: 600; 83 | color: var(--color-text-primary); 84 | } 85 | 86 | v-license .license-info-hint { 87 | font: var(--type-small-normal); 88 | color: var(--color-text-secondary); 89 | margin: 12px 0 0; 90 | } 91 | 92 | v-license .support-section { 93 | margin-top: 12px; 94 | } 95 | 96 | v-license .support-section p { 97 | font: var(--type-small-normal); 98 | color: var(--color-text-secondary); 99 | margin: 0; 100 | } 101 | 102 | v-license .support-section a { 103 | color: var(--color-interactive-positive); 104 | text-decoration: none; 105 | font-weight: 500; 106 | } 107 | 108 | v-license .support-section a:hover { 109 | text-decoration: underline; 110 | } 111 | 112 | v-license .license-info + .button { 113 | margin-top: 20px; 114 | width: 100%; 115 | } 116 | -------------------------------------------------------------------------------- /src/ui/views/license/LicenseView.js: -------------------------------------------------------------------------------- 1 | import './LicenseView.css' 2 | import Element from 'src/ui/Element' 3 | import Tracking from 'src/utils/Tracking' 4 | import { setCachedLicenseStatus } from 'src/payments/gate' 5 | import { 6 | verifyGumroadLicense, 7 | decrementLicenseUsage, 8 | activateLicense, 9 | removeLicense, 10 | createLicenseInfo 11 | } from 'src/payments/license' 12 | 13 | class LicenseView extends Element { 14 | 15 | beforeMount() { 16 | this.data.isValidating = false 17 | this.data.statusMessage = '' 18 | this.data.statusType = '' // 'success', 'error', or '' 19 | this.data.isLicensed = false 20 | this.data.licenseInfo = null 21 | this.loadLicenseFromProps() 22 | } 23 | 24 | loadLicenseFromProps() { 25 | const licenseAttr = this.getAttribute('license') 26 | if (licenseAttr && licenseAttr !== '{}') { 27 | try { 28 | const license = JSON.parse(licenseAttr.replace(/"/g, '"').replace(/'/g, "'")) 29 | if (license && license.licensed) { 30 | this.data.isLicensed = true 31 | this.data.licenseInfo = license 32 | // If no uses info is stored, default to 1 (this device) 33 | if (!this.data.licenseInfo.uses) { 34 | this.data.licenseInfo.uses = 1 35 | } 36 | this.render() 37 | } 38 | } catch (e) { 39 | console.warn('[LicenseView] Failed to parse license data:', e) 40 | } 41 | } 42 | } 43 | 44 | activateLicense() { 45 | const licenseInput = this.find('#license-key') 46 | const licenseKey = licenseInput.value.trim() 47 | 48 | if (!licenseKey) { 49 | this.showStatus('Please enter a license key', 'error') 50 | return 51 | } 52 | 53 | // Track license validation started 54 | Tracking.track('licenseValidationStarted', { 55 | keyLength: licenseKey.length, 56 | source: 'license-tab' 57 | }) 58 | 59 | this.data.isValidating = true 60 | this.data.statusMessage = '' // Clear any previous error messages 61 | this.data.statusType = '' 62 | this.render() 63 | 64 | verifyGumroadLicense(licenseKey) 65 | .then(result => { 66 | if (result.ok) { 67 | // Send license data to Core.js for storage 68 | activateLicense(licenseKey, result.purchase, result.uses) 69 | 70 | // IMMEDIATELY update the gate cache to ensure instant effect 71 | const licenseData = createLicenseInfo(licenseKey, result.purchase, result.uses) 72 | setCachedLicenseStatus(licenseData) 73 | console.log('[LicenseView] Gate cache updated immediately:', licenseData) 74 | 75 | // Update state to show licensed view 76 | this.data.isLicensed = true 77 | this.data.licenseInfo = licenseData 78 | 79 | Tracking.track('licenseActivated', { email: result.purchase.email }) 80 | } else { 81 | this.showStatus(result.error, 'error') 82 | Tracking.track('licenseActivationFailed', { error: result.error }) 83 | } 84 | 85 | this.data.isValidating = false 86 | this.render() 87 | }) 88 | .catch(error => { 89 | this.showStatus('Validation failed. Please try again.', 'error') 90 | this.data.isValidating = false 91 | this.render() 92 | }) 93 | } 94 | 95 | showStatus(message, type) { 96 | this.data.statusMessage = message 97 | this.data.statusType = type 98 | this.render() 99 | } 100 | 101 | openSupportForm() { 102 | window.open('https://forms.gle/xZTBvrq8aW26r1m8A', '_blank') 103 | Tracking.track('supportFormOpened') 104 | } 105 | 106 | unlinkLicense() { 107 | const licenseKey = this.data.licenseInfo ? this.data.licenseInfo.licenseKey : null 108 | 109 | if (licenseKey) { 110 | // First decrement the usage count on Gumroad 111 | decrementLicenseUsage(licenseKey) 112 | .then(result => { 113 | if (result.ok) { 114 | // Usage count decremented successfully 115 | } else { 116 | console.warn('[LicenseView] Failed to decrement usage count, but proceeding with unlink') 117 | } 118 | }) 119 | } 120 | 121 | // Send message to Core.js to remove license 122 | removeLicense() 123 | 124 | // IMMEDIATELY clear the gate cache to ensure instant effect 125 | setCachedLicenseStatus(null) 126 | console.log('[LicenseView] Gate cache cleared immediately') 127 | 128 | // Update local state 129 | this.data.isLicensed = false 130 | this.data.licenseInfo = null 131 | this.data.statusMessage = '' 132 | this.data.statusType = '' 133 | 134 | Tracking.track('licenseUnlinked') 135 | this.render() 136 | } 137 | 138 | bind() { 139 | this.addEventListener('submit', (e) => { 140 | if (e.target.id === 'license-form') { 141 | e.preventDefault() 142 | this.activateLicense() 143 | } 144 | }) 145 | 146 | this.addEventListener('click', (e) => { 147 | if (e.target.id === 'support-link') { 148 | e.preventDefault() 149 | this.openSupportForm() 150 | } 151 | if (e.target.id === 'unlink-license') { 152 | this.unlinkLicense() 153 | } 154 | }) 155 | } 156 | 157 | renderLicensedView() { 158 | const purchase = this.data.licenseInfo.purchase || {} 159 | const email = purchase.email || 'Unknown' 160 | const licenseKey = this.data.licenseInfo.licenseKey || 'Unknown' 161 | const activatedDate = this.data.licenseInfo.activatedAt ? 162 | new Date(this.data.licenseInfo.activatedAt).toLocaleDateString() : 'Unknown' 163 | 164 | return ` 165 |
    166 |

    Super Tidy Pro

    167 |

    168 | You can now run all commands instantly without any countdown delays. 169 |

    170 | 171 |
    172 |
    173 | Licensed to
    174 | ${email} 175 |
    176 |
    177 | License Key
    178 | ${licenseKey} 179 |
    180 |
    181 | Activated
    182 | ${activatedDate} 183 |
    184 | 190 |
    191 | 192 | 198 |

    199 | Unlinking will return you to the free plan and reset this device usage. 200 |

    201 | 202 | ${this.renderSupportSection()} 203 |
    204 | ` 205 | } 206 | 207 | renderUnlicensedView() { 208 | return ` 209 |
    210 |

    Activate Super Tidy Pro

    211 |

    212 | Enter your license key to unlock instant runs and skip all countdowns. 213 |

    214 | 215 |
    216 |
    217 | 224 |
    225 | 226 | 233 | 234 | ${this.data.statusType === 'error' ? ` 235 |
    236 | ${this.data.statusMessage} 237 |
    238 | ` : ''} 239 |
    240 | 241 | ${this.renderSupportSection()} 242 |
    243 | ` 244 | } 245 | 246 | renderSupportSection() { 247 | return ` 248 |
    249 |

    250 | Need help or have feedback? 251 | Contact support 252 |

    253 |
    254 | ` 255 | } 256 | 257 | render() { 258 | if (this.data.isLicensed && this.data.licenseInfo) { 259 | return this.renderLicensedView() 260 | } 261 | 262 | return this.renderUnlicensedView() 263 | } 264 | } 265 | 266 | customElements.define('v-license', LicenseView) -------------------------------------------------------------------------------- /src/ui/views/preferences/PreferencesView.css: -------------------------------------------------------------------------------- 1 | v-preferences {} 2 | v-preferences form strong { font: var(--type-small-bold); color: var(--color-text-primary); display: block; margin-bottom: 4px; } 3 | v-preferences form p { font: var(--type-small-normal); color: var(--color-text-secondary); display: block; margin-bottom: 4px; } 4 | v-preferences label, v-preferences .fake-label { margin-bottom: 16px; display: block; } 5 | v-preferences label .input-icon { width: 88px; } 6 | 7 | v-preferences fieldset { overflow: hidden; padding: 2px; } 8 | v-preferences fieldset label { float: left } 9 | v-preferences .button.button--primary { margin-top: 0; } 10 | 11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /src/ui/views/preferences/PreferencesView.js: -------------------------------------------------------------------------------- 1 | import './PreferencesView.css' 2 | 3 | import Element from 'src/ui/Element' 4 | import Tracking from "src/utils/Tracking" 5 | import Router from 'src/utils/Router' 6 | import FigPen from 'src/utils/FigPen' 7 | import CONFIG from 'src/Config' 8 | import 'src/ui/components/select/SelectComponent' 9 | 10 | class PreferencesView extends Element { 11 | 12 | beforeMount() { 13 | this.FP = new FigPen(CONFIG) 14 | } 15 | 16 | savePreferences() { 17 | let x_spacing = this.find('#x_spacing').value 18 | let y_spacing = this.find('#y_spacing').value 19 | let starting_name = this.find('#starting_name').value 20 | let pager_variable = this.find('#pager_variable').value 21 | let wrap_instances = this.find('#wrap_instances').checked 22 | let rename_trategy = this.find('#rename_strategy [selected]').getAttribute('data-value') 23 | let layout_paradigm = this.find('#layout_paradigm [selected]').getAttribute('data-value') 24 | 25 | let preferences = { 26 | spacing: { x: parseInt(x_spacing), y: parseInt(y_spacing) }, 27 | start_name: starting_name, 28 | wrap_instances: wrap_instances, 29 | rename_strategy: rename_trategy, 30 | pager_variable: pager_variable, 31 | layout_paradigm: layout_paradigm 32 | } 33 | 34 | Tracking.track('autoSavePreferences', preferences) 35 | this.FP.notifyEditor({ type: 'preferences', preferences: preferences }) 36 | } 37 | 38 | bind(e) { 39 | // Auto-save on input changes 40 | this.find('#x_spacing').addEventListener('input', () => this.savePreferences()) 41 | this.find('#y_spacing').addEventListener('input', () => this.savePreferences()) 42 | this.find('#starting_name').addEventListener('input', () => this.savePreferences()) 43 | this.find('#pager_variable').addEventListener('input', () => this.savePreferences()) 44 | this.find('#wrap_instances').addEventListener('change', () => this.savePreferences()) 45 | this.find('#layout_paradigm').addEventListener('change', () => this.savePreferences()) 46 | this.find('#rename_strategy').addEventListener('change', () => this.savePreferences()) 47 | } 48 | 49 | render() { 50 | return ` 51 |
    52 |
    53 | Grid spacing 54 |

    Spacing between frames applied when running the Tidy action.

    55 | 56 | 64 | 65 | 73 |
    74 | 75 |
    76 | Layout 77 |

    Rows (horizontal flow, good for mobile designs) or Columns (vertical flow, ideal for presentations and wide formats).

    78 | 79 | 82 | 85 | 86 |
    87 | 88 | 99 | 100 | 101 | 112 | 113 | 124 | 125 |
    126 | Rename strategy 127 |

    Merges or replaces your frame names with numbers based on their position on the canvas. Applied with the Rename action.

    128 | 129 | 132 | 135 | 136 |
    137 |
    138 | 139 | ` 140 | } 141 | } 142 | 143 | customElements.define('v-preferences', PreferencesView) 144 | -------------------------------------------------------------------------------- /src/utils/DisplayNetwork.js: -------------------------------------------------------------------------------- 1 | import FigPen from 'src/utils/FigPen' 2 | import CONFIG from 'src/Config' 3 | let singleton = null 4 | const DAILY_INTERVAL = 86400000 5 | const WEEKLY_INTERVAL = DAILY_INTERVAL*7 6 | const MONTHLY_INTERVAL = WEEKLY_INTERVAL*4 7 | 8 | class DisplayNetwork { 9 | 10 | constructor() { 11 | this.root = 'https://figma-plugins-display-network.netlify.app/api.json' 12 | this.timeStampNow = Date.now() 13 | this.FP = new FigPen(CONFIG) 14 | 15 | if (!singleton) singleton = this 16 | return singleton 17 | } 18 | 19 | getAds() { 20 | return fetch(this.root, { method: 'GET' }) 21 | } 22 | 23 | getAvailableAd(lastShownDate, lastShownImpression) { 24 | return new Promise((resolve, reject) => { 25 | this.getAds().then(response => { 26 | response.json().then(ads => { 27 | 28 | let availableAds = ads.reduce((prev, current) => { 29 | if (this.checkImpressionAvailability(parseInt(lastShownDate), parseInt(lastShownImpression), current)) { 30 | prev.push(current) 31 | } 32 | return prev 33 | }, []) 34 | 35 | resolve(availableAds[this.getRandomInt(0, availableAds.length)]) 36 | }) 37 | }).catch(error => console.log('Error getAvailableAd', error)) 38 | }) 39 | } 40 | 41 | getRandomInt(min, max) { 42 | min = Math.ceil(min); 43 | max = Math.floor(max); 44 | return Math.floor(Math.random() * (max - min) + min); //The maximum is exclusive and the minimum is inclusive 45 | } 46 | 47 | getAdInterval(ad) { 48 | if (WP_ENV == 'development') return 10000 49 | 50 | return parseInt(ad.impression_net_interval) 51 | } 52 | 53 | checkImpressionAvailability(lastShownDate, lastShownImpression, ad) { 54 | 55 | let adInterval = this.getAdInterval(ad) 56 | let availableTimeWindow = (!lastShownDate || lastShownDate + adInterval < this.timeStampNow) 57 | 58 | // Check if the ad is optimised for impression rather than time 59 | if (ad.impression_count) { 60 | let availableImpression = (lastShownImpression < parseInt(ad.impression_count)) 61 | 62 | if (availableTimeWindow && availableImpression || !availableTimeWindow && availableImpression) { 63 | // meanwhile there is impressions left, spend the impression 64 | return true 65 | } else 66 | if (availableTimeWindow && !availableImpression) { 67 | // when impressions limit is reached and time window is valid, reset the impression 68 | this.FP.notifyEditor({ type: 'resetImpression' }) 69 | return false 70 | } else { 71 | return false 72 | } 73 | } else { 74 | return availableTimeWindow 75 | } 76 | 77 | } 78 | } 79 | 80 | export default new DisplayNetwork() 81 | -------------------------------------------------------------------------------- /src/utils/FigPen.js: -------------------------------------------------------------------------------- 1 | const FIGMA = 'figma' 2 | const PENPOT = 'penpot' 3 | 4 | export default class FigPen { 5 | 6 | constructor({designTool, name, url, width, height}) { 7 | this.designTool = designTool 8 | this.name = name 9 | this.url = url 10 | this.width = width 11 | this.height = height 12 | } 13 | 14 | currentCommand() { 15 | if (this.designTool === FIGMA) { 16 | return figma.command 17 | } else if (this.designTool === PENPOT) { 18 | return null 19 | } 20 | } 21 | 22 | openUI() { 23 | if (this.designTool === FIGMA) { 24 | figma.showUI(__html__, { themeColors: true, width: this.width, height: this.height }) 25 | } else if (this.designTool === PENPOT) { 26 | penpot.ui.open(this.name, this.url, { width: this.width, height: this.height }) 27 | } 28 | } 29 | 30 | openUIHidden() { 31 | if (this.designTool === FIGMA) { 32 | figma.showUI(__html__, { themeColors: true, visible: false }) 33 | } else if (this.designTool === PENPOT) { 34 | penpot.ui.open(this.name, this.url, { width: this.width, height: this.height }) 35 | } 36 | } 37 | 38 | onEditorMessage(callback) { 39 | window.addEventListener('message', event => { 40 | let msg = (this.designTool === FIGMA) ? event.data.pluginMessage : event.data 41 | callback(msg) 42 | }) 43 | } 44 | 45 | onUIMessage(callback) { 46 | if (this.designTool === FIGMA) { 47 | figma.ui.onmessage = callback 48 | } else if (this.designTool === PENPOT) { 49 | penpot.ui.onMessage((message) => { callback(message) }); 50 | } 51 | } 52 | 53 | clearUIListeners() { 54 | if (this.designTool === FIGMA) { 55 | figma.ui.onmessage = null 56 | } else if (this.designTool === PENPOT) { 57 | penpot.ui.onMessage = null 58 | penpot.ui.onMessage(() => { }); 59 | } 60 | } 61 | 62 | onSelectionChange(callback) { 63 | if (this.designTool === FIGMA) { 64 | figma.on('selectionchange', () => { callback(this.currentSelection()) }) 65 | } else if (this.designTool === PENPOT) { 66 | penpot.on('selectionchange', () => { callback(this.currentSelection()) }) 67 | } 68 | } 69 | 70 | notifyUI(message) { 71 | if (this.designTool === FIGMA) { 72 | figma.ui.postMessage(message) 73 | } else if (this.designTool === PENPOT) { 74 | penpot.ui.sendMessage(message) 75 | } 76 | } 77 | 78 | notifyEditor(message) { 79 | if (this.designTool === FIGMA) { 80 | parent.postMessage({ pluginMessage: message }, '*') 81 | } else if (this.designTool === PENPOT) { 82 | parent.postMessage(message, '*') 83 | } 84 | } 85 | 86 | waitForUIReady() { 87 | this.openUIHidden() 88 | 89 | return new Promise((resolve, reject) => { 90 | this.onUIMessage(msg => { 91 | if (msg.type === 'ui-ready') { 92 | resolve() 93 | } 94 | }) 95 | }) 96 | } 97 | 98 | initializeUI() { 99 | this.notifyEditor({ type: 'ui-ready' }) 100 | } 101 | 102 | resizeUI(width, height) { 103 | if (this.designTool === FIGMA) { 104 | figma.ui.resize(width, height) 105 | } else if (this.designTool === PENPOT) { 106 | penpot.ui.resize(width, height) 107 | } 108 | } 109 | 110 | closePlugin() { 111 | if (this.designTool === FIGMA) { 112 | figma.closePlugin() 113 | } else if (this.designTool === PENPOT) { 114 | penpot.closePlugin() 115 | } 116 | } 117 | 118 | getStorageItem(key) { 119 | return new Promise((resolve, reject) => { 120 | if (this.designTool === FIGMA) { 121 | figma.clientStorage.getAsync(key).then(resolve).catch(reject) 122 | } else if (this.designTool === PENPOT) { 123 | let item = JSON.parse(penpot.localStorage.getItem(key)) 124 | resolve(item) 125 | } 126 | }) 127 | } 128 | 129 | setStorageItem(key, value) { 130 | return new Promise((resolve, reject) => { 131 | if (this.designTool === FIGMA) { 132 | figma.clientStorage.setAsync(key, value).then(resolve).catch(reject) 133 | } else if (this.designTool === PENPOT) { 134 | let item = JSON.stringify(value) 135 | resolve(penpot.localStorage.setItem(key, item)) 136 | } 137 | }) 138 | } 139 | 140 | currentSelection() { 141 | if (this.designTool === FIGMA) { 142 | return figma.currentPage.selection 143 | } else if (this.designTool === PENPOT) { 144 | return penpot.selection 145 | } 146 | } 147 | 148 | setSelection(nodes) { 149 | if (this.designTool === FIGMA) { 150 | return figma.currentPage.selection = nodes 151 | } else if (this.designTool === PENPOT) { 152 | return penpot.selection = nodes 153 | } 154 | } 155 | 156 | currentPage() { 157 | if (this.designTool === FIGMA) { 158 | return figma.currentPage 159 | } else if (this.designTool === PENPOT) { 160 | return penpot.currentPage 161 | } 162 | } 163 | 164 | currentTheme() { 165 | if (this.designTool === FIGMA) { 166 | console.warn('currentTheme not supported in Figma. More info: https://developers.figma.com/docs/plugins/css-variables/') 167 | } else if (this.designTool === PENPOT) { 168 | return penpot.theme 169 | } 170 | } 171 | 172 | showNotification(text) { 173 | if (this.designTool === FIGMA) { 174 | figma.notify(text) 175 | } else if (this.designTool === PENPOT) { 176 | console.warn("showNotification not supported for PenPot:", text) 177 | } 178 | } 179 | 180 | setNodeCharacters(node, characters) { 181 | return new Promise((resolve, reject) => { 182 | if (this.designTool === FIGMA) { 183 | figma.loadFontAsync(node.fontName).then(() => { 184 | node.characters = characters.toString() 185 | resolve(node.characters) 186 | }) 187 | } else if (this.designTool === PENPOT) { 188 | node.characters = characters.toString() 189 | resolve(node.characters) 190 | } 191 | }) 192 | } 193 | 194 | createFrame() { 195 | if (this.designTool === FIGMA) { 196 | return figma.createFrame() 197 | } else if (this.designTool === PENPOT) { 198 | return penpot.createBoard() 199 | } 200 | } 201 | 202 | getNodeType(node) { 203 | if (this.designTool === FIGMA) { 204 | return node.type 205 | } else if (this.designTool === PENPOT) { 206 | return node.type.toUpperCase() 207 | } 208 | } 209 | 210 | startUndoBlock() { 211 | if (this.designTool === FIGMA) { 212 | console.warn('startUndoBlock not supported in Figma. https://developers.figma.com/docs/plugins/api/properties/figma-commitundo/') 213 | } else if (this.designTool === PENPOT) { 214 | console.log('startUndoBlock', penpot.history) 215 | return penpot.history.undoBlockBegin() 216 | } 217 | } 218 | 219 | finishUndoBlock(historyId) { 220 | if (this.designTool === FIGMA) { 221 | console.warn('finishUndoBlock not supported in Figma. https://developers.figma.com/docs/plugins/api/properties/figma-commitundo/') 222 | } else if (this.designTool === PENPOT) { 223 | console.log('finishUndoBlock', penpot.history) 224 | return penpot.history.undoBlockFinish(historyId) 225 | } 226 | } 227 | 228 | } -------------------------------------------------------------------------------- /src/utils/LayoutUtils.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Layout utilities for Super Tidy plugin 3 | * Pure functions for positioning, grouping, and organizing nodes 4 | */ 5 | 6 | import FigPen from "src/utils/FigPen" 7 | import CONFIG from "src/Config" 8 | 9 | const FP = new FigPen(CONFIG) 10 | 11 | /** 12 | * Groups nodes by their position on the canvas 13 | * @param {Array} nodes - Array of figma nodes 14 | * @param {string} layout - 'rows' or 'columns' layout paradigm 15 | * @returns {Array} Array of grouped nodes 16 | */ 17 | export function getNodesGroupedbyPosition(nodes, layout = 'rows') { 18 | // Prepare nodes 19 | var input_ids = nodes.reduce((acc, item) => { 20 | acc.push({ id: item.id, x: item.x, y: item.y, width: item.width, height: item.height, name: item.name }) 21 | return acc 22 | }, []) 23 | 24 | if (layout === 'columns') { 25 | return getNodesGroupedByColumns(input_ids) 26 | } else { 27 | return getNodesGroupedByRows(input_ids) 28 | } 29 | } 30 | 31 | /** 32 | * Groups nodes by rows (original behavior) 33 | * @param {Array} input_ids - Array of node data 34 | * @returns {Array} Array of rows with columns 35 | */ 36 | function getNodesGroupedByRows(input_ids) { 37 | // Sort by X 38 | input_ids.sort((current, next) => { 39 | return current.x - next.x 40 | }) 41 | 42 | // Create rows and columns 43 | var rows = [] 44 | input_ids.map(item => { 45 | var rowExist = rows.find(row => row.y + item.height/2 > item.y && row.y - item.height/2 < item.y) 46 | if (rowExist) { 47 | rowExist.columns.push(item) 48 | } else { 49 | rows.push({ y: item.y, columns: [item] }) 50 | } 51 | }) 52 | 53 | // Sort by Y 54 | return rows.sort((current, next) => current.y - next.y); 55 | } 56 | 57 | /** 58 | * Groups nodes by columns (new feature) 59 | * @param {Array} input_ids - Array of node data 60 | * @returns {Array} Array of columns with rows 61 | */ 62 | function getNodesGroupedByColumns(input_ids) { 63 | // Sort by Y first for column layout 64 | input_ids.sort((current, next) => { 65 | return current.y - next.y 66 | }) 67 | 68 | // Create columns and rows 69 | var columns = [] 70 | input_ids.map(item => { 71 | var columnExist = columns.find(column => column.x + item.width/2 > item.x && column.x - item.width/2 < item.x) 72 | if (columnExist) { 73 | columnExist.rows.push(item) 74 | } else { 75 | columns.push({ x: item.x, rows: [item] }) 76 | } 77 | }) 78 | 79 | // Sort by X 80 | return columns.sort((current, next) => current.x - next.x); 81 | } 82 | 83 | /** 84 | * Generates a name based on position 85 | * @param {number} primary - Primary index (row or column) 86 | * @param {number} secondary - Secondary index (column or row) 87 | * @param {string} startName - Starting name/number 88 | * @returns {string} Generated name 89 | */ 90 | export function getNameByPosition(primary, secondary, startName) { 91 | var padLength = startName.length 92 | var parseStartName = parseInt(startName) 93 | var primaryName = parseStartName + primary * Math.pow(10, padLength - 1) 94 | var secondaryName = primaryName + secondary 95 | var name = '' 96 | 97 | function zeroPad(num, places) { 98 | var zero = places - num.toString().length + 1; 99 | return Array(+(zero > 0 && zero)).join("0") + num; 100 | } 101 | 102 | if (secondary == 0) { 103 | name = (primary == 0) ? zeroPad(primaryName, padLength) : primaryName.toString(); 104 | } else { 105 | name = (primary == 0) ? zeroPad(secondaryName, padLength) : secondaryName.toString(); 106 | } 107 | 108 | return name 109 | } 110 | 111 | /** 112 | * Repositions nodes in a tidy grid layout 113 | * @param {Array} groupedNodes - Grouped nodes from getNodesGroupedbyPosition 114 | * @param {number} xSpacing - Horizontal spacing 115 | * @param {number} ySpacing - Vertical spacing 116 | * @param {boolean} wrapInstances - Whether to wrap instances with frames 117 | * @param {string} layout - 'rows' or 'columns' layout paradigm 118 | * @param {Array} allNodes - All nodes in the parent 119 | * @returns {void} 120 | */ 121 | export function repositionNodes(groupedNodes, xSpacing, ySpacing, wrapInstances, layout, allNodes) { 122 | if (layout === 'columns') { 123 | repositionNodesInColumns(groupedNodes, xSpacing, ySpacing, wrapInstances, allNodes) 124 | } else { 125 | repositionNodesInRows(groupedNodes, xSpacing, ySpacing, wrapInstances, allNodes) 126 | } 127 | } 128 | 129 | /** 130 | * Repositions nodes in rows layout (original behavior) 131 | */ 132 | function repositionNodesInRows(groupedNodes, xSpacing, ySpacing, wrapInstances, allNodes) { 133 | var x0 = 0 134 | var y0 = 0 135 | var xPos = 0 136 | var yPos = 0 137 | var tallestInRow = [] 138 | 139 | // Store tallest node per row 140 | groupedNodes.forEach((row, rowidx) => { 141 | let sortedRowColumns = row.columns.slice() 142 | sortedRowColumns.sort((prev, next) => { 143 | return (prev.height > next.height) ? -1 : 1; 144 | }) 145 | tallestInRow.push(sortedRowColumns[0].height) 146 | }) 147 | 148 | // Reposition nodes 149 | groupedNodes.forEach((row, rowidx) => { 150 | row.columns.forEach((col, colidx) => { 151 | if (rowidx == 0 && colidx == 0) { 152 | x0 = col.x 153 | y0 = col.y 154 | xPos = col.x 155 | yPos = col.y 156 | } 157 | var match = allNodes.find(node => node.id === col.id) 158 | var newXPos = (colidx == 0) ? xPos : xPos + xSpacing; 159 | var newYPos = yPos 160 | 161 | // Wrap instances with a frame around 162 | if (wrapInstances && match.type == 'INSTANCE') { 163 | var instanceParent = figma.createFrame() 164 | instanceParent.x = newXPos 165 | instanceParent.y = newYPos 166 | instanceParent.resize(match.width, match.height) 167 | instanceParent.appendChild(match) 168 | match.x = 0 169 | match.y = 0 170 | figma.currentPage.selection = figma.currentPage.selection.concat(instanceParent) 171 | } else { 172 | match.x = newXPos 173 | match.y = newYPos 174 | } 175 | 176 | xPos = newXPos + match.width 177 | }) 178 | 179 | xPos = x0 180 | yPos = yPos + (tallestInRow[rowidx] + ySpacing) 181 | }) 182 | } 183 | 184 | /** 185 | * Repositions nodes in columns layout (new feature) 186 | */ 187 | function repositionNodesInColumns(groupedNodes, xSpacing, ySpacing, wrapInstances, allNodes) { 188 | var x0 = 0 189 | var y0 = 0 190 | var xPos = 0 191 | var yPos = 0 192 | var widestInColumn = [] 193 | 194 | // Store widest node per column 195 | groupedNodes.forEach((column, colidx) => { 196 | let sortedColumnRows = column.rows.slice() 197 | sortedColumnRows.sort((prev, next) => { 198 | return (prev.width > next.width) ? -1 : 1; 199 | }) 200 | widestInColumn.push(sortedColumnRows[0].width) 201 | }) 202 | 203 | // Reposition nodes 204 | groupedNodes.forEach((column, colidx) => { 205 | column.rows.forEach((row, rowidx) => { 206 | if (colidx == 0 && rowidx == 0) { 207 | x0 = row.x 208 | y0 = row.y 209 | xPos = row.x 210 | yPos = row.y 211 | } 212 | var match = allNodes.find(node => node.id === row.id) 213 | var newXPos = xPos 214 | var newYPos = (rowidx == 0) ? yPos : yPos + ySpacing; 215 | 216 | // Wrap instances with a frame around 217 | if (wrapInstances && match.type == 'INSTANCE') { 218 | var instanceParent = figma.createFrame() 219 | instanceParent.x = newXPos 220 | instanceParent.y = newYPos 221 | instanceParent.resize(match.width, match.height) 222 | instanceParent.appendChild(match) 223 | match.x = 0 224 | match.y = 0 225 | figma.currentPage.selection = figma.currentPage.selection.concat(instanceParent) 226 | } else { 227 | match.x = newXPos 228 | match.y = newYPos 229 | } 230 | 231 | yPos = newYPos + match.height 232 | }) 233 | 234 | yPos = y0 235 | xPos = xPos + (widestInColumn[colidx] + xSpacing) 236 | }) 237 | } 238 | 239 | /** 240 | * Reorders nodes based on their grouped position 241 | * @param {Array} groupedNodes - Grouped nodes from getNodesGroupedbyPosition 242 | * @param {string} layout - 'rows' or 'columns' layout paradigm 243 | * @param {Object} parent - Parent node 244 | * @param {Array} allNodes - All nodes in the parent 245 | * @returns {void} 246 | */ 247 | export function reorderNodes(groupedNodes, layout, parent, allNodes) { 248 | if (layout === 'columns') { 249 | // For columns: reverse columns, then reverse rows within each column 250 | groupedNodes.reverse().forEach(column => { 251 | column.rows.reverse().forEach(row => { 252 | var match = allNodes.find(node => node.id === row.id) 253 | parent.appendChild(match) 254 | }) 255 | }) 256 | } else { 257 | // For rows: reverse rows, then reverse columns within each row (original behavior) 258 | groupedNodes.reverse().forEach(row => { 259 | row.columns.reverse().forEach(col => { 260 | var match = allNodes.find(node => node.id === col.id) 261 | parent.appendChild(match) 262 | }) 263 | }) 264 | } 265 | } 266 | 267 | /** 268 | * Applies pager numbering to text nodes 269 | * @param {Array} groupedNodes - Grouped nodes from getNodesGroupedbyPosition 270 | * @param {string} layout - 'rows' or 'columns' layout paradigm 271 | * @param {string} pagerVariable - Variable name to replace with numbers 272 | * @param {Array} allNodes - All nodes in the parent 273 | * @returns {void} 274 | */ 275 | export function applyPagerNumbers(groupedNodes, layout, pagerVariable, allNodes) { 276 | var frameIndex = 0 277 | 278 | function searchPagerNodes(node, idx) { 279 | let isTextNode = (FP.getNodeType(node) == 'TEXT') 280 | if (typeof node.children != 'undefined') { 281 | node.children.forEach(child => { 282 | searchPagerNodes(child, idx) 283 | }) 284 | } else if (isTextNode && node.name == pagerVariable) { 285 | FP.setNodeCharacters(node, idx) 286 | } 287 | } 288 | 289 | if (layout === 'columns') { 290 | groupedNodes.forEach(column => { 291 | column.rows.forEach(row => { 292 | var frame = allNodes.find(node => node.id === row.id) 293 | searchPagerNodes(frame, frameIndex) 294 | ++frameIndex 295 | }) 296 | }) 297 | } else { 298 | groupedNodes.forEach(row => { 299 | row.columns.forEach(col => { 300 | var frame = allNodes.find(node => node.id === col.id) 301 | searchPagerNodes(frame, frameIndex) 302 | ++frameIndex 303 | }) 304 | }) 305 | } 306 | } 307 | 308 | /** 309 | * Applies rename strategy to nodes 310 | * @param {Array} groupedNodes - Grouped nodes from getNodesGroupedbyPosition 311 | * @param {string} layout - 'rows' or 'columns' layout paradigm 312 | * @param {string} renameStrategy - 'merge' or 'replace' 313 | * @param {string} startName - Starting name/number 314 | * @param {Array} allNodes - All nodes in the parent 315 | * @returns {void} 316 | */ 317 | export function applyRenameStrategy(groupedNodes, layout, renameStrategy, startName, allNodes) { 318 | if (layout === 'columns') { 319 | groupedNodes.forEach((column, colidx) => { 320 | column.rows.forEach((row, rowidx) => { 321 | var name = getNameByPosition(colidx, rowidx, startName) 322 | var match = allNodes.find(node => node.id === row.id) 323 | 324 | if (renameStrategy == 'merge') { 325 | match.name = `${name}_${match.name}` 326 | } else if (renameStrategy == 'replace') { 327 | match.name = name 328 | } 329 | }) 330 | }) 331 | } else { 332 | groupedNodes.forEach((row, rowidx) => { 333 | row.columns.forEach((col, colidx) => { 334 | var name = getNameByPosition(rowidx, colidx, startName) 335 | var match = allNodes.find(node => node.id === col.id) 336 | 337 | if (renameStrategy == 'merge') { 338 | match.name = `${name}_${match.name}` 339 | } else if (renameStrategy == 'replace') { 340 | match.name = name 341 | } 342 | }) 343 | }) 344 | } 345 | } 346 | -------------------------------------------------------------------------------- /src/utils/MessageBus.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MessageBus utility for managing multiple figma.ui.onmessage listeners 3 | * Prevents listener overwriting by maintaining a registry of callbacks 4 | */ 5 | 6 | import FigPen from 'src/utils/FigPen' 7 | import CONFIG from 'src/Config' 8 | 9 | let singleton = null 10 | 11 | class MessageBus { 12 | constructor() { 13 | this.listeners = new Map() 14 | this.isAttached = false 15 | this.FP = new FigPen(CONFIG) 16 | 17 | if (!singleton) singleton = this 18 | return singleton 19 | } 20 | 21 | /** 22 | * Binds a callback to a specific message name 23 | * If a listener already exists for this message name, it will be replaced 24 | * @param {string} messageName - The message name to listen for (e.g., 'activate-license') 25 | * @param {Function} callback - The callback function to execute 26 | */ 27 | bind(messageName, callback) { 28 | if (!messageName || typeof callback !== 'function') { 29 | throw new Error('MessageBus.bind() requires messageName and callback function') 30 | } 31 | 32 | // Set the listener (this will overwrite any existing listener for this message name) 33 | this.listeners.set(messageName, callback) 34 | 35 | // Attach the main message handler if not already attached 36 | this.attachMainHandler() 37 | 38 | console.log(`[MessageBus] Bound listener for '${messageName}'`) 39 | } 40 | 41 | /** 42 | * Unbinds the listener for a specific message name 43 | * @param {string} messageName - The message name to unbind 44 | * @returns {boolean} True if listener was found and removed 45 | */ 46 | unbind(messageName) { 47 | if (!this.listeners.has(messageName)) { 48 | console.warn(`[MessageBus] No listener found for: ${messageName}`) 49 | return false 50 | } 51 | 52 | this.listeners.delete(messageName) 53 | console.log(`[MessageBus] Unbound listener for '${messageName}'`) 54 | 55 | // Detach main handler if no listeners remain 56 | if (this.listeners.size === 0) { 57 | this.detachMainHandler() 58 | } 59 | 60 | return true 61 | } 62 | 63 | /** 64 | * Unbinds all listeners and detaches the main handler 65 | * @returns {number} Total number of listeners removed 66 | */ 67 | unbindAll() { 68 | const totalCount = this.listeners.size 69 | 70 | this.listeners.clear() 71 | this.detachMainHandler() 72 | 73 | console.log(`[MessageBus] Unbound all ${totalCount} listeners`) 74 | return totalCount 75 | } 76 | 77 | /** 78 | * Gets information about current listeners (for debugging) 79 | * @returns {Array} Array of message names with listeners 80 | */ 81 | getListenerInfo() { 82 | return Array.from(this.listeners.keys()) 83 | } 84 | 85 | /** 86 | * Attaches the main figma.ui.onmessage handler 87 | * @private 88 | */ 89 | attachMainHandler() { 90 | if (this.isAttached) return 91 | 92 | this.FP.onUIMessage(msg => { 93 | this.handleMessage(msg) 94 | }) 95 | 96 | this.isAttached = true 97 | console.log('[MessageBus] Main message handler attached') 98 | } 99 | 100 | /** 101 | * Detaches the main figma.ui.onmessage handler 102 | * @private 103 | */ 104 | detachMainHandler() { 105 | if (!this.isAttached) return 106 | 107 | this.FP.clearUIListeners() 108 | this.isAttached = false 109 | console.log('[MessageBus] Main message handler detached') 110 | } 111 | 112 | /** 113 | * Handles incoming messages and distributes to registered listeners 114 | * @param {Object} msg - The message object from figma.ui 115 | * @private 116 | */ 117 | handleMessage(msg) { 118 | if (!msg || !msg.type) { 119 | console.warn('[MessageBus] Received message without type:', msg) 120 | return 121 | } 122 | 123 | const messageName = msg.type 124 | const callback = this.listeners.get(messageName) 125 | 126 | if (!callback) { 127 | console.log(`[MessageBus] No listener for message name: ${messageName}`) 128 | return 129 | } 130 | 131 | console.log(`[MessageBus] Executing listener for '${messageName}'`) 132 | 133 | // Execute the listener for this message name 134 | try { 135 | callback(msg) 136 | } catch (error) { 137 | console.error(`[MessageBus] Error in listener for '${messageName}':`, error) 138 | } 139 | } 140 | } 141 | 142 | // Export singleton instance 143 | export default new MessageBus() 144 | -------------------------------------------------------------------------------- /src/utils/Router.js: -------------------------------------------------------------------------------- 1 | import LEOObject from 'leo/object' 2 | 3 | let singleton = null 4 | class Router extends LEOObject { 5 | constructor() { 6 | super() 7 | this.url = window.location.hash 8 | this.routes = {} 9 | this.bind() 10 | 11 | if (!singleton) singleton = this 12 | return singleton 13 | } 14 | 15 | get root() { return '' } 16 | 17 | setup(routes) { 18 | this.routes = routes 19 | } 20 | 21 | updateURLBar(url) { 22 | window.location.hash = url 23 | } 24 | 25 | reload() { 26 | this.trigger('change:url', this.url) 27 | } 28 | 29 | navigate(url) { 30 | this.updateURLBar(url) 31 | } 32 | 33 | back() { 34 | window.history.back() 35 | } 36 | 37 | bind() { 38 | window.addEventListener('hashchange', (e) => this.url = window.location.hash) 39 | } 40 | } 41 | 42 | export default new Router() 43 | -------------------------------------------------------------------------------- /src/utils/Storage.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Storage utility for Figma clientStorage operations 3 | * Provides a centralized abstraction layer with error tracking 4 | */ 5 | 6 | import FigPen from 'src/utils/FigPen' 7 | import CONFIG from 'src/Config' 8 | 9 | let singleton = null 10 | 11 | class Storage { 12 | constructor() { 13 | this.keys = new Map() 14 | this.initialized = false 15 | this.FigPen = new FigPen(CONFIG) 16 | 17 | if (!singleton) singleton = this 18 | return singleton 19 | } 20 | 21 | /** 22 | * Initialize storage with valid keys from Core.js 23 | * @param {Object} keyDefinitions - Object with key-value pairs for valid storage keys 24 | */ 25 | init(keyDefinitions) { 26 | this.keys.clear() 27 | Object.entries(keyDefinitions).forEach(([name, value]) => { 28 | this.keys.set(name, value) 29 | }) 30 | this.initialized = true 31 | console.log('[Storage] Initialized with keys:', Array.from(this.keys.keys())) 32 | } 33 | 34 | /** 35 | * Validates that a storage key is defined in the keys Map 36 | * @param {string} key - The storage key to validate 37 | * @returns {boolean} True if valid, throws error if not 38 | */ 39 | validateStorageKey(key) { 40 | if (!this.initialized) { 41 | throw new Error('Storage not initialized. Call Storage.init() first.') 42 | } 43 | 44 | const validKeys = Array.from(this.keys.values()) 45 | if (!validKeys.includes(key)) { 46 | const keyNames = Array.from(this.keys.keys()) 47 | throw new Error(`Invalid storage key: ${key}. Valid keys: ${keyNames.join(', ')}`) 48 | } 49 | return true 50 | } 51 | 52 | /** 53 | * Get a key value by name 54 | * @param {string} keyName - The key name (e.g., 'UUID', 'LICENSE_V1') 55 | * @returns {string} The actual storage key value 56 | */ 57 | getKey(keyName) { 58 | if (!this.keys.has(keyName)) { 59 | throw new Error(`Key name not found: ${keyName}`) 60 | } 61 | return this.keys.get(keyName) 62 | } 63 | 64 | /** 65 | * Gets a value from Figma client storage 66 | * @param {string} key - Storage key (must be defined in keys Map) 67 | * @returns {Promise} The stored value or null if not found/error 68 | */ 69 | get(key) { 70 | this.validateStorageKey(key) 71 | 72 | return this.FigPen.getStorageItem(key) 73 | .then(value => { 74 | return value 75 | }) 76 | .catch(error => { 77 | console.error(`[Storage] Failed to get ${key}:`, error) 78 | this.emitStorageError('get', key, error) 79 | return null 80 | }) 81 | } 82 | 83 | /** 84 | * Sets a value in Figma client storage 85 | * @param {string} key - Storage key (must be defined in keys Map) 86 | * @param {any} value - Value to store (null to remove) 87 | * @returns {Promise} True if successful, false otherwise 88 | */ 89 | set(key, value) { 90 | this.validateStorageKey(key) 91 | 92 | return this.FigPen.setStorageItem(key, value) 93 | .then(() => { 94 | return true 95 | }) 96 | .catch(error => { 97 | console.error(`[Storage] Failed to set ${key}:`, error) 98 | this.emitStorageError('set', key, error) 99 | return false 100 | }) 101 | } 102 | 103 | /** 104 | * Gets multiple storage values in parallel 105 | * @param {string[]} keys - Array of storage keys 106 | * @returns {Promise} Object with key-value pairs (failed keys will have null values) 107 | */ 108 | getMultiple(keys) { 109 | // Validate all keys first 110 | keys.forEach(key => this.validateStorageKey(key)) 111 | 112 | const promises = keys.map((key) => { 113 | return this.FigPen.getStorageItem(key) 114 | .then(value => { 115 | return { key, value, success: true } 116 | }) 117 | .catch(error => { 118 | console.error(`[Storage] Failed to get ${key}:`, error) 119 | this.emitStorageError('get', key, error) 120 | return { key, value: null, success: false } 121 | }) 122 | }) 123 | 124 | return Promise.all(promises) 125 | .then(results => { 126 | // Convert to key-value object 127 | return results.reduce((acc, result) => { 128 | acc[result.key] = result.value 129 | return acc 130 | }, {}) 131 | }) 132 | } 133 | 134 | /** 135 | * Sets multiple storage values in parallel 136 | * @param {Object} keyValuePairs - Object with key-value pairs to store 137 | * @returns {Promise} Object with key-success pairs indicating which operations succeeded 138 | */ 139 | setMultiple(keyValuePairs) { 140 | const keys = Object.keys(keyValuePairs) 141 | 142 | // Validate all keys first 143 | keys.forEach(key => this.validateStorageKey(key)) 144 | 145 | const promises = keys.map((key) => { 146 | return this.FigPen.setStorageItem(key, keyValuePairs[key]) 147 | .then(() => { 148 | return { key, success: true } 149 | }) 150 | .catch(error => { 151 | console.error(`[Storage] Failed to set ${key}:`, error) 152 | this.emitStorageError('set', key, error) 153 | return { key, success: false } 154 | }) 155 | }) 156 | 157 | return Promise.all(promises) 158 | .then(results => { 159 | // Convert to key-success object 160 | return results.reduce((acc, result) => { 161 | acc[result.key] = result.success 162 | return acc 163 | }, {}) 164 | }) 165 | } 166 | 167 | /** 168 | * Removes a value from storage (sets to null) 169 | * @param {string} key - Storage key to remove 170 | * @returns {Promise} True if successful, false otherwise 171 | */ 172 | remove(key) { 173 | return this.set(key, null) 174 | } 175 | 176 | /** 177 | * Emits a tracking event for storage errors 178 | * @param {string} operation - 'get' or 'set' 179 | * @param {string} key - The storage key that failed 180 | * @param {Error} error - The error that occurred 181 | */ 182 | emitStorageError(operation, key, error) { 183 | if (typeof figma !== 'undefined' && figma.ui) { 184 | figma.ui.postMessage({ 185 | type: 'tracking-event', 186 | event: 'storage-operation-failed', 187 | properties: { 188 | operation: operation, 189 | key: key, 190 | error: error.message || error.toString(), 191 | timestamp: Date.now() 192 | } 193 | }) 194 | } 195 | } 196 | } 197 | 198 | // Export singleton instance 199 | export default new Storage() 200 | -------------------------------------------------------------------------------- /src/utils/Tracking.js: -------------------------------------------------------------------------------- 1 | import UAParser from 'ua-parser-js' 2 | 3 | const UUIDKey = 'UUID' 4 | let singleton = null 5 | 6 | class Tracking { 7 | constructor() { 8 | this.root = 'https://api.amplitude.com/httpapi' 9 | this.apiKey = '' 10 | this.userId = '' 11 | this.userProps = {} 12 | this.UUID = '' 13 | this.UA = '' 14 | this.hasSetup = false 15 | this.parser = new UAParser() 16 | 17 | if (!singleton) singleton = this 18 | return singleton 19 | } 20 | 21 | createUUID(a) { 22 | // See: https://github.com/amplitude/Amplitude-Javascript/blob/master/src/uuid.js 23 | var uuid = function(a) { 24 | return a // if the placeholder was passed, return 25 | ? ( // a random number from 0 to 15 26 | a ^ // unless b is 8, 27 | Math.random() // in which case 28 | * 16 // a random number from 29 | >> a / 4 // 8 to 11 30 | ).toString(16) // in hexadecimal 31 | : ( // or otherwise a concatenated string: 32 | [1e7] + // 10000000 + 33 | -1e3 + // -1000 + 34 | -4e3 + // -4000 + 35 | -8e3 + // -80000000 + 36 | -1e11 // -100000000000, 37 | ).replace( // replacing 38 | /[018]/g, // zeroes, ones, and eights with 39 | uuid // random hex digits 40 | ); 41 | } 42 | return uuid() 43 | } 44 | 45 | setup(apiKey, UUID) { 46 | this.apiKey = apiKey 47 | this.UUID = UUID 48 | this.UA = this.parser.getResult() 49 | this.hasSetup = true 50 | } 51 | 52 | track(event, props) { 53 | if (!this.hasSetup) return console.log('Missing Tracking.init(API_KEY, UUID) before Tracking.track()') 54 | if (WP_ENV == 'development') return console.log(event, props || {}) 55 | 56 | let evtObj = { 57 | user_id: this.userId, 58 | device_id: this.UUID, 59 | event_type: event, 60 | os_name: this.UA.os.name, 61 | os_version: this.UA.os.version, 62 | platform: `${this.UA.browser.name} ${this.UA.browser.major}`, 63 | language: navigator.language, 64 | user_properties: this.userProps, 65 | event_properties: props, 66 | time: Math.floor(Date.now()) 67 | } 68 | 69 | var data = new FormData() 70 | data.append( "api_key", this.apiKey) 71 | data.append( "event", JSON.stringify(evtObj)) 72 | 73 | fetch(this.root, { 74 | method: 'POST', 75 | body: data 76 | }) 77 | } 78 | 79 | } 80 | 81 | export default new Tracking() 82 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const secrets = require('./secrets.json') 2 | const webpack = require('webpack') 3 | const HtmlWebpackPlugin = require('html-webpack-plugin') 4 | const HtmlInlineScriptPlugin = require('html-inline-script-webpack-plugin') 5 | const path = require('path') 6 | 7 | if (process.env.DESIGN_TOOL === 'figma') { 8 | design_tool_dist = 'figma/dist' 9 | design_tool_root = 'figma' 10 | } else if (process.env.DESIGN_TOOL === 'penpot') { 11 | design_tool_dist = 'penpot/dist' 12 | design_tool_root = 'penpot' 13 | } else { 14 | console.error('Invalid design tool. Please set the mode to figma or penpot.') 15 | process.exit(1) 16 | } 17 | 18 | if (process.env.NODE_ENV === 'production') { 19 | PLUGIN_URL = 'https://super-tidy.netlify.app/dist/index.html' 20 | } else { 21 | PLUGIN_URL = 'http://localhost:3000/dist/index.html' 22 | } 23 | 24 | module.exports = (env, argv) => ({ 25 | mode: argv.mode === 'production' ? 'production' : 'development', 26 | 27 | // This is necessary because Figma's 'eval' works differently than normal eval 28 | devtool: argv.mode === 'production' ? false : 'inline-source-map', 29 | 30 | entry: { 31 | ui: './src/App.js', // The entry point for your UI code 32 | core: './src/Core.js', // The entry point for your plugin code 33 | }, 34 | 35 | module: { 36 | rules: [ 37 | // Enables including CSS by doing "import './file.css'" in your JavaScript code 38 | { 39 | test: /\.css$/, 40 | use: [ 41 | 'style-loader', 42 | { 43 | loader: 'css-loader', 44 | options: { 45 | // Disable URL processing to avoid issues with data URLs 46 | url: false, 47 | // Handle imports properly 48 | import: true, 49 | }, 50 | } 51 | ] 52 | }, 53 | 54 | // Handle images and assets 55 | { 56 | test: /\.(png|jpg|gif|webp|svg)$/, 57 | type: 'asset/inline' // Webpack 5 way to inline assets 58 | }, 59 | 60 | // JavaScript/ES6+ processing 61 | { 62 | test: /\.js$/, 63 | exclude: /node_modules/, 64 | use: { 65 | loader: 'babel-loader', 66 | options: { 67 | presets: ['@babel/preset-env'] 68 | } 69 | } 70 | } 71 | ] 72 | }, 73 | 74 | // Webpack tries these extensions for you if you omit the extension like "import './file'" 75 | resolve: { 76 | extensions: ['.js'], 77 | alias: { 78 | '@': path.resolve(__dirname, './'), 79 | src: path.resolve(__dirname, 'src/'), 80 | leo: path.resolve(__dirname, 'node_modules/@basiclines/leo/dist/'), 81 | } 82 | }, 83 | 84 | output: { 85 | filename: '[name].js', 86 | path: path.resolve(__dirname, design_tool_dist), // Compile into a folder called "dist" 87 | clean: true, // Clean output directory before build 88 | environment: { 89 | // Ensure compatibility with older environments 90 | arrowFunction: false, 91 | const: false, 92 | destructuring: false, 93 | forOf: false, 94 | module: false, 95 | } 96 | }, 97 | 98 | // Modern webpack 5 optimizations 99 | optimization: { 100 | // Enable tree shaking for better bundle size 101 | usedExports: true, 102 | sideEffects: false, 103 | // Disable code splitting for Figma plugin compatibility 104 | splitChunks: false, 105 | }, 106 | 107 | // Tells Webpack to generate "ui.html" and to inline "ui.js" into it 108 | plugins: [ 109 | new webpack.DefinePlugin({ 110 | 'WP_ENV': JSON.stringify(process.env.NODE_ENV), 111 | 'WP_AMPLITUDE_KEY': JSON.stringify(secrets.AMPLITUDE_KEY), 112 | 'WP_GUMROAD_PRODUCT_ID': JSON.stringify(secrets.GUMROAD_PRODUCT_ID), 113 | 'WP_DESIGN_TOOL': JSON.stringify(process.env.DESIGN_TOOL), 114 | 'WP_PLUGIN_URL': JSON.stringify(PLUGIN_URL), 115 | // Define globals for Figma plugin environment 116 | 'self': 'globalThis', 117 | 'global': 'globalThis', 118 | }), 119 | new HtmlWebpackPlugin({ 120 | templateContent: ``, 121 | filename: 'index.html', 122 | chunks: ['ui'], 123 | }), 124 | new HtmlInlineScriptPlugin() 125 | ] 126 | }) 127 | --------------------------------------------------------------------------------