tabby-tabbyspaces 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/.github/workflows/ci.yml +26 -0
  3. package/.github/workflows/claude-code-review.yml +44 -0
  4. package/.github/workflows/claude.yml +81 -0
  5. package/.github/workflows/release.yml +30 -0
  6. package/CHANGELOG.md +46 -0
  7. package/CLAUDE.md +33 -0
  8. package/CONTRIBUTING.md +3 -1
  9. package/README.md +21 -18
  10. package/TODO.md +5 -0
  11. package/dist/build-config.d.ts +3 -3
  12. package/dist/components/deleteConfirmModal.component.d.ts +7 -0
  13. package/dist/components/deleteConfirmModal.component.d.ts.map +1 -0
  14. package/dist/components/paneEditor.component.d.ts +9 -18
  15. package/dist/components/paneEditor.component.d.ts.map +1 -1
  16. package/dist/components/splitPreview.component.d.ts +50 -50
  17. package/dist/components/splitPreview.component.d.ts.map +1 -1
  18. package/dist/components/workspaceEditor.component.d.ts +61 -54
  19. package/dist/components/workspaceEditor.component.d.ts.map +1 -1
  20. package/dist/components/workspaceList.component.d.ts +56 -39
  21. package/dist/components/workspaceList.component.d.ts.map +1 -1
  22. package/dist/index.d.ts +6 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js +1 -1
  25. package/dist/index.js.LICENSE.txt +1 -1
  26. package/dist/index.js.map +1 -1
  27. package/dist/models/workspace.model.d.ts +118 -78
  28. package/dist/models/workspace.model.d.ts.map +1 -1
  29. package/dist/package.json +1 -1
  30. package/dist/providers/config.provider.d.ts +8 -8
  31. package/dist/providers/settings.provider.d.ts +7 -7
  32. package/dist/providers/toolbar.provider.d.ts +23 -15
  33. package/dist/providers/toolbar.provider.d.ts.map +1 -1
  34. package/dist/services/startupCommand.service.d.ts +27 -19
  35. package/dist/services/startupCommand.service.d.ts.map +1 -1
  36. package/dist/services/workspaceBackground.service.d.ts +38 -0
  37. package/dist/services/workspaceBackground.service.d.ts.map +1 -0
  38. package/dist/services/workspaceEditor.service.d.ts +46 -32
  39. package/dist/services/workspaceEditor.service.d.ts.map +1 -1
  40. package/docs/DESIGN.md +57 -0
  41. package/docs/SESSION-2026-01-14-S1-DESIGN.md +134 -0
  42. package/mockups/index.html +162 -0
  43. package/mockups/s1-tight-sharp.html +522 -0
  44. package/mockups/shared/base.css +216 -0
  45. package/mockups/v06-tabbed.html +643 -0
  46. package/package.json +2 -1
  47. package/screenshots/editor.png +0 -0
  48. package/scripts/build-dev.js +2 -1
  49. package/scripts/build-prod.js +2 -1
  50. package/src/components/deleteConfirmModal.component.ts +23 -0
  51. package/src/components/paneEditor.component.pug +27 -43
  52. package/src/components/paneEditor.component.scss +37 -85
  53. package/src/components/paneEditor.component.ts +4 -32
  54. package/src/components/splitPreview.component.pug +0 -9
  55. package/src/components/splitPreview.component.scss +46 -70
  56. package/src/components/splitPreview.component.ts +15 -25
  57. package/src/components/workspaceEditor.component.pug +140 -112
  58. package/src/components/workspaceEditor.component.scss +270 -202
  59. package/src/components/workspaceEditor.component.ts +161 -85
  60. package/src/components/workspaceList.component.pug +31 -51
  61. package/src/components/workspaceList.component.scss +86 -77
  62. package/src/components/workspaceList.component.ts +89 -34
  63. package/src/index.ts +4 -0
  64. package/src/models/workspace.model.ts +80 -2
  65. package/src/providers/toolbar.provider.ts +78 -9
  66. package/src/services/startupCommand.service.ts +30 -32
  67. package/src/services/workspaceBackground.service.ts +167 -0
  68. package/src/services/workspaceEditor.service.ts +77 -40
  69. package/src/styles/_index.scss +3 -0
  70. package/src/styles/_mixins.scss +180 -0
  71. package/src/styles/_variables.scss +67 -0
  72. package/TEST_MCP.md +0 -176
  73. package/cdp-click.js +0 -22
  74. package/cdp-test.js +0 -28
  75. package/screenshots/pane-edit.png +0 -0
  76. package/test_cdp.py +0 -50
