kasy-cli 1.23.0 → 1.24.0

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.
package/bin/kasy.js CHANGED
@@ -463,9 +463,15 @@ function buildProgram(language) {
463
463
  if (p[p.length - 1] === 'lib') p = p.slice(0, -1);
464
464
  prefix = p.join(path.sep) || null;
465
465
  }
466
- const args = ['install', '-g', 'kasy-cli@latest'];
467
- if (prefix) args.push('--prefix', prefix);
468
- const result = spawnSync('npm', args, { stdio: 'inherit', shell: true });
466
+ // Build a single, pre-quoted command string. Passing a quoted string
467
+ // (instead of an args array) with shell:true keeps a prefix that contains
468
+ // spaces intact e.g. C:\Users\John Silva\.kasy which the array form
469
+ // splits into a broken path (npm reads "Silva\.kasy" as a second package).
470
+ // It also avoids Node's DEP0190 arg-concatenation warning.
471
+ const quote = (s) => `"${s}"`;
472
+ let cmd = 'npm install -g kasy-cli@latest';
473
+ if (prefix) cmd += ` --prefix ${quote(prefix)}`;
474
+ const result = spawnSync(cmd, { stdio: 'inherit', shell: true });
469
475
  if (result.status === 0) {
470
476
  const after = readVersion();
471
477
  let msg;
@@ -486,9 +492,25 @@ function buildProgram(language) {
486
492
  .description(t('cli.command.uninstall.description'))
487
493
  .action(() => {
488
494
  const { spawnSync } = require('node:child_process');
495
+ const path = require('node:path');
489
496
  printCompactHeader(t);
490
497
  console.log(kleur.cyan(t('cli.command.uninstall.running')) + '\n');
491
- const result = spawnSync('npm', ['uninstall', '-g', 'kasy-cli'], { stdio: 'inherit', shell: true });
498
+ // Remove from the SAME prefix the CLI was installed into (~/.kasy), the
499
+ // way `kasy upgrade` derives it — otherwise npm targets its default global
500
+ // prefix and leaves the real copy in place. Quote it so a path with
501
+ // spaces (C:\Users\John Silva\.kasy) survives shell:true.
502
+ const segs = __dirname.split(path.sep);
503
+ const nm = segs.lastIndexOf('node_modules');
504
+ let prefix = null;
505
+ if (nm > 0) {
506
+ let p = segs.slice(0, nm);
507
+ if (p[p.length - 1] === 'lib') p = p.slice(0, -1);
508
+ prefix = p.join(path.sep) || null;
509
+ }
510
+ const quote = (s) => `"${s}"`;
511
+ let cmd = 'npm uninstall -g kasy-cli';
512
+ if (prefix) cmd += ` --prefix ${quote(prefix)}`;
513
+ const result = spawnSync(cmd, { stdio: 'inherit', shell: true });
492
514
  if (result.status === 0) {
493
515
  console.log('\n' + kleur.green('✓ ' + t('cli.command.uninstall.done')) + '\n');
494
516
  } else {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.23.0",
3
+ "version": "1.24.0",
4
4
  "description": "CLI for scaffolding production-ready Flutter SaaS apps with Firebase, Supabase, or API REST backends.",
5
5
  "bin": {
6
6
  "kasy": "./bin/kasy.js"
@@ -746,6 +746,7 @@ class _KasySidebarState extends State<KasySidebar> {
746
746
  hoverColor: c.activeBg,
747
747
  pressColor: c.textActive,
748
748
  focusable: true,
749
+ focusGapColor: c.bg,
749
750
  onTap: widget.onProfileTap ?? () {},
750
751
  child: Padding(
751
752
  padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
@@ -877,6 +878,7 @@ class _KasySidebarState extends State<KasySidebar> {
877
878
  hoverColor: c.activeBg,
878
879
  pressColor: c.textActive,
879
880
  focusable: true,
881
+ focusGapColor: c.bg,
880
882
  onTap: onTap,
881
883
  child: Container(
882
884
  constraints: const BoxConstraints(minHeight: _kItemMinH),
@@ -947,6 +949,7 @@ class _KasySidebarState extends State<KasySidebar> {
947
949
  hoverColor: c.activeBg,
948
950
  pressColor: c.textActive,
949
951
  focusable: true,
952
+ focusGapColor: c.bg,
950
953
  onTap: () => _activateItem(item.id),
951
954
  child: Container(
952
955
  constraints: const BoxConstraints(minHeight: _kItemMinH),
@@ -152,20 +152,27 @@ class BottomMenu extends StatelessWidget {
152
152
  }
153
153
  }
154
154
 
155
- /// Owns the sidebar's keyboard [FocusScope] and keeps the initial Tab focus
156
- /// anchored on the sidebar — on every screen, like Stripe/Linear.
155
+ /// Keeps the initial keyboard Tab focus anchored on the sidebar on every
156
+ /// screen, like Stripe/Linear.
157
157
  ///
158
158
  /// Why this exists: Bart renders each page inside a nested [Navigator]
159
159
  /// (see bart's nested_navigator.dart), which has its OWN FocusScope and claims
160
160
  /// the primary focus the moment a route mounts. A plain `autofocus` on a sidebar
161
161
  /// item loses that race — the Navigator overwrites it in the same frame.
162
162
  ///
163
- /// The fix is a post-frame callback (runs AFTER the Navigator has claimed focus)
164
- /// that hands focus back to this sidebar scope. We focus the SCOPE, not a
165
- /// specific item, so the very first Tab lands on the first item (no item is
166
- /// skipped). It re-runs whenever [currentItem] changes (a tab navigation) so a
167
- /// fresh screen also starts at the sidebar. The ring still only paints during
168
- /// keyboard navigation, so this is invisible to mouse/touch users.
163
+ /// The fix is a tiny non-traversable [Focus] anchor inside the sidebar. A
164
+ /// post-frame callback (runs AFTER the Navigator has claimed focus) moves focus
165
+ /// to that anchor, pulling the primary focus out of the Navigator's scope and
166
+ /// back onto the sidebar. Because the anchor sets `skipTraversal: true`, it is
167
+ /// skipped by Tab, so the very first Tab lands on the first real sidebar item
168
+ /// and then flows on to the header and content — nothing is trapped. (A
169
+ /// [FocusScope] would have worked for the anchoring, but it adds a traversal
170
+ /// boundary that wraps Tab back to the sidebar's start instead of moving on to
171
+ /// the content, which is the opposite of what we want.)
172
+ ///
173
+ /// It re-anchors whenever [currentItem] changes (a tab navigation) so a fresh
174
+ /// screen also starts at the sidebar. The ring still only paints during keyboard
175
+ /// navigation, so this is invisible to mouse/touch users.
169
176
  class _FocusableSidebar extends StatefulWidget {
170
177
  final Widget child;
171
178
  final ValueNotifier<int> currentItem;
@@ -177,7 +184,10 @@ class _FocusableSidebar extends StatefulWidget {
177
184
  }
178
185
 
179
186
  class _FocusableSidebarState extends State<_FocusableSidebar> {
180
- final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'sidebarScope');
187
+ final FocusNode _anchor = FocusNode(
188
+ debugLabel: 'sidebarFocusAnchor',
189
+ skipTraversal: true,
190
+ );
181
191
 
182
192
  @override
183
193
  void initState() {
@@ -199,26 +209,28 @@ class _FocusableSidebarState extends State<_FocusableSidebar> {
199
209
  // which claims focus for its own scope while the route is mounting.
200
210
  void _anchorFocus() {
201
211
  WidgetsBinding.instance.addPostFrameCallback((_) {
202
- if (!mounted) return;
203
- // Only seize focus when nothing inside the sidebar already holds it, so a
204
- // keyboard user mid-navigation in the sidebar isn't yanked back to start.
205
- if (_scope.hasFocus) return;
206
- _scope.requestFocus();
212
+ if (mounted) _anchor.requestFocus();
207
213
  });
208
214
  }
209
215
 
210
216
  @override
211
217
  void dispose() {
212
218
  widget.currentItem.removeListener(_anchorFocus);
213
- _scope.dispose();
219
+ _anchor.dispose();
214
220
  super.dispose();
215
221
  }
216
222
 
217
223
  @override
218
224
  Widget build(BuildContext context) {
219
- return FocusScope(
220
- node: _scope,
221
- child: FocusTraversalGroup(child: widget.child),
225
+ return FocusTraversalGroup(
226
+ // The anchor is a zero-size sibling so it never affects the sidebar's
227
+ // layout; it exists only to hold the initial keyboard focus (see above).
228
+ child: Stack(
229
+ children: [
230
+ widget.child,
231
+ Focus(focusNode: _anchor, child: const SizedBox.shrink()),
232
+ ],
233
+ ),
222
234
  );
223
235
  }
224
236
  }
@@ -27,12 +27,20 @@ class KasyFocusRing extends StatefulWidget {
27
27
  /// When false the ring never takes focus or paints (disabled controls).
28
28
  final bool enabled;
29
29
 
30
+ /// Colour of the hair-line gap between the control and the ring. Defaults to
31
+ /// [KasyColors.background] (correct on full-page backgrounds). Pass the actual
32
+ /// surface colour the control sits on (e.g. the sidebar/header `surface`) so
33
+ /// the gap blends in instead of painting a visible halo — this matters in dark
34
+ /// mode, where `background` is darker than `surface`.
35
+ final Color? gapColor;
36
+
30
37
  const KasyFocusRing({
31
38
  super.key,
32
39
  required this.child,
33
40
  required this.borderRadius,
34
41
  this.onActivate,
35
42
  this.enabled = true,
43
+ this.gapColor,
36
44
  });
37
45
 
38
46
  @override
@@ -57,7 +65,7 @@ class _KasyFocusRingState extends State<KasyFocusRing> {
57
65
  // A hair-line gap in the canvas colour lifts the ring just off the control
58
66
  // so it reads as a crisp outline (same focus language as KasyTextField) and
59
67
  // stays visible even on primary-coloured buttons (e.g. the chat send orb).
60
- final Color gapColor = context.colors.background;
68
+ final Color gapColor = widget.gapColor ?? context.colors.background;
61
69
  final VoidCallback? onActivate = widget.onActivate;
62
70
 
63
71
  return FocusableActionDetector(
@@ -50,6 +50,7 @@ class KasyHover extends ConsumerStatefulWidget {
50
50
  this.hoverColor,
51
51
  this.pressColor,
52
52
  this.focusable = false,
53
+ this.focusGapColor,
53
54
  });
54
55
 
55
56
  final Widget child;
@@ -112,6 +113,12 @@ class KasyHover extends ConsumerStatefulWidget {
112
113
  /// call sites stay plain, non-focusable rows.
113
114
  final bool focusable;
114
115
 
116
+ /// Gap colour forwarded to the focus ring (only used when [focusable]). Pass
117
+ /// the surface colour the row sits on so the keyboard ring's hair-line gap
118
+ /// blends in instead of showing a darker halo (notably in dark mode). When
119
+ /// null the ring falls back to [KasyColors.background].
120
+ final Color? focusGapColor;
121
+
115
122
  @override
116
123
  ConsumerState<KasyHover> createState() => _KasyHoverState();
117
124
  }
@@ -209,6 +216,7 @@ class _KasyHoverState extends ConsumerState<KasyHover> {
209
216
  ? KasyFocusRing(
210
217
  borderRadius: widget.borderRadius,
211
218
  onActivate: widget.onTap,
219
+ gapColor: widget.focusGapColor,
212
220
  child: content,
213
221
  )
214
222
  : content;