├── .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 | 			
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) ? `
50 | 			
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 | 			
78 | 				${defaults.reduce((buffer, item) => {
79 | 					buffer += this.renderItem(item)
80 | 					return buffer
81 | 				}, '')}
82 | 			
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 | 				
 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 | 			
 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 | 			
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 | 		
140 | 			Empty selection
141 | 			
142 | 				Select some layers first to start using Super Tidy.
143 | 			
144 | 		
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 | 			
210 | 				
Activate Super Tidy Pro
211 | 				
212 | 					Enter your license key to unlock instant runs and skip all countdowns.
213 | 				
214 | 				
215 | 				
240 | 				
241 | 				${this.renderSupportSection()}
242 |