@@ -0,0 +1,167 @@
1
+ import { Injectable } from '@angular/core'
2
+ import { AppService, SplitTabComponent } from 'tabby-core'
3
+ import { WorkspaceEditorService } from './workspaceEditor.service'
4
+ import { WorkspaceBackground } from '../models/workspace.model'
5
+ import { CONFIG_KEY } from '../build-config'
6
+
7
+ /**
8
+ * Service for applying custom backgrounds to workspace tabs.
9
+ * Injects CSS dynamically based on workspace configuration.
10
+ */
11
+ @Injectable({ providedIn: 'root' })
12
+ export class WorkspaceBackgroundService {
13
+ private styleElement: HTMLStyleElement | null = null
14
+ private appliedWorkspaces = new Map<string, string>() // workspaceId -> CSS
15
+
16
+ constructor(
17
+ private app: AppService,
18
+ private workspaceService: WorkspaceEditorService
19
+ ) {}
20
+
21
+ /**
22
+ * Initialize the service by setting up tab event listeners.
23
+ * Must be called once during app initialization.
24
+ */
25
+ initialize(): void {
26
+ this.setupTabListeners()
27
+ }
28
+
29
+ private setupTabListeners(): void {
30
+ // Listen for tab open
31
+ this.app.tabOpened$.subscribe(tab => this.onTabOpened(tab))
32
+
33
+ // Listen for tab close - cleanup
34
+ this.app.tabClosed$.subscribe(tab => this.onTabClosed(tab))
35
+ }
36
+
37
+ private onTabOpened(tab: unknown): void {
38
+ if (!(tab instanceof SplitTabComponent)) return
39
+
40
+ // Small delay to let Angular finish rendering
41
+ setTimeout(() => {
42
+ const workspaceId = this.extractWorkspaceId(tab)
43
+ if (!workspaceId) return
44
+
45
+ const workspace = this.workspaceService.getWorkspaces()
46
+ .find(w => w.id === workspaceId)
47
+
48
+ if (workspace?.background && workspace.background.type !== 'none') {
49
+ this.applyBackground(workspaceId, workspace.background)
50
+ }
51
+ }, 200)
52
+ }
53
+
54
+ private onTabClosed(tab: unknown): void {
55
+ if (!(tab instanceof SplitTabComponent)) return
56
+
57
+ const workspaceId = this.extractWorkspaceId(tab)
58
+ if (workspaceId) {
59
+ this.removeBackground(workspaceId)
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Extract workspace ID from a SplitTabComponent.
65
+ * Tries multiple strategies: _recoveredState and child profile ID.
66
+ */
67
+ private extractWorkspaceId(tab: SplitTabComponent): string | undefined {
68
+ const tabAny = tab as any
69
+
70
+ // Strategy 1: Check _recoveredState.workspaceId (for restored tabs)
71
+ if (tabAny._recoveredState?.workspaceId) {
72
+ return tabAny._recoveredState.workspaceId
73
+ }
74
+
75
+ // Strategy 2: Extract from child profile ID (for freshly opened tabs)
76
+ const profilePrefix = `split-layout:${CONFIG_KEY}:`
77
+ for (const child of tab.getAllTabs()) {
78
+ const profileId = (child as any).profile?.id ?? ''
79
+ if (profileId.startsWith(profilePrefix)) {
80
+ // Profile ID format: split-layout:CONFIG_KEY:name:UUID
81
+ const parts = profileId.split(':')
82
+ return parts[parts.length - 1]
83
+ }
84
+ }
85
+
86
+ return undefined
87
+ }
88
+
89
+ private applyBackground(workspaceId: string, bg: WorkspaceBackground): void {
90
+ // Mark split-tab element with data attribute
91
+ this.markSplitTabElement(workspaceId)
92
+
93
+ // Generate and inject CSS
94
+ const css = this.generateCSS(workspaceId, bg)
95
+ this.injectCSS(workspaceId, css)
96
+ }
97
+
98
+ private markSplitTabElement(workspaceId: string): void {
99
+ // Find split-tab that doesn't have a workspace-id yet
100
+ const splitTabs = document.querySelectorAll('split-tab')
101
+ for (let i = splitTabs.length - 1; i >= 0; i--) {
102
+ const splitTab = splitTabs[i]
103
+ if (!splitTab.hasAttribute('data-workspace-id')) {
104
+ splitTab.setAttribute('data-workspace-id', workspaceId)
105
+ break
106
+ }
107
+ }
108
+ }
109
+
110
+ private generateCSS(workspaceId: string, bg: WorkspaceBackground): string {
111
+ if (bg.type === 'none' || !bg.value) return ''
112
+
113
+ return `
114
+ split-tab[data-workspace-id="${workspaceId}"] {
115
+ background: ${bg.value} !important;
116
+ }
117
+ split-tab[data-workspace-id="${workspaceId}"] .xterm-viewport,
118
+ split-tab[data-workspace-id="${workspaceId}"] .xterm-screen {
119
+ background: transparent !important;
120
+ }
121
+ `
122
+ }
123
+
124
+ private injectCSS(workspaceId: string, css: string): void {
125
+ if (!this.styleElement) {
126
+ this.styleElement = document.createElement('style')
127
+ this.styleElement.id = 'tabbyspaces-backgrounds'
128
+ document.head.appendChild(this.styleElement)
129
+ }
130
+
131
+ this.appliedWorkspaces.set(workspaceId, css)
132
+ this.updateStyleElement()
133
+ }
134
+
135
+ private removeBackground(workspaceId: string): void {
136
+ this.appliedWorkspaces.delete(workspaceId)
137
+ this.updateStyleElement()
138
+ }
139
+
140
+ private updateStyleElement(): void {
141
+ if (this.styleElement) {
142
+ this.styleElement.textContent = Array.from(this.appliedWorkspaces.values()).join('\n')
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Refresh background for a specific workspace.
148
+ * Call this when workspace background is updated in settings.
149
+ */
150
+ refreshWorkspaceBackground(workspaceId: string): void {
151
+ const workspace = this.workspaceService.getWorkspaces()
152
+ .find(w => w.id === workspaceId)
153
+
154
+ if (!workspace) {
155
+ this.removeBackground(workspaceId)
156
+ return
157
+ }
158
+
159
+ if (workspace.background && workspace.background.type !== 'none') {
160
+ const css = this.generateCSS(workspaceId, workspace.background)
161
+ this.appliedWorkspaces.set(workspaceId, css)
162
+ } else {
163
+ this.appliedWorkspaces.delete(workspaceId)
164
+ }
165
+ this.updateStyleElement()
166
+ }
167
+ }
@@ -6,6 +6,7 @@ import {
6
6
  WorkspaceSplit,
7
7
  isWorkspaceSplit,
8
8
  generateUUID,
9
+ deepClone,
9
10
  TabbyProfile,
10
11
  TabbyRecoveryToken,
11
12
  TabbySplitLayoutProfile,
@@ -15,7 +16,9 @@ import { PendingCommand } from './startupCommand.service'
15
16
 
16
17
  @Injectable({ providedIn: 'root' })
17
18
  export class WorkspaceEditorService {
18
- private cachedProfiles: TabbyProfile[] = []
19
+ private cachedProfiles: TabbyProfile[] | null = null
20
+ private cacheTimestamp: number = 0
21
+ private readonly CACHE_TTL = 30000 // 30 seconds
19
22
 
20
23
  constructor(
21
24
  private config: ConfigService,
@@ -23,49 +26,78 @@ export class WorkspaceEditorService {
23
26
  private profilesService: ProfilesService
24
27
  ) {}
25
28
 
26
- private async cacheProfiles(): Promise<void> {
27
- this.cachedProfiles = (await this.profilesService.getProfiles()) as TabbyProfile[]
29
+ private async getCachedProfiles(): Promise<TabbyProfile[]> {
30
+ const now = Date.now()
31
+ if (!this.cachedProfiles || now - this.cacheTimestamp > this.CACHE_TTL) {
32
+ this.cachedProfiles = (await this.profilesService.getProfiles()) as TabbyProfile[]
33
+ this.cacheTimestamp = now
34
+ }
35
+ return this.cachedProfiles
28
36
  }
29
37
 
38
+ /** Returns all saved workspaces from config. */
30
39
  getWorkspaces(): Workspace[] {
31
40
  return this.config.store?.[CONFIG_KEY]?.workspaces ?? []
32
41
  }
33
42
 
34
- async saveWorkspaces(workspaces: Workspace[]): Promise<boolean> {
43
+ /**
44
+ * Saves the workspace list to config.
45
+ * @throws Error if config store is not initialized
46
+ */
47
+ async saveWorkspaces(workspaces: Workspace[]): Promise<void> {
35
48
  if (!this.config.store?.[CONFIG_KEY]) {
36
- return false
49
+ throw new Error('Config store not initialized')
37
50
  }
38
51
  this.config.store[CONFIG_KEY].workspaces = workspaces
39
- return await this.saveConfig()
52
+ await this.saveConfig()
40
53
  }
41
54
 
55
+ /** Adds a new workspace and shows notification. */
42
56
  async addWorkspace(workspace: Workspace): Promise<void> {
43
- const workspaces = this.getWorkspaces()
44
- workspaces.push(workspace)
45
- await this.saveWorkspaces(workspaces)
46
- this.notifications.info(`Workspace "${workspace.name}" created`)
57
+ try {
58
+ const workspaces = this.getWorkspaces()
59
+ workspaces.push(workspace)
60
+ await this.saveWorkspaces(workspaces)
61
+ this.notifications.info(`Workspace "${workspace.name}" created`)
62
+ } catch (error) {
63
+ this.notifications.error(`Failed to create workspace "${workspace.name}"`)
64
+ throw error
65
+ }
47
66
  }
48
67
 
68
+ /** Updates an existing workspace by ID and shows notification. */
49
69
  async updateWorkspace(workspace: Workspace): Promise<void> {
50
- const workspaces = this.getWorkspaces()
51
- const index = workspaces.findIndex((w) => w.id === workspace.id)
52
- if (index !== -1) {
53
- workspaces[index] = workspace
54
- await this.saveWorkspaces(workspaces)
55
- this.notifications.info(`Workspace "${workspace.name}" updated`)
70
+ try {
71
+ const workspaces = this.getWorkspaces()
72
+ const index = workspaces.findIndex((w) => w.id === workspace.id)
73
+ if (index !== -1) {
74
+ workspaces[index] = workspace
75
+ await this.saveWorkspaces(workspaces)
76
+ this.notifications.info(`Workspace "${workspace.name}" updated`)
77
+ }
78
+ } catch (error) {
79
+ this.notifications.error(`Failed to update workspace "${workspace.name}"`)
80
+ throw error
56
81
  }
57
82
  }
58
83
 
84
+ /** Deletes a workspace by ID and shows notification. */
59
85
  async deleteWorkspace(workspaceId: string): Promise<void> {
60
86
  const workspaces = this.getWorkspaces()
61
87
  const workspace = workspaces.find((w) => w.id === workspaceId)
62
- const filtered = workspaces.filter((w) => w.id !== workspaceId)
63
- await this.saveWorkspaces(filtered)
64
- if (workspace) {
65
- this.notifications.info(`Workspace "${workspace.name}" deleted`)
88
+ try {
89
+ const filtered = workspaces.filter((w) => w.id !== workspaceId)
90
+ await this.saveWorkspaces(filtered)
91
+ if (workspace) {
92
+ this.notifications.info(`Workspace "${workspace.name}" deleted`)
93
+ }
94
+ } catch (error) {
95
+ this.notifications.error(`Failed to delete workspace "${workspace?.name || workspaceId}"`)
96
+ throw error
66
97
  }
67
98
  }
68
99
 
100
+ /** Returns all local shell profiles available for use in workspaces. */
69
101
  async getAvailableProfiles(): Promise<TabbyProfile[]> {
70
102
  const allProfiles = await this.profilesService.getProfiles()
71
103
  return allProfiles.filter(
@@ -93,8 +125,9 @@ export class WorkspaceEditorService {
93
125
  }
94
126
  }
95
127
 
128
+ /** Generates a Tabby split-layout profile from a workspace for opening. */
96
129
  async generateTabbyProfile(workspace: Workspace): Promise<TabbySplitLayoutProfile> {
97
- await this.cacheProfiles()
130
+ await this.getCachedProfiles()
98
131
  const safeName = this.sanitizeForProfileId(workspace.name)
99
132
  return {
100
133
  id: `split-layout:${CONFIG_KEY}:${safeName}:${workspace.id}`,
@@ -105,26 +138,27 @@ export class WorkspaceEditorService {
105
138
  color: workspace.color,
106
139
  isBuiltin: false,
107
140
  options: {
108
- recoveryToken: this.generateRecoveryToken(workspace.root),
141
+ recoveryToken: this.generateRecoveryToken(workspace.root, workspace.name, workspace.id),
109
142
  },
110
143
  }
111
144
  }
112
145
 
113
- private generateRecoveryToken(split: WorkspaceSplit): TabbyRecoveryToken {
146
+ private generateRecoveryToken(split: WorkspaceSplit, workspaceName: string, workspaceId: string): TabbyRecoveryToken {
114
147
  return {
115
148
  type: 'app:split-tab',
116
149
  orientation: split.orientation === 'horizontal' ? 'h' : 'v',
117
150
  ratios: split.ratios,
151
+ workspaceId,
118
152
  children: split.children.map((child) => {
119
153
  if (isWorkspaceSplit(child)) {
120
- return this.generateRecoveryToken(child)
154
+ return this.generateRecoveryToken(child, workspaceName, workspaceId)
121
155
  }
122
- return this.generatePaneToken(child)
156
+ return this.generatePaneToken(child, workspaceName, workspaceId)
123
157
  }),
124
158
  }
125
159
  }
126
160
 
127
- private generatePaneToken(pane: WorkspacePane): TabbyRecoveryToken {
161
+ private generatePaneToken(pane: WorkspacePane, workspaceName: string, workspaceId: string): TabbyRecoveryToken {
128
162
  const baseProfile = this.getProfileById(pane.profileId)
129
163
 
130
164
  if (!baseProfile) {
@@ -162,7 +196,7 @@ export class WorkspaceEditorService {
162
196
  options,
163
197
  icon: baseProfile.icon || '',
164
198
  color: baseProfile.color || '',
165
- disableDynamicTitle: false,
199
+ disableDynamicTitle: true,
166
200
  weight: 0,
167
201
  isBuiltin: false,
168
202
  isTemplate: false,
@@ -170,22 +204,25 @@ export class WorkspaceEditorService {
170
204
  behaviorOnSessionEnd: 'auto',
171
205
  }
172
206
 
173
- // Use pane.id for matching in StartupCommandService
174
- // Original title will be restored after command execution
207
+ // tabTitle: workspace name (what user sees)
208
+ // tabCustomTitle: pane.id (for matching in StartupCommandService)
209
+ // workspaceId: for duplicate detection after Tabby recovery
175
210
  const cwd = pane.cwd || baseProfile.options?.cwd || ''
176
211
  return {
177
212
  type: 'app:local-tab',
178
213
  profile,
179
214
  savedState: false,
180
- tabTitle: pane.id,
215
+ tabTitle: workspaceName,
181
216
  tabCustomTitle: pane.id,
182
- disableDynamicTitle: false,
217
+ workspaceId,
218
+ disableDynamicTitle: true,
183
219
  cwd,
184
220
  }
185
221
  }
186
222
 
223
+ /** Creates a deep copy of a workspace with new IDs. */
187
224
  duplicateWorkspace(workspace: Workspace): Workspace {
188
- const clone = JSON.parse(JSON.stringify(workspace)) as Workspace
225
+ const clone = deepClone(workspace)
189
226
  clone.id = generateUUID()
190
227
  clone.name = `${workspace.name} (Copy)`
191
228
  clone.launchOnStartup = false
@@ -221,40 +258,40 @@ export class WorkspaceEditorService {
221
258
  if (found) return found
222
259
 
223
260
  // Fallback: check cached profiles (includes built-ins)
224
- return this.cachedProfiles.find((p) => p.id === profileId && isLocalType(p.type))
261
+ return this.cachedProfiles?.find((p) => p.id === profileId && isLocalType(p.type))
225
262
  }
226
263
 
264
+ /** Collects all startup commands from panes in a workspace. */
227
265
  collectStartupCommands(workspace: Workspace): PendingCommand[] {
228
266
  const commands: PendingCommand[] = []
229
- this.collectCommandsFromNode(workspace.root, commands)
267
+ this.collectCommandsFromNode(workspace.root, workspace.name, commands)
230
268
  return commands
231
269
  }
232
270
 
233
271
  private collectCommandsFromNode(
234
272
  node: WorkspacePane | WorkspaceSplit,
273
+ workspaceName: string,
235
274
  commands: PendingCommand[]
236
275
  ): void {
237
276
  if (isWorkspaceSplit(node)) {
238
277
  for (const child of node.children) {
239
- this.collectCommandsFromNode(child, commands)
278
+ this.collectCommandsFromNode(child, workspaceName, commands)
240
279
  }
241
280
  } else if (node.startupCommand) {
242
281
  commands.push({
243
282
  paneId: node.id,
244
283
  command: node.startupCommand,
245
- originalTitle: node.title || '',
284
+ originalTitle: workspaceName,
246
285
  })
247
286
  }
248
287
  }
249
288
 
250
- private async saveConfig(): Promise<boolean> {
289
+ private async saveConfig(): Promise<void> {
251
290
  try {
252
291
  await this.config.save()
253
- return true
254
292
  } catch (error) {
255
- this.notifications.error('Failed to save configuration')
256
293
  console.error('TabbySpaces save error:', error)
257
- return false
294
+ throw error
258
295
  }
259
296
  }
260
297
  }
@@ -0,0 +1,3 @@
1
+ // Main entry point for shared styles
2
+ @forward 'variables';
3
+ @forward 'mixins';
@@ -0,0 +1,180 @@
1
+ @use 'variables' as *;
2
+
3
+ // ======================
4
+ // LAYOUT MIXINS
5
+ // ======================
6
+
7
+ @mixin flex-center {
8
+ display: flex;
9
+ align-items: center;
10
+ justify-content: center;
11
+ }
12
+
13
+ // ======================
14
+ // FORM INPUT MIXIN
15
+ // ======================
16
+
17
+ @mixin form-input($bg: var(--theme-bg)) {
18
+ padding: $spacing-sm $spacing-md;
19
+ border-radius: $radius-sm;
20
+ border: 1px solid var(--theme-border, $fallback-border);
21
+ background: $bg;
22
+ color: var(--theme-fg);
23
+ font-size: $font-md;
24
+
25
+ &:focus {
26
+ outline: none;
27
+ border-color: var(--theme-primary);
28
+ }
29
+
30
+ &::placeholder {
31
+ color: var(--theme-fg-more, $fallback-fg-more);
32
+ }
33
+ }
34
+
35
+ // ======================
36
+ // FORM LABEL MIXIN (S1: Uppercase compact)
37
+ // ======================
38
+
39
+ @mixin form-label {
40
+ display: block;
41
+ font-size: $font-xs;
42
+ color: var(--theme-fg-less);
43
+ text-transform: uppercase;
44
+ letter-spacing: 0.5px;
45
+ margin-bottom: $spacing-xs;
46
+ }
47
+
48
+ // ======================
49
+ // BUTTON MIXINS
50
+ // ======================
51
+
52
+ @mixin toolbar-btn {
53
+ padding: $spacing-sm $spacing-md;
54
+ background: var(--theme-bg);
55
+ border: 1px solid var(--theme-border, $fallback-border);
56
+ border-radius: $radius-sm;
57
+ color: var(--theme-fg);
58
+ cursor: pointer;
59
+ transition: all $transition-fast;
60
+ font-size: 0.85rem;
61
+
62
+ &:hover:not(:disabled) {
63
+ background: var(--theme-bg-more-more);
64
+ }
65
+
66
+ &:disabled {
67
+ opacity: 0.4;
68
+ cursor: not-allowed;
69
+ }
70
+
71
+ &.danger {
72
+ color: var(--theme-danger, $color-danger);
73
+ }
74
+ }
75
+
76
+ @mixin btn-success {
77
+ background: $color-success;
78
+ border-color: $color-success;
79
+ color: white;
80
+
81
+ &:hover {
82
+ background: $color-success-hover;
83
+ border-color: $color-success-hover;
84
+ }
85
+ }
86
+
87
+ @mixin btn-base {
88
+ display: inline-flex;
89
+ align-items: center;
90
+ gap: $spacing-sm;
91
+ padding: $spacing-sm $spacing-lg;
92
+ font-size: $font-sm;
93
+ border-radius: $radius-sm;
94
+ cursor: pointer;
95
+ transition: all $transition-fast;
96
+ }
97
+
98
+ @mixin btn-ghost {
99
+ @include btn-base;
100
+ background: transparent;
101
+ border: 1px solid var(--theme-border, $fallback-border);
102
+ color: var(--theme-fg);
103
+
104
+ &:hover {
105
+ background: var(--theme-bg-more);
106
+ }
107
+ }
108
+
109
+ @mixin btn-primary {
110
+ @include btn-base;
111
+ background: var(--theme-primary);
112
+ border: 1px solid var(--theme-primary);
113
+ color: white;
114
+
115
+ &:hover {
116
+ filter: brightness(1.1);
117
+ }
118
+ }
119
+
120
+ @mixin icon-btn-sm($size: 24px) {
121
+ width: $size;
122
+ height: $size;
123
+ @include flex-center;
124
+ background: var(--theme-bg);
125
+ border: 1px solid var(--theme-border, $fallback-border);
126
+ border-radius: $radius-sm;
127
+ color: var(--theme-fg-more, $fallback-fg-more);
128
+ cursor: pointer;
129
+ font-size: 10px;
130
+ transition: all $transition-fast;
131
+
132
+ &:hover:not(:disabled) {
133
+ border-color: var(--theme-primary);
134
+ color: var(--theme-primary);
135
+ }
136
+
137
+ &.danger:hover:not(:disabled) {
138
+ border-color: var(--theme-danger);
139
+ color: var(--theme-danger);
140
+ }
141
+
142
+ &:disabled {
143
+ opacity: 0.4;
144
+ cursor: not-allowed;
145
+ }
146
+ }
147
+
148
+ // ======================
149
+ // OVERLAY/MODAL MIXINS
150
+ // ======================
151
+
152
+ @mixin full-overlay($z-index: $z-modal-overlay) {
153
+ position: fixed;
154
+ top: 0;
155
+ left: 0;
156
+ right: 0;
157
+ bottom: 0;
158
+ z-index: $z-index;
159
+ }
160
+
161
+ // ======================
162
+ // TEXT UTILITIES
163
+ // ======================
164
+
165
+ @mixin text-ellipsis {
166
+ white-space: nowrap;
167
+ overflow: hidden;
168
+ text-overflow: ellipsis;
169
+ }
170
+
171
+ // ======================
172
+ // DROPDOWN/POPUP
173
+ // ======================
174
+
175
+ @mixin dropdown-panel {
176
+ background: var(--theme-bg-more);
177
+ border: 1px solid var(--theme-border, $fallback-border);
178
+ border-radius: $radius-md;
179
+ box-shadow: $shadow-dropdown;
180
+ }
@@ -0,0 +1,67 @@
1
+ // ======================
2
+ // SPACING SCALE (S1: Tight)
3
+ // ======================
4
+ $spacing-xs: 2px; // toolbar group gap, tiny margins
5
+ $spacing-sm: 4px; // gaps, small padding
6
+ $spacing-md: 6px; // default gaps, button padding
7
+ $spacing-lg: 10px; // section gaps, form spacing
8
+ $spacing-xl: 14px; // container padding, large gaps
9
+ $spacing-2xl: 18px; // modal padding, section margins
10
+
11
+ // ======================
12
+ // BORDER RADIUS SCALE (S1: Sharp)
13
+ // ======================
14
+ $radius-xs: 2px; // tiny elements
15
+ $radius-sm: 2px; // buttons, small elements
16
+ $radius-md: 3px; // inputs, dropdowns
17
+ $radius-lg: 4px; // cards, containers, modals
18
+
19
+ // ======================
20
+ // CUSTOM COLORS
21
+ // ======================
22
+ // These extend Tabby's --theme-* variables
23
+ $color-success: #10b981;
24
+ $color-success-hover: #059669;
25
+ $color-warning: #f59e0b;
26
+ $color-danger: #ef4444;
27
+
28
+ // Fallback values for Tabby theme variables that may not be defined
29
+ $fallback-border: rgba(255, 255, 255, 0.1);
30
+ $fallback-fg-more: rgba(255, 255, 255, 0.3);
31
+
32
+ // ======================
33
+ // SHADOWS (S1: Flat - no shadows)
34
+ // ======================
35
+ $shadow-dropdown: none;
36
+ $shadow-context-menu: none;
37
+ $shadow-modal: none;
38
+ $overlay-bg: rgba(0, 0, 0, 0.7);
39
+
40
+ // ======================
41
+ // PREVIEW COMPONENT
42
+ // ======================
43
+ $preview-height: 140px;
44
+ $nested-split-bg: rgba(255, 255, 255, 0.02);
45
+ $selected-pane-gradient-start: rgba(59, 130, 246, 0.08);
46
+ $selected-pane-gradient-end: rgba(59, 130, 246, 0.04);
47
+
48
+ // ======================
49
+ // FONT SIZES (S1: Compact)
50
+ // ======================
51
+ $font-xs: 0.7rem; // 11px - sublabels, hints
52
+ $font-sm: 0.8rem; // 13px - labels, secondary text
53
+ $font-md: 0.85rem; // 14px - default body
54
+
55
+ // ======================
56
+ // Z-INDEX SCALE
57
+ // ======================
58
+ $z-dropdown: 100;
59
+ $z-modal-overlay: 1100;
60
+ $z-context-menu-overlay: 1200;
61
+ $z-context-menu: 1201;
62
+
63
+ // ======================
64
+ // TRANSITIONS
65
+ // ======================
66
+ $transition-fast: 0.15s;
67
+ $transition-normal: 0.2s;