kasy-cli 1.22.0 → 1.23.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.
@@ -5,6 +5,7 @@ const ui = require('./ui');
5
5
  const { createTranslator, detectDefaultLanguage } = require('./i18n');
6
6
  const { augmentedEnv, pubCacheBin } = require('./env-tools');
7
7
  const { promptOpenBrowser } = require('./browser');
8
+ const { installFlutterWindows } = require('./flutter-install');
8
9
 
9
10
  const execAsync = promisify(exec);
10
11
 
@@ -66,6 +67,12 @@ const BASE_CHECKS = [
66
67
  minVersion: MIN_FLUTTER_VERSION,
67
68
  installGuide: () => getInstallGuide('flutter'),
68
69
  confirmInstall: true,
70
+ // Windows has no clean winget path for Flutter, so we run a custom installer
71
+ // (download SDK zip + Git) instead of a package-manager command. macOS/Linux
72
+ // keep using installGuide().cmd (brew). See flutter-install.js.
73
+ installFn: process.platform === 'win32' ? installFlutterWindows : null,
74
+ installFnDescKey: 'checks.install.flutterWinDesc',
75
+ installFnNoteKey: 'checks.install.flutterWinNote',
69
76
  },
70
77
  {
71
78
  name: 'Dart SDK',
@@ -268,16 +275,25 @@ async function recoverCheckInteractively(check, t) {
268
275
  // The step list already flagged this tool as missing; go straight to fixing it.
269
276
  const guide = typeof check.installGuide === 'function' ? check.installGuide() : null;
270
277
 
271
- // 1) Offer auto-install for heavy tools that have a package-manager command.
272
- if (guide && guide.cmd && check.confirmInstall) {
278
+ // 1) Offer auto-install for heavy tools. Most use a package-manager command
279
+ // (brew/winget); Flutter on Windows uses a custom installer (check.installFn)
280
+ // because winget ships no stable Flutter package.
281
+ if (check.confirmInstall && (check.installFn || (guide && guide.cmd))) {
282
+ const cmdLabel = check.installFn
283
+ ? (check.installFnDescKey ? t(check.installFnDescKey) : check.name)
284
+ : guide.cmd;
273
285
  const doInstall = await ui.confirm({
274
- message: t('checks.install.confirm', { name: check.name, cmd: guide.cmd }),
286
+ message: t('checks.install.confirm', { name: check.name, cmd: cmdLabel }),
275
287
  initialValue: true,
276
288
  });
277
289
  if (doInstall) {
290
+ // Heads-up for the custom installer: it's a big download and may pop a UAC.
291
+ if (check.installFn && check.installFnNoteKey) ui.log.info(t(check.installFnNoteKey));
278
292
  const spinner = ui.timedSpinner();
279
293
  spinner.start(t('checks.install.running', { name: check.name }));
280
- const installed = await execTool(guide.cmd, INSTALL_TIMEOUT);
294
+ const installed = check.installFn
295
+ ? await check.installFn({})
296
+ : await execTool(guide.cmd, INSTALL_TIMEOUT);
281
297
  spinner.stop(t('checks.install.running', { name: check.name }));
282
298
  if (installed.ok && (await revalidate(check, t))) return true;
283
299
  ui.log.warn(t('checks.install.failedManual', { name: check.name }));
@@ -105,6 +105,13 @@ function extraToolDirs() {
105
105
  process.env.LOCALAPPDATA && path.win32.join(process.env.LOCALAPPDATA, 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin'),
106
106
  process.env.ProgramFiles && path.win32.join(process.env.ProgramFiles, 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin'),
107
107
  process.env['ProgramFiles(x86)'] && path.win32.join(process.env['ProgramFiles(x86)'], 'Google', 'Cloud SDK', 'google-cloud-sdk', 'bin'),
108
+ // Flutter SDK auto-installed by `kasy new` (archive unzipped into %LOCALAPPDATA%\flutter).
109
+ // Putting its bin here lets the same run's flutter pub get / build_runner /
110
+ // flutterfire find both `flutter` and `dart` without a new terminal.
111
+ process.env.LOCALAPPDATA && path.win32.join(process.env.LOCALAPPDATA, 'flutter', 'bin'),
112
+ // Git (a Flutter prerequisite) installed via winget Git.Git — default locations.
113
+ process.env.ProgramFiles && path.win32.join(process.env.ProgramFiles, 'Git', 'cmd'),
114
+ process.env.LOCALAPPDATA && path.win32.join(process.env.LOCALAPPDATA, 'Programs', 'Git', 'cmd'),
108
115
  ].filter(Boolean);
109
116
  return candidates.filter((dir) => fs.existsSync(dir));
110
117
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * Auto-install the Flutter SDK on Windows, the only OS where there is no clean
3
+ * package-manager path (winget ships no stable Flutter package — the official
4
+ * route is the SDK archive). We mirror what the Flutter docs tell a human to do,
5
+ * but do it for the user so `kasy new` can prepare a bare machine end to end:
6
+ *
7
+ * 1. Install Git via winget if missing (Flutter refuses to run without it).
8
+ * 2. Download the current stable Flutter SDK zip and unzip it into
9
+ * %LOCALAPPDATA%\flutter (a per-user dir that never needs admin rights).
10
+ * 3. Persist %LOCALAPPDATA%\flutter\bin on the User PATH for future terminals.
11
+ *
12
+ * The freshly-installed dirs are added to `augmentedEnv()` (see env-tools), so
13
+ * the rest of THIS `kasy new` run — flutter pub get, build_runner, flutterfire —
14
+ * finds the SDK immediately, without the user opening a new terminal.
15
+ *
16
+ * macOS/Linux don't need this: there Flutter installs via `brew install --cask
17
+ * flutter` (handled by the generic install path in checks.js).
18
+ */
19
+ const { exec } = require('node:child_process');
20
+ const path = require('node:path');
21
+
22
+ const isWindows = process.platform === 'win32';
23
+
24
+ // Where the SDK archive is unzipped. Must stay in sync with env-tools
25
+ // extraToolDirs() so the recheck and the build steps look in the same place.
26
+ function flutterWinHome() {
27
+ const base =
28
+ process.env.LOCALAPPDATA ||
29
+ path.win32.join(process.env.USERPROFILE || '', 'AppData', 'Local');
30
+ return path.win32.join(base, 'flutter');
31
+ }
32
+
33
+ // The PowerShell that does the actual work. Kept as a script (not a one-liner)
34
+ // because it has to download ~1 GB, unzip it, and edit the persistent PATH —
35
+ // none of which fits a single shell command cleanly.
36
+ const PS_SCRIPT = `
37
+ $ErrorActionPreference = 'Stop'
38
+ # Silencing the progress bar makes Invoke-WebRequest dramatically faster and
39
+ # keeps our spinner the only thing the user sees.
40
+ $ProgressPreference = 'SilentlyContinue'
41
+ [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
42
+
43
+ $dest = Join-Path $env:LOCALAPPDATA 'flutter'
44
+ $bin = Join-Path $dest 'bin'
45
+
46
+ # 1. Git — Flutter uses it internally and won't run without it.
47
+ if (-not (Get-Command git -ErrorAction SilentlyContinue)) {
48
+ if (Get-Command winget -ErrorAction SilentlyContinue) {
49
+ winget install --id Git.Git -e --source winget --silent --accept-source-agreements --accept-package-agreements --disable-interactivity | Out-Null
50
+ }
51
+ }
52
+
53
+ # 2. Flutter SDK — download + unzip, unless it's already there.
54
+ if (-not (Test-Path (Join-Path $bin 'flutter.bat'))) {
55
+ if (Test-Path $dest) { Remove-Item -Recurse -Force $dest }
56
+ $json = Invoke-RestMethod 'https://storage.googleapis.com/flutter_infra_release/releases/releases_windows.json'
57
+ $hash = $json.current_release.stable
58
+ $rel = $json.releases | Where-Object { $_.hash -eq $hash } | Select-Object -First 1
59
+ if (-not $rel) { throw 'Could not find the current stable Flutter release.' }
60
+ $url = $json.base_url + '/' + $rel.archive
61
+ $zip = Join-Path $env:TEMP ('flutter_sdk_' + $rel.version + '.zip')
62
+ Invoke-WebRequest -Uri $url -OutFile $zip
63
+ # ZipFile.ExtractToDirectory is much faster than Expand-Archive for a SDK-sized
64
+ # archive. The zip contains a top-level 'flutter\\' folder, so extracting into
65
+ # %LOCALAPPDATA% yields exactly %LOCALAPPDATA%\flutter.
66
+ Add-Type -AssemblyName System.IO.Compression.FileSystem
67
+ [System.IO.Compression.ZipFile]::ExtractToDirectory($zip, $env:LOCALAPPDATA)
68
+ Remove-Item $zip -Force -ErrorAction SilentlyContinue
69
+ }
70
+
71
+ # 3. Persist flutter\\bin on the User PATH so every future terminal finds it.
72
+ $userPath = [Environment]::GetEnvironmentVariable('Path', 'User')
73
+ if (-not $userPath) { $userPath = '' }
74
+ if (($userPath -split ';') -notcontains $bin) {
75
+ $newPath = if ($userPath.Length -gt 0) { $userPath.TrimEnd(';') + ';' + $bin } else { $bin }
76
+ [Environment]::SetEnvironmentVariable('Path', $newPath, 'User')
77
+ }
78
+
79
+ Write-Output ('FLUTTER_BIN=' + $bin)
80
+ `;
81
+
82
+ /**
83
+ * Run the installer. Resolves to the same shape as checks.execTool
84
+ * ({ ok, stdout, stderr, error }) so the caller can treat it uniformly.
85
+ *
86
+ * @param {{ timeout?: number }} [opts]
87
+ * @returns {Promise<{ ok: boolean, stdout: string, stderr: string, error: string|null }>}
88
+ */
89
+ function installFlutterWindows({ timeout = 1_800_000 } = {}) {
90
+ return new Promise((resolve) => {
91
+ if (!isWindows) {
92
+ resolve({ ok: false, stdout: '', stderr: '', error: 'not windows' });
93
+ return;
94
+ }
95
+ // -EncodedCommand (UTF-16LE base64) sidesteps every quoting headache of
96
+ // passing a multi-line script through a shell.
97
+ const encoded = Buffer.from(PS_SCRIPT, 'utf16le').toString('base64');
98
+ const cmd = `powershell -NoProfile -ExecutionPolicy Bypass -EncodedCommand ${encoded}`;
99
+ exec(
100
+ cmd,
101
+ { timeout, maxBuffer: 50 * 1024 * 1024, windowsHide: true },
102
+ (error, stdout, stderr) => {
103
+ resolve({
104
+ ok: !error,
105
+ stdout: stdout || '',
106
+ stderr: stderr || '',
107
+ error: error ? error.message : null,
108
+ });
109
+ }
110
+ );
111
+ });
112
+ }
113
+
114
+ module.exports = { installFlutterWindows, flutterWinHome };
@@ -134,6 +134,8 @@ module.exports = {
134
134
  'checks.thenRun': 'Then run',
135
135
  'checks.recheck': 'After installing {name}, press Enter to check again',
136
136
  'checks.install.confirm': 'Install {name} now? ({cmd})',
137
+ 'checks.install.flutterWinDesc': 'downloads the official Flutter SDK + Git, ~1.8 GB',
138
+ 'checks.install.flutterWinNote': 'This can take a few minutes (large download). If a Windows approval popup appears, click Yes.',
137
139
  'checks.install.running': 'Installing {name}…',
138
140
  'checks.install.failedManual': 'Could not install {name} automatically. Do it manually:',
139
141
  'checks.install.openDocs': 'Open the install page in the browser?',
@@ -134,6 +134,8 @@ module.exports = {
134
134
  'checks.thenRun': 'Luego ejecuta',
135
135
  'checks.recheck': 'Tras instalar {name}, presiona Enter para verificar de nuevo',
136
136
  'checks.install.confirm': '¿Instalar {name} ahora? ({cmd})',
137
+ 'checks.install.flutterWinDesc': 'descarga el SDK oficial de Flutter + Git, ~1,8 GB',
138
+ 'checks.install.flutterWinNote': 'Esto puede tardar unos minutos (descarga grande). Si aparece una ventana de permiso de Windows, haz clic en Sí.',
137
139
  'checks.install.running': 'Instalando {name}…',
138
140
  'checks.install.failedManual': 'No pude instalar {name} automáticamente. Hazlo manualmente:',
139
141
  'checks.install.openDocs': '¿Abrir la página de instalación en el navegador?',
@@ -134,6 +134,8 @@ module.exports = {
134
134
  'checks.thenRun': 'Depois rode',
135
135
  'checks.recheck': 'Após instalar {name}, pressione Enter para verificar novamente',
136
136
  'checks.install.confirm': 'Instalar {name} agora? ({cmd})',
137
+ 'checks.install.flutterWinDesc': 'baixa o SDK oficial do Flutter + Git, ~1,8 GB',
138
+ 'checks.install.flutterWinNote': 'Pode levar alguns minutos (download grande). Se aparecer um popup do Windows pedindo permissão, clique em Sim.',
137
139
  'checks.install.running': 'Instalando {name}…',
138
140
  'checks.install.failedManual': 'Não consegui instalar {name} automaticamente. Faça manualmente:',
139
141
  'checks.install.openDocs': 'Abrir a página de instalação no navegador?',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kasy-cli",
3
- "version": "1.22.0",
3
+ "version": "1.23.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"
@@ -308,8 +308,15 @@ class _KasySidebarState extends State<KasySidebar> {
308
308
  setState(() => _userChoseCollapsed = !_userChoseCollapsed);
309
309
 
310
310
  /// Navigates to a real route via Bart and clears any static-item highlight.
311
+ /// Moving to another screen also collapses an open submenu (e.g. Income) and
312
+ /// drops its selected sub-item, so the sidebar always reflects the active
313
+ /// screen rather than a left-over expanded menu.
311
314
  void _navigateTo(int index) {
312
- setState(() => _activeItemId = '');
315
+ setState(() {
316
+ _activeItemId = '';
317
+ _incomeExpanded = false;
318
+ _activeSubItem = '';
319
+ });
313
320
  widget.onTapItem!(index);
314
321
  }
315
322
 
@@ -738,6 +745,7 @@ class _KasySidebarState extends State<KasySidebar> {
738
745
  borderRadius: BorderRadius.circular(_kItemRadius),
739
746
  hoverColor: c.activeBg,
740
747
  pressColor: c.textActive,
748
+ focusable: true,
741
749
  onTap: widget.onProfileTap ?? () {},
742
750
  child: Padding(
743
751
  padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
@@ -868,6 +876,7 @@ class _KasySidebarState extends State<KasySidebar> {
868
876
  borderRadius: BorderRadius.circular(_kItemRadius),
869
877
  hoverColor: c.activeBg,
870
878
  pressColor: c.textActive,
879
+ focusable: true,
871
880
  onTap: onTap,
872
881
  child: Container(
873
882
  constraints: const BoxConstraints(minHeight: _kItemMinH),
@@ -937,6 +946,7 @@ class _KasySidebarState extends State<KasySidebar> {
937
946
  borderRadius: BorderRadius.circular(_kItemRadius),
938
947
  hoverColor: c.activeBg,
939
948
  pressColor: c.textActive,
949
+ focusable: true,
940
950
  onTap: () => _activateItem(item.id),
941
951
  child: Container(
942
952
  constraints: const BoxConstraints(minHeight: _kItemMinH),
@@ -38,6 +38,59 @@ class BottomMenu extends StatelessWidget {
38
38
  extendBody: true,
39
39
  );
40
40
 
41
+ // Desktop/tablet sidebar, marked as the FIRST stop in the keyboard Tab
42
+ // order. Together with the header/content orders set in WebContentWrapper
43
+ // and the OrderedTraversalPolicy below, every screen tabs the same way:
44
+ // sidebar → header → content. (Touch/mobile has no Tab, so the `small`
45
+ // layout below is left untouched.)
46
+ bart.CustomSideBarOptions connectedSidebar() => bart.CustomSideBarOptions(
47
+ sideBarBuilder: (routes, onTap, current) => FocusTraversalOrder(
48
+ order: const NumericFocusOrder(1),
49
+ child: _FocusableSidebar(
50
+ currentItem: current,
51
+ child: Consumer(
52
+ builder: (context, ref, _) {
53
+ final User user =
54
+ ref.watch(userStateNotifierProvider).user;
55
+ final (String name, String email) = switch (user) {
56
+ final AuthenticatedUserData u => (
57
+ (u.name?.isNotEmpty ?? false)
58
+ ? u.name!
59
+ : u.email.split('@').first,
60
+ u.email,
61
+ ),
62
+ _ => (context.t.settings.my_account, ''),
63
+ };
64
+ return KasySidebar(
65
+ routes: routes,
66
+ onTapItem: onTap,
67
+ currentItem: current,
68
+ onLogout: () => confirmLogout(context, ref),
69
+ profileName: name,
70
+ profileEmail: email,
71
+ profileAvatar: const KasyUserAvatar(),
72
+ );
73
+ },
74
+ ),
75
+ ),
76
+ ),
77
+ );
78
+
79
+ // medium and large are identical here — the sidebar collapses itself by
80
+ // viewport width. The OrderedTraversalPolicy turns the numbered orders
81
+ // (sidebar=1, header=2, content=3) into the actual Tab sequence.
82
+ Widget connectedScaffold() => FocusTraversalGroup(
83
+ policy: OrderedTraversalPolicy(),
84
+ child: bart.BartScaffold(
85
+ routesBuilder: subRoutes,
86
+ bottomBar: kasyPaddedSurfaceBottomBar(),
87
+ initialRoute: resolvedInitialRoute,
88
+ showBottomBarOnStart: showBottomBarOnStart,
89
+ scaffoldOptions: scaffoldOptions,
90
+ sideBarOptions: connectedSidebar(),
91
+ ),
92
+ );
93
+
41
94
  return AnnotatedRegion<SystemUiOverlayStyle>(
42
95
  value: switch (Theme.brightnessOf(context)) {
43
96
  Brightness.dark => SystemUiOverlayStyle.light,
@@ -51,72 +104,8 @@ class BottomMenu extends StatelessWidget {
51
104
  showBottomBarOnStart: showBottomBarOnStart,
52
105
  scaffoldOptions: scaffoldOptions,
53
106
  ),
54
- // medium (768–1024 px): KasySidebar auto-collapses at this width
55
- medium: bart.BartScaffold(
56
- routesBuilder: subRoutes,
57
- bottomBar: kasyPaddedSurfaceBottomBar(),
58
- initialRoute: resolvedInitialRoute,
59
- showBottomBarOnStart: showBottomBarOnStart,
60
- scaffoldOptions: scaffoldOptions,
61
- sideBarOptions: bart.CustomSideBarOptions(
62
- sideBarBuilder: (routes, onTap, current) => Consumer(
63
- builder: (context, ref, _) {
64
- final User user = ref.watch(userStateNotifierProvider).user;
65
- final (String name, String email) = switch (user) {
66
- final AuthenticatedUserData u => (
67
- (u.name?.isNotEmpty ?? false)
68
- ? u.name!
69
- : u.email.split('@').first,
70
- u.email,
71
- ),
72
- _ => (context.t.settings.my_account, ''),
73
- };
74
- return KasySidebar(
75
- routes: routes,
76
- onTapItem: onTap,
77
- currentItem: current,
78
- onLogout: () => confirmLogout(context, ref),
79
- profileName: name,
80
- profileEmail: email,
81
- profileAvatar: const KasyUserAvatar(),
82
- );
83
- },
84
- ),
85
- ),
86
- ),
87
- // large (1024 px+): full expanded sidebar
88
- large: bart.BartScaffold(
89
- routesBuilder: subRoutes,
90
- bottomBar: kasyPaddedSurfaceBottomBar(),
91
- initialRoute: resolvedInitialRoute,
92
- showBottomBarOnStart: showBottomBarOnStart,
93
- scaffoldOptions: scaffoldOptions,
94
- sideBarOptions: bart.CustomSideBarOptions(
95
- sideBarBuilder: (routes, onTap, current) => Consumer(
96
- builder: (context, ref, _) {
97
- final User user = ref.watch(userStateNotifierProvider).user;
98
- final (String name, String email) = switch (user) {
99
- final AuthenticatedUserData u => (
100
- (u.name?.isNotEmpty ?? false)
101
- ? u.name!
102
- : u.email.split('@').first,
103
- u.email,
104
- ),
105
- _ => (context.t.settings.my_account, ''),
106
- };
107
- return KasySidebar(
108
- routes: routes,
109
- onTapItem: onTap,
110
- currentItem: current,
111
- onLogout: () => confirmLogout(context, ref),
112
- profileName: name,
113
- profileEmail: email,
114
- profileAvatar: const KasyUserAvatar(),
115
- );
116
- },
117
- ),
118
- ),
119
- ),
107
+ medium: connectedScaffold(),
108
+ large: connectedScaffold(),
120
109
  ),
121
110
  );
122
111
  }
@@ -162,3 +151,74 @@ class BottomMenu extends StatelessWidget {
162
151
  return segments.length < 2;
163
152
  }
164
153
  }
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.
157
+ ///
158
+ /// Why this exists: Bart renders each page inside a nested [Navigator]
159
+ /// (see bart's nested_navigator.dart), which has its OWN FocusScope and claims
160
+ /// the primary focus the moment a route mounts. A plain `autofocus` on a sidebar
161
+ /// item loses that race — the Navigator overwrites it in the same frame.
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.
169
+ class _FocusableSidebar extends StatefulWidget {
170
+ final Widget child;
171
+ final ValueNotifier<int> currentItem;
172
+
173
+ const _FocusableSidebar({required this.child, required this.currentItem});
174
+
175
+ @override
176
+ State<_FocusableSidebar> createState() => _FocusableSidebarState();
177
+ }
178
+
179
+ class _FocusableSidebarState extends State<_FocusableSidebar> {
180
+ final FocusScopeNode _scope = FocusScopeNode(debugLabel: 'sidebarScope');
181
+
182
+ @override
183
+ void initState() {
184
+ super.initState();
185
+ widget.currentItem.addListener(_anchorFocus);
186
+ _anchorFocus();
187
+ }
188
+
189
+ @override
190
+ void didUpdateWidget(_FocusableSidebar oldWidget) {
191
+ super.didUpdateWidget(oldWidget);
192
+ if (oldWidget.currentItem != widget.currentItem) {
193
+ oldWidget.currentItem.removeListener(_anchorFocus);
194
+ widget.currentItem.addListener(_anchorFocus);
195
+ }
196
+ }
197
+
198
+ // Defer to after the frame so we win the race against the nested Navigator,
199
+ // which claims focus for its own scope while the route is mounting.
200
+ void _anchorFocus() {
201
+ 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();
207
+ });
208
+ }
209
+
210
+ @override
211
+ void dispose() {
212
+ widget.currentItem.removeListener(_anchorFocus);
213
+ _scope.dispose();
214
+ super.dispose();
215
+ }
216
+
217
+ @override
218
+ Widget build(BuildContext context) {
219
+ return FocusScope(
220
+ node: _scope,
221
+ child: FocusTraversalGroup(child: widget.child),
222
+ );
223
+ }
224
+ }
@@ -20,6 +20,9 @@ class WebContentWrapper extends StatelessWidget {
20
20
  MediaQuery.sizeOf(context).width >= DeviceType.large.breakpoint;
21
21
  if (!isDesktop) return child;
22
22
 
23
+ // Keyboard Tab order across the whole app: sidebar (1, set in BottomMenu) →
24
+ // header (2) → content (3). Each block is its own FocusTraversalGroup so its
25
+ // internal order stays natural (e.g. header: search → theme → … → create).
23
26
  return ColoredBox(
24
27
  color: context.colors.background,
25
28
  child: Column(
@@ -27,13 +30,23 @@ class WebContentWrapper extends StatelessWidget {
27
30
  children: [
28
31
  // The sidebar carries the user profile here, so the header drops its
29
32
  // avatar (showAvatar: false) to avoid duplicating the account chip.
30
- KasyWebHeader(
31
- onToggleTheme: () => ThemeProvider.of(context).toggle(),
32
- onNotifications: () {},
33
- onCreate: () {},
34
- showAvatar: false,
33
+ FocusTraversalOrder(
34
+ order: const NumericFocusOrder(2),
35
+ child: FocusTraversalGroup(
36
+ child: KasyWebHeader(
37
+ onToggleTheme: () => ThemeProvider.of(context).toggle(),
38
+ onNotifications: () {},
39
+ onCreate: () {},
40
+ showAvatar: false,
41
+ ),
42
+ ),
43
+ ),
44
+ Expanded(
45
+ child: FocusTraversalOrder(
46
+ order: const NumericFocusOrder(3),
47
+ child: FocusTraversalGroup(child: child),
48
+ ),
35
49
  ),
36
- Expanded(child: child),
37
50
  ],
38
51
  ),
39
52
  );
@@ -6,6 +6,7 @@ import 'package:flutter/services.dart';
6
6
  import 'package:flutter_riverpod/flutter_riverpod.dart';
7
7
  import 'package:kasy_kit/core/haptics/haptic_feedback_notifier.dart';
8
8
  import 'package:kasy_kit/core/theme/theme.dart';
9
+ import 'package:kasy_kit/core/widgets/kasy_focus_ring.dart';
9
10
 
10
11
  /// Universal hover/press wrapper — works on any widget shape, any platform.
11
12
  ///
@@ -48,6 +49,7 @@ class KasyHover extends ConsumerStatefulWidget {
48
49
  this.pressEnabled = true,
49
50
  this.hoverColor,
50
51
  this.pressColor,
52
+ this.focusable = false,
51
53
  });
52
54
 
53
55
  final Widget child;
@@ -103,6 +105,13 @@ class KasyHover extends ConsumerStatefulWidget {
103
105
  /// background is already tinted so the feedback stays on-palette.
104
106
  final Color? pressColor;
105
107
 
108
+ /// When true, the control becomes a keyboard tab-stop by wrapping its visual
109
+ /// in the kit's [KasyFocusRing]: a focus ring appears during keyboard
110
+ /// navigation (never on pointer/touch) and Enter/Space triggers [onTap].
111
+ /// Pointer and touch behaviour are unchanged. Defaults to false so existing
112
+ /// call sites stay plain, non-focusable rows.
113
+ final bool focusable;
114
+
106
115
  @override
107
116
  ConsumerState<KasyHover> createState() => _KasyHoverState();
108
117
  }
@@ -194,13 +203,23 @@ class _KasyHoverState extends ConsumerState<KasyHover> {
194
203
  child: widget.child,
195
204
  );
196
205
 
206
+ // Keyboard focus is owned by the kit's single focus indicator so Tab +
207
+ // Enter/Space behave identically to login, signup and the chat send button.
208
+ final Widget focusContent = widget.focusable
209
+ ? KasyFocusRing(
210
+ borderRadius: widget.borderRadius,
211
+ onActivate: widget.onTap,
212
+ child: content,
213
+ )
214
+ : content;
215
+
197
216
  Widget interactive = GestureDetector(
198
217
  behavior: HitTestBehavior.opaque,
199
218
  onTap: _handleTap,
200
219
  onTapDown: _onTapDown,
201
220
  onTapUp: _onTapUp,
202
221
  onTapCancel: _onTapCancel,
203
- child: content,
222
+ child: focusContent,
204
223
  );
205
224
 
206
225
  // MouseRegion is active on all platforms:
@@ -5,6 +5,7 @@ import 'package:kasy_kit/components/kasy_card.dart';
5
5
  import 'package:kasy_kit/core/theme/theme.dart';
6
6
  import 'package:kasy_kit/core/widgets/kasy_brand_logo.dart';
7
7
  import 'package:kasy_kit/core/widgets/page_background.dart';
8
+ import 'package:kasy_kit/core/widgets/responsive_layout.dart';
8
9
  import 'package:kasy_kit/features/authentication/ui/widgets/auth_page_back_button.dart';
9
10
 
10
11
  /// Premium shell shared by the auth screens (sign in / sign up / recover).
@@ -54,6 +55,49 @@ class AuthCardScaffold extends StatelessWidget {
54
55
  builder: (context, constraints) {
55
56
  final double minHeight = (constraints.maxHeight - padding.vertical)
56
57
  .clamp(0.0, double.infinity);
58
+ // On the mobile breakpoint (small, < 768) the card container is
59
+ // dropped: the form sits directly over the page background,
60
+ // edge‑to‑edge minus the page gutter. The elevated card stays on
61
+ // tablet/desktop (medium and up).
62
+ final bool isMobile =
63
+ DeviceType.fromWidth(constraints.maxWidth) == DeviceType.small;
64
+ final Widget content = Column(
65
+ crossAxisAlignment: CrossAxisAlignment.stretch,
66
+ mainAxisSize: MainAxisSize.min,
67
+ children: [
68
+ if (showLogo) ...[
69
+ Center(
70
+ child: KasyBrandLogo(height: logoHeight)
71
+ .animate()
72
+ .fadeIn(
73
+ duration: const Duration(milliseconds: 420),
74
+ curve: Curves.easeOut,
75
+ ),
76
+ ),
77
+ const SizedBox(height: KasySpacing.lg),
78
+ ],
79
+ Text(
80
+ title,
81
+ textAlign: TextAlign.center,
82
+ style: context.textTheme.headlineSmall?.copyWith(
83
+ fontWeight: FontWeight.w700,
84
+ ),
85
+ ),
86
+ const SizedBox(height: KasySpacing.xs),
87
+ Text(
88
+ subtitle,
89
+ textAlign: TextAlign.center,
90
+ style: context.textTheme.bodyMedium?.copyWith(
91
+ color: context.colors.onSurface.withValues(
92
+ alpha: 0.62,
93
+ ),
94
+ fontWeight: FontWeight.w500,
95
+ ),
96
+ ),
97
+ const SizedBox(height: KasySpacing.xl),
98
+ ...children,
99
+ ],
100
+ );
57
101
  return SingleChildScrollView(
58
102
  physics: const ClampingScrollPhysics(),
59
103
  keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
@@ -64,49 +108,15 @@ class AuthCardScaffold extends StatelessWidget {
64
108
  alignment: kIsWeb ? Alignment.center : Alignment.topCenter,
65
109
  child: ConstrainedBox(
66
110
  constraints: BoxConstraints(maxWidth: maxContentWidth),
67
- child: KasyCard(
68
- padding: const EdgeInsets.symmetric(
69
- horizontal: KasySpacing.lg,
70
- vertical: KasySpacing.xl,
71
- ),
72
- child: Column(
73
- crossAxisAlignment: CrossAxisAlignment.stretch,
74
- mainAxisSize: MainAxisSize.min,
75
- children: [
76
- if (showLogo) ...[
77
- Center(
78
- child: KasyBrandLogo(height: logoHeight)
79
- .animate()
80
- .fadeIn(
81
- duration: const Duration(milliseconds: 420),
82
- curve: Curves.easeOut,
83
- ),
84
- ),
85
- const SizedBox(height: KasySpacing.lg),
86
- ],
87
- Text(
88
- title,
89
- textAlign: TextAlign.center,
90
- style: context.textTheme.headlineSmall?.copyWith(
91
- fontWeight: FontWeight.w700,
111
+ child: isMobile
112
+ ? content
113
+ : KasyCard(
114
+ padding: const EdgeInsets.symmetric(
115
+ horizontal: KasySpacing.lg,
116
+ vertical: KasySpacing.xl,
92
117
  ),
118
+ child: content,
93
119
  ),
94
- const SizedBox(height: KasySpacing.xs),
95
- Text(
96
- subtitle,
97
- textAlign: TextAlign.center,
98
- style: context.textTheme.bodyMedium?.copyWith(
99
- color: context.colors.onSurface.withValues(
100
- alpha: 0.62,
101
- ),
102
- fontWeight: FontWeight.w500,
103
- ),
104
- ),
105
- const SizedBox(height: KasySpacing.xl),
106
- ...children,
107
- ],
108
- ),
109
- ),
110
120
  ),
111
121
  ),
112
122
  ),
@@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
16
16
  # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
17
17
  # In Windows, build-name is used as the major, minor, and patch parts
18
18
  # of the product and file versions while build-number is used as the build suffix.
19
- version: 1.0.0+33
19
+ version: 1.0.0+34
20
20
 
21
21
  environment:
22
22
  sdk: ^3.11.0