From 0257b5120be77f1ca43684b0e5b2289840589e28 Mon Sep 17 00:00:00 2001 From: ehsan shariati Date: Mon, 24 Aug 2026 08:15:19 -0400 Subject: [PATCH] Generation steps: drop the boxes, show the three real passes 1. NO MORE BOUNDING BOXES Each step was a filled, outlined card, which reads as a tappable action button and invites a tap that does nothing. StepRow gains ordered (default TRUE, so setup_unlock_sheet - where each row IS an action - is untouched) and the progress checklist passes false. 2. THE GENERATE STEP NOW SHOWS ITS SUB-STEPS They are real, not invented. ai/src/services/claudeService.ts reports each pass through the job's statusMessage: 'Designing art direction...' -> Design 'Building your website...' -> Build 'Polishing design and motion...' -> Polish Matched on the leading VERB rather than the sentence, because 'Polishing design and motion' also contains the word 'design' - a naive contains('design') reports the wrong pass for the last one. The legacy single-pass path says 'Generating website...' and maps to null, which renders NO sub-steps rather than three fake ones. Monotonic like the parent phase: a late or stale poll cannot un-tick a pass the user already watched complete, and an unrecognised status line leaves the pass alone instead of clearing it. A failure inside generation marks the pass it was actually on. A completed generation collapses them away - the detail only matters while it runs. StepRow also gains dense for the nested rows: 16px badge, 12px type, tighter padding, so the passes read as subordinate to their parent. --- lib/shared/widgets/step_row.dart | 47 ++++++++--- lib/web/screens/web_settings_screen.dart | 2 +- .../screens/web_website_detail_screen.dart | 45 ++++++++-- lib/web/services/web_generation_steps.dart | 83 +++++++++++++++++++ lib/web/services/web_website_service.dart | 13 +++ test/unit/web/web_generation_steps_test.dart | 78 +++++++++++++++++ 6 files changed, 245 insertions(+), 23 deletions(-) diff --git a/lib/shared/widgets/step_row.dart b/lib/shared/widgets/step_row.dart index f8322b6..9311a8c 100644 --- a/lib/shared/widgets/step_row.dart +++ b/lib/shared/widgets/step_row.dart @@ -18,6 +18,17 @@ class StepRow extends StatelessWidget { final Widget? expanded; final bool optional; + /// Draw the filled, outlined card around the row. + /// + /// True suits `setup_unlock_sheet`, where each row IS a tappable action. + /// Set false for a pure PROGRESS list: there the boxes read as buttons + /// and invite a tap that does nothing. + final bool bordered; + + /// Compact scale for nested rows (the generation passes under + /// "Generate site"): smaller badge and type, tighter padding. + final bool dense; + const StepRow({ super.key, required this.state, @@ -29,6 +40,8 @@ class StepRow extends StatelessWidget { this.onCta, this.expanded, this.optional = false, + this.bordered = true, + this.dense = false, }); @override @@ -53,24 +66,29 @@ class StepRow extends StatelessWidget { : theme.dividerColor.withValues(alpha: 0.4); return Container( - decoration: BoxDecoration( - color: bg, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: borderColor, width: isActive ? 1.5 : 1), - ), + decoration: bordered + ? BoxDecoration( + color: bg, + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: borderColor, width: isActive ? 1.5 : 1), + ) + : null, child: Material( color: Colors.transparent, child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(12), child: Padding( - padding: const EdgeInsets.all(14), + padding: bordered + ? const EdgeInsets.all(14) + : EdgeInsets.symmetric(vertical: dense ? 3 : 5), child: Column( children: [ Row( children: [ _badge(isDone, isActive, isError, number, context), - const SizedBox(width: 12), + SizedBox(width: dense ? 8 : 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -84,7 +102,7 @@ class StepRow extends StatelessWidget { child: Text( title, style: TextStyle( - fontSize: 13, + fontSize: dense ? 12 : 13, fontWeight: isActive || isError ? FontWeight.w600 : FontWeight.w500, @@ -162,9 +180,10 @@ class StepRow extends StatelessWidget { : done || active ? AppColors.primary : Colors.transparent; + final size = dense ? 16.0 : 24.0; return Container( - width: 24, - height: 24, + width: size, + height: size, decoration: BoxDecoration( color: fill, shape: BoxShape.circle, @@ -177,16 +196,18 @@ class StepRow extends StatelessWidget { ), alignment: Alignment.center, child: error - ? const Icon(LucideIcons.x, color: Colors.white, size: 14) + ? Icon(LucideIcons.x, + color: Colors.white, size: dense ? 10 : 14) : done - ? const Icon(LucideIcons.check, color: Colors.white, size: 14) + ? Icon(LucideIcons.check, + color: Colors.white, size: dense ? 10 : 14) : Text( n ?? '', style: TextStyle( color: active ? Colors.white : Theme.of(context).colorScheme.onSurfaceVariant, - fontSize: 12, + fontSize: dense ? 9 : 12, fontWeight: FontWeight.w600, ), ), diff --git a/lib/web/screens/web_settings_screen.dart b/lib/web/screens/web_settings_screen.dart index dfe0dfc..ecac9bc 100644 --- a/lib/web/screens/web_settings_screen.dart +++ b/lib/web/screens/web_settings_screen.dart @@ -14,7 +14,7 @@ import 'package:fula_files/web/services/web_session.dart'; /// App version label shown in About + the home footer. Kept in one place /// so the two stay in sync (the home footer imports this). -const String kWebAppVersion = 'v1.11.13.0'; +const String kWebAppVersion = 'v1.11.14.0'; /// In-app web Settings page. Replaces the old behavior where the gear icon /// opened cloud.fx.land in a new tab. Mirrors the mobile Settings screen's diff --git a/lib/web/screens/web_website_detail_screen.dart b/lib/web/screens/web_website_detail_screen.dart index 8d2be50..fe01c41 100644 --- a/lib/web/screens/web_website_detail_screen.dart +++ b/lib/web/screens/web_website_detail_screen.dart @@ -1448,6 +1448,7 @@ class _StepChecklist extends StatelessWidget { errorMessage: null, uploadedAssets: g.uploadedAssets, totalAssets: g.totalAssets, + subStep: service.subStepFor(g.id), ); final rows = []; @@ -1455,26 +1456,52 @@ class _StepChecklist extends StatelessWidget { final step = steps[i]; final isUploadInFlight = i == 0 && step.state == WebsiteStepState.active && g.totalAssets > 0; - if (i > 0) rows.add(const SizedBox(height: 6)); + + // Generation passes, indented under their parent step. Rendered in + // the `expanded` slot so they move with it. + final Widget? nested = step.subSteps.isEmpty + ? null + : Padding( + padding: const EdgeInsets.only(left: 4, top: 2), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + for (final sub in step.subSteps) + StepRow( + state: _rowState(sub.state), + title: sub.title, + bordered: false, + dense: true, + ), + ], + ), + ); + rows.add(StepRow( - state: switch (step.state) { - WebsiteStepState.pending => StepRowState.pending, - WebsiteStepState.active => StepRowState.active, - WebsiteStepState.done => StepRowState.done, - WebsiteStepState.failed => StepRowState.error, - }, + state: _rowState(step.state), number: '${i + 1}', title: step.title, subtitle: step.subtitle, + // Borderless: this is a progress list, not a menu. The boxed + // variant reads as tappable buttons and invites a tap that does + // nothing. + bordered: false, expanded: isUploadInFlight ? LinearProgressIndicator( value: g.uploadedAssets / g.totalAssets, ) - : null, + : nested, )); } - return Column(children: rows); + return Column(crossAxisAlignment: CrossAxisAlignment.start, children: rows); } + + static StepRowState _rowState(WebsiteStepState s) => switch (s) { + WebsiteStepState.pending => StepRowState.pending, + WebsiteStepState.active => StepRowState.active, + WebsiteStepState.done => StepRowState.done, + WebsiteStepState.failed => StepRowState.error, + }; } /// Click-tracking stats line for one generation — mirror of the native diff --git a/lib/web/services/web_generation_steps.dart b/lib/web/services/web_generation_steps.dart index 1e8ba39..d1c023e 100644 --- a/lib/web/services/web_generation_steps.dart +++ b/lib/web/services/web_generation_steps.dart @@ -32,6 +32,53 @@ const String kServerPhasePublishing = 'publishing'; enum WebsiteStepState { pending, active, done, failed } +/// The server's three generation passes, surfaced as sub-steps under +/// "Generate site". +/// +/// These are REAL — `claudeService.ts` reports each pass through the job's +/// `statusMessage`: +/// +/// 'Designing art direction...' -> [design] +/// 'Building your website...' -> [build] +/// 'Polishing design and motion...'-> [polish] +/// +/// The legacy single-pass path reports 'Generating website...' instead and +/// maps to null, which correctly renders no sub-steps rather than +/// inventing three. +enum WebsiteSubStep { design, build, polish } + +String websiteSubStepLabel(WebsiteSubStep s) => switch (s) { + WebsiteSubStep.design => 'Design', + WebsiteSubStep.build => 'Build', + WebsiteSubStep.polish => 'Polish', + }; + +/// Which pass a `statusMessage` describes, or null when it names none. +/// +/// Matched on the leading VERB, not the whole sentence: the wording is the +/// server's to change, and 'Polishing design and motion' also contains the +/// word "design" — so a naive `contains('design')` would report the wrong +/// pass for the last one. The verbs do not overlap. +WebsiteSubStep? subStepFromStatusMessage(String? message) { + if (message == null) return null; + final m = message.toLowerCase(); + if (m.contains('polish')) return WebsiteSubStep.polish; + if (m.contains('building')) return WebsiteSubStep.build; + if (m.contains('designing')) return WebsiteSubStep.design; + return null; +} + +/// Fold a newly-observed pass into the one already held, never going +/// backwards — same rule as [advanceServerPhase]. An unrecognised message +/// (a status line that is not a pass marker) leaves the pass untouched +/// rather than clearing it. +WebsiteSubStep? advanceSubStep(WebsiteSubStep? previous, String? message) { + final incoming = subStepFromStatusMessage(message); + if (incoming == null) return previous; + if (previous == null) return incoming; + return incoming.index >= previous.index ? incoming : previous; +} + class WebsiteStep { final String title; final WebsiteStepState state; @@ -39,10 +86,15 @@ class WebsiteStep { /// Shown under the title. Only ever populated for the current step. final String? subtitle; + /// Nested passes, currently only on "Generate site" and only once the + /// server has actually named one. Empty otherwise. + final List subSteps; + const WebsiteStep({ required this.title, required this.state, this.subtitle, + this.subSteps = const [], }); @override @@ -112,6 +164,10 @@ List buildWebsiteGenerationSteps({ String? errorMessage, int uploadedAssets = 0, int totalAssets = 0, + + /// Furthest generation pass observed. Null until the server names one, + /// which is also the legacy single-pass case — then no sub-steps show. + WebsiteSubStep? subStep, }) { final failed = status == WebsiteGenStatus.error; final completed = status == WebsiteGenStatus.completed; @@ -151,7 +207,34 @@ List buildWebsiteGenerationSteps({ title: kWebsiteStepTitles[i], state: state, subtitle: subtitle, + // Passes belong to "Generate site" (index 2) and only exist once + // the server has named one. A completed generation collapses them + // away — the detail is only interesting while it is running. + subSteps: (i == 2 && subStep != null && !completed) + ? _passSteps(subStep, parentState: state) + : const [], )); } return steps; } + +/// The three passes, resolved against the furthest one reached. +List _passSteps( + WebsiteSubStep reached, { + required WebsiteStepState parentState, +}) { + return [ + for (final pass in WebsiteSubStep.values) + WebsiteStep( + title: websiteSubStepLabel(pass), + state: pass.index < reached.index + ? WebsiteStepState.done + : pass.index == reached.index + // A failure inside the generate step failed THIS pass. + ? (parentState == WebsiteStepState.failed + ? WebsiteStepState.failed + : WebsiteStepState.active) + : WebsiteStepState.pending, + ), + ]; +} diff --git a/lib/web/services/web_website_service.dart b/lib/web/services/web_website_service.dart index 311226c..60ba4d4 100644 --- a/lib/web/services/web_website_service.dart +++ b/lib/web/services/web_website_service.dart @@ -445,6 +445,13 @@ class WebWebsiteService extends ChangeNotifier { final Map _serverPhase = {}; final Map _lastActiveStatus = {}; + /// Furthest generation PASS observed, per generation. Derived from the + /// server's `statusMessage` ('Designing…' / 'Building…' / 'Polishing…') + /// and, like the phase above, monotonic and transient. + final Map _subStep = {}; + + WebsiteSubStep? subStepFor(String generationId) => _subStep[generationId]; + /// Directory opt-in for an IN-FLIGHT generation, keyed by generation /// id. Transient for the same reason as the two maps above: it is only /// needed between `startGeneration` and the `/generate` POST, after @@ -480,6 +487,7 @@ class WebWebsiteService extends ChangeNotifier { _serverPhase.remove(generationId); _lastActiveStatus.remove(generationId); _listInDirectory.remove(generationId); + _subStep.remove(generationId); } /// Turn a completed website's public-directory listing on or off. @@ -1053,6 +1061,11 @@ class WebWebsiteService extends ChangeNotifier { advanceServerPhase(_serverPhase[generation.id], serverStatus) != _serverPhase[generation.id]; _recordServerPhase(generation.id, serverStatus); + // The three generation passes are reported through statusMessage, + // so the sub-step is folded in from the same value. + final nextSub = advanceSubStep(_subStep[generation.id], statusMsg); + if (nextSub != null) _subStep[generation.id] = nextSub; + if (statusMsg != null) { generation.statusMessage = statusMsg; generation.updatedAt = DateTime.now(); diff --git a/test/unit/web/web_generation_steps_test.dart b/test/unit/web/web_generation_steps_test.dart index 6996cfb..3485f74 100644 --- a/test/unit/web/web_generation_steps_test.dart +++ b/test/unit/web/web_generation_steps_test.dart @@ -190,6 +190,84 @@ void main() { }); }); + group('generation passes (sub-steps)', () { + // These strings come from ai/src/services/claudeService.ts, which + // reports each pass through the job's statusMessage. + test('maps the three real server pass markers', () { + expect(subStepFromStatusMessage('Designing art direction...'), + WebsiteSubStep.design); + expect(subStepFromStatusMessage('Building your website...'), + WebsiteSubStep.build); + expect(subStepFromStatusMessage('Polishing design and motion...'), + WebsiteSubStep.polish); + }); + + test('"Polishing design and motion" is POLISH, not design', () { + // It contains the word "design", so a naive contains-check reports + // the wrong pass for the last one. + expect(subStepFromStatusMessage('Polishing design and motion...'), + isNot(WebsiteSubStep.design)); + }); + + test('the legacy single-pass message names no pass', () { + expect(subStepFromStatusMessage('Generating website...'), isNull); + expect(subStepFromStatusMessage('Queued for generation'), isNull); + expect(subStepFromStatusMessage(null), isNull); + }); + + test('advanceSubStep never walks backwards', () { + expect(advanceSubStep(null, 'Designing art direction...'), + WebsiteSubStep.design); + expect( + advanceSubStep(WebsiteSubStep.build, 'Polishing design and motion...'), + WebsiteSubStep.polish); + // A late/stale poll must not un-tick a pass already seen. + expect(advanceSubStep(WebsiteSubStep.polish, 'Building your website...'), + WebsiteSubStep.polish); + // An unrecognised line leaves the pass alone rather than clearing it. + expect(advanceSubStep(WebsiteSubStep.build, 'Almost there'), + WebsiteSubStep.build); + }); + + test('passes hang off Generate site, and only once one is known', () { + final none = buildWebsiteGenerationSteps( + status: WebsiteGenStatus.generating, + ); + expect(none[2].subSteps, isEmpty); + + final building = buildWebsiteGenerationSteps( + status: WebsiteGenStatus.generating, + subStep: WebsiteSubStep.build, + ); + expect(building[2].subSteps.map((s) => s.title).toList(), + ['Design', 'Build', 'Polish']); + expect(building[2].subSteps[0].state, WebsiteStepState.done); + expect(building[2].subSteps[1].state, WebsiteStepState.active); + expect(building[2].subSteps[2].state, WebsiteStepState.pending); + // No other step grows sub-steps. + expect(building.where((s) => s.subSteps.isNotEmpty).length, 1); + }); + + test('a failure inside generation fails the pass it was on', () { + final steps = buildWebsiteGenerationSteps( + status: WebsiteGenStatus.error, + lastActiveStatus: WebsiteGenStatus.generating, + subStep: WebsiteSubStep.polish, + ); + expect(steps[2].state, WebsiteStepState.failed); + expect(steps[2].subSteps[2].state, WebsiteStepState.failed); + expect(steps[2].subSteps[0].state, WebsiteStepState.done); + }); + + test('a completed generation collapses the passes away', () { + final steps = buildWebsiteGenerationSteps( + status: WebsiteGenStatus.completed, + subStep: WebsiteSubStep.polish, + ); + expect(steps[2].subSteps, isEmpty); + }); + }); + group('serverPhaseRank', () { test('ranks the three known phases in pipeline order', () { expect(serverPhaseRank(kServerPhasePending), 0);