diff --git a/packages/site_shared/lib/developer_profiles.dart b/packages/site_shared/lib/developer_profiles.dart new file mode 100644 index 0000000000..5c33e0f3f3 --- /dev/null +++ b/packages/site_shared/lib/developer_profiles.dart @@ -0,0 +1,9 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// Utilities for integrating with the Google Developer Profiles API, +/// also known as the Google Developer Program (GDP) API. +library; + +export 'src/utils/developer_profiles_client.dart'; diff --git a/packages/site_shared/lib/src/utils/developer_profiles_client.dart b/packages/site_shared/lib/src/utils/developer_profiles_client.dart new file mode 100644 index 0000000000..63fb44fccc --- /dev/null +++ b/packages/site_shared/lib/src/utils/developer_profiles_client.dart @@ -0,0 +1,240 @@ +// Copyright 2026 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +/// A lightweight client for the Google Developer Profiles API +/// (`developers.googleapis.com`), also known as the +/// Google Developer Program (GDP) API. +library; + +import 'dart:convert'; + +import 'package:http/http.dart' as http; + +/// The OAuth 2.0 scope required to read and write +/// the signed-in user's Google Developer profile. +const String developerProfilesScope = + 'https://www.googleapis.com/auth/devprofiles.full_control'; + +/// The Nomenclature node ID that represents the Flutter product. +/// +/// Add it to a profile with [DeveloperProfilesClient.addInterests] +/// to mark the user as interested in Flutter. +const String flutterInterestNodeId = 'E-product-Flutter'; + +/// A deployment of the Google Developer Profiles API. +enum DeveloperProfilesEnvironment { + /// The staging deployment, used for development and testing. + staging('https://staging-developers.sandbox.googleapis.com'), + + /// The production deployment. + production('https://developers.googleapis.com'); + + const DeveloperProfilesEnvironment(this.baseUrl); + + /// The base URL of the API in this environment. + final String baseUrl; +} + +/// A user's preference for receiving a category of email. +enum EmailPreference { + /// The user opted out of this category of email. + disabled('DISABLED'), + + /// The user opted in to this category of email. + enabled('ENABLED'); + + const EmailPreference(this.value); + + /// The value of this preference as expected by the API. + final String value; +} + +/// The save state of an interest on a profile. +enum InterestState { + /// The user manually selected the interest. + selected('STATE_SELECTED'), + + /// The interest was inferred from the user's previous behavior. + inferred('STATE_INFERRED'); + + const InterestState(this.value); + + /// The value of this state as expected by the API. + final String value; +} + +/// An error returned by the Google Developer Profiles API. +final class DeveloperProfilesException implements Exception { + /// Creates an exception for a failed request + /// with the specified HTTP [statusCode] and [message]. + const DeveloperProfilesException(this.statusCode, this.message); + + /// The HTTP status code of the failed response. + final int statusCode; + + /// The error message returned by the API, if any. + final String message; + + @override + String toString() => 'DeveloperProfilesException($statusCode): $message'; +} + +/// A client for the Google Developer Profiles API that +/// acts on behalf of the user who granted its access token. +/// +/// The access token must be granted the [developerProfilesScope]. +/// When running in the browser, obtain it with +/// Google Identity Services' OAuth 2.0 token client. +final class DeveloperProfilesClient { + /// Creates a client that authenticates requests with `accessToken`. + /// + /// Requests go to the [environment] deployment, which + /// defaults to [DeveloperProfilesEnvironment.staging]. + /// If specified, `apiKey` is sent as the `key` query parameter. + DeveloperProfilesClient({ + required this._accessToken, + this.environment = .staging, + this._apiKey, + http.Client? httpClient, + }) : _httpClient = httpClient ?? http.Client(); + + /// The deployment of the API that requests are sent to. + final DeveloperProfilesEnvironment environment; + + final String _accessToken; + final String? _apiKey; + final http.Client _httpClient; + + /// Returns the resource name (`profiles/{id}`) of + /// the signed-in user's profile, or `null` if they don't have one. + Future hasProfile() async { + final response = await _send('GET', 'profile:has'); + return switch (response) { + {'obfuscatedProfileId': final String id} when id.isNotEmpty => + 'profiles/$id', + _ => null, + }; + } + + /// Returns the signed-in user's profile as decoded JSON. + Future> getProfile() => _send('GET', 'profile:get'); + + /// Creates a profile for the signed-in user and + /// returns its resource name (`profiles/{id}`). + /// + /// Use [eventEmail] and [newsletterEmail] to record the user's + /// consent to event and newsletter emails, and [originUri] to + /// attribute the profile creation to the page it happened on. + Future createProfile({ + EmailPreference? eventEmail, + EmailPreference? newsletterEmail, + String? originUri, + }) async { + final response = await _send( + 'POST', + 'profiles', + body: { + 'eventEmail': ?eventEmail?.value, + 'newsletterEmail': ?newsletterEmail?.value, + if (originUri != null) 'creationOrigin': {'originUri': originUri}, + }, + ); + return switch (response) { + {'name': final String name} when name.isNotEmpty => name, + _ => throw const DeveloperProfilesException( + 200, + 'CreateProfile response did not include a profile name.', + ), + }; + } + + /// Returns the resource name of the signed-in user's profile, + /// creating the profile with [createProfile] if it doesn't exist yet. + Future<({String name, bool created})> ensureProfile({ + EmailPreference? eventEmail, + EmailPreference? newsletterEmail, + String? originUri, + }) async { + if (await hasProfile() case final existingName?) { + return (name: existingName, created: false); + } + + final name = await createProfile( + eventEmail: eventEmail, + newsletterEmail: newsletterEmail, + originUri: originUri, + ); + return (name: name, created: true); + } + + /// Adds the Nomenclature [nodeIds] as interests of + /// the profile with resource name [profileName]. + /// + /// Interests that are already present on the profile are left as is. + /// At most 100 interests can be added in a single call. + Future addInterests( + String profileName, + List nodeIds, { + InterestState state = .selected, + }) async { + await _send( + 'POST', + '$profileName/interests:batchUpdate', + body: { + 'requests': [ + for (final nodeId in nodeIds) + { + 'interest': { + 'name': '$profileName/interests/$nodeId', + 'nodeId': nodeId, + 'interestState': state.value, + }, + }, + ], + }, + ); + } + + /// Closes the underlying HTTP client. + void close() => _httpClient.close(); + + Future> _send( + String method, + String path, { + Map? body, + }) async { + final baseUri = Uri.parse('${environment.baseUrl}/v1beta1/$path'); + final uri = switch (_apiKey) { + final apiKey? => baseUri.replace(queryParameters: {'key': apiKey}), + null => baseUri, + }; + final request = http.Request(method, uri) + ..headers['Authorization'] = 'Bearer $_accessToken'; + if (body != null) { + request + ..headers['Content-Type'] = 'application/json' + ..body = jsonEncode(body); + } + + final response = await http.Response.fromStream( + await _httpClient.send(request), + ); + final decoded = response.body.isEmpty + ? const {} + : jsonDecode(response.body); + + if (response.statusCode < 200 || response.statusCode >= 300) { + final message = switch (decoded) { + {'error': {'message': final String message}} => message, + _ => response.reasonPhrase ?? 'Unknown error', + }; + throw DeveloperProfilesException(response.statusCode, message); + } + + return switch (decoded) { + final Map json => json, + _ => const {}, + }; + } +} diff --git a/sites/www/content/create-2026.md b/sites/www/content/create-2026.md new file mode 100644 index 0000000000..e58663d1da --- /dev/null +++ b/sites/www/content/create-2026.md @@ -0,0 +1,10 @@ +--- +title: Flutter Create 2026 +bodyTags: interior flutter-create +description: >- + Flutter Create is Flutter's flagship livestream event on November 11, 2026. + Sign up to get notified when the livestream starts. +publishDate: "2026-10-13" +--- + + diff --git a/sites/www/lib/main.client.options.dart b/sites/www/lib/main.client.options.dart index 1f8f128ec1..a71b52fe48 100644 --- a/sites/www/lib/main.client.options.dart +++ b/sites/www/lib/main.client.options.dart @@ -20,6 +20,10 @@ import 'package:flutter_website/src/components/pages/consultants_grid.dart' deferred as _consultants_grid; import 'package:flutter_website/src/components/pages/events_grid.dart' deferred as _events_grid; +import 'package:flutter_website/src/components/pages/flutter_create_countdown.dart' + deferred as _flutter_create_countdown; +import 'package:flutter_website/src/components/pages/flutter_create_signup.dart' + deferred as _flutter_create_signup; import 'package:flutter_website/src/components/pages/games_adaptive_media.dart' deferred as _games_adaptive_media; import 'package:flutter_website/src/components/pages/showcase_grid.dart' @@ -138,6 +142,16 @@ ClientOptions get defaultClientOptions => ClientOptions( ), loader: _events_grid.loadLibrary, ), + 'flutter_create_countdown': ClientLoader( + (p) => _flutter_create_countdown.FlutterCreateCountdown(), + loader: _flutter_create_countdown.loadLibrary, + ), + 'flutter_create_signup': ClientLoader( + (p) => _flutter_create_signup.FlutterCreateSignup( + compact: p['compact'] as bool, + ), + loader: _flutter_create_signup.loadLibrary, + ), 'games_adaptive_media': ClientLoader( (p) => _games_adaptive_media.GamesAdaptiveMedia( mobileAssets: (p['mobileAssets'] as Map) diff --git a/sites/www/lib/main.server.dart b/sites/www/lib/main.server.dart index b98c78e3e4..a65a74d671 100644 --- a/sites/www/lib/main.server.dart +++ b/sites/www/lib/main.server.dart @@ -34,6 +34,7 @@ import 'src/pages/ecosystem_page.dart'; import 'src/pages/embedded_page.dart'; import 'src/pages/events_page.dart'; import 'src/pages/flip_page.dart'; +import 'src/pages/flutter_create_page.dart'; import 'src/pages/games_page.dart'; import 'src/pages/google_integrations_page.dart'; import 'src/pages/home_page.dart'; @@ -126,6 +127,7 @@ void main() { defineComponent('ShowcasePage', const ShowcasePage()), defineComponent('BrandPage', const BrandPage()), defineComponent('FlipPage', const FlipPage()), + defineComponent('FlutterCreatePage', const FlutterCreatePage()), defineComponent('NewsPage', const NewsPage()), defineComponent('WhyFlutterPage', const WhyFlutterPage()), defineComponentWithAttrs('Image', Image.fromAttrs), diff --git a/sites/www/lib/main.server.options.dart b/sites/www/lib/main.server.options.dart index 3a2002b0ab..88cb5a50e5 100644 --- a/sites/www/lib/main.server.options.dart +++ b/sites/www/lib/main.server.options.dart @@ -17,6 +17,10 @@ import 'package:flutter_website/src/components/pages/consultants_grid.dart' as _consultants_grid; import 'package:flutter_website/src/components/pages/events_grid.dart' as _events_grid; +import 'package:flutter_website/src/components/pages/flutter_create_countdown.dart' + as _flutter_create_countdown; +import 'package:flutter_website/src/components/pages/flutter_create_signup.dart' + as _flutter_create_signup; import 'package:flutter_website/src/components/pages/games_adaptive_media.dart' as _games_adaptive_media; import 'package:flutter_website/src/components/pages/showcase_grid.dart' @@ -88,6 +92,15 @@ ServerOptions get defaultServerOptions => ServerOptions( 'events_grid', params: __events_gridEventsGrid, ), + _flutter_create_countdown.FlutterCreateCountdown: + ClientTarget<_flutter_create_countdown.FlutterCreateCountdown>( + 'flutter_create_countdown', + ), + _flutter_create_signup.FlutterCreateSignup: + ClientTarget<_flutter_create_signup.FlutterCreateSignup>( + 'flutter_create_signup', + params: __flutter_create_signupFlutterCreateSignup, + ), _games_adaptive_media.GamesAdaptiveMedia: ClientTarget<_games_adaptive_media.GamesAdaptiveMedia>( 'games_adaptive_media', @@ -179,6 +192,9 @@ Map __events_gridEventsGrid(_events_grid.EventsGrid c) => { 'items': c.items.map((i) => i.toId()).toList(), 'past': c.past, }; +Map __flutter_create_signupFlutterCreateSignup( + _flutter_create_signup.FlutterCreateSignup c, +) => {'compact': c.compact}; Map __games_adaptive_mediaGamesAdaptiveMedia( _games_adaptive_media.GamesAdaptiveMedia c, ) => {'mobileAssets': c.mobileAssets, 'desktopAssets': c.desktopAssets}; diff --git a/sites/www/lib/src/components/pages/flutter_create_countdown.dart b/sites/www/lib/src/components/pages/flutter_create_countdown.dart new file mode 100644 index 0000000000..1e9617664d --- /dev/null +++ b/sites/www/lib/src/components/pages/flutter_create_countdown.dart @@ -0,0 +1,90 @@ +// Copyright 2026, the Flutter authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'dart:async'; + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +/// The local calendar date of the Flutter Create 2026 livestream. +final DateTime _eventDate = DateTime(2026, 11, 11); + +const String _fallbackLabel = 'November 11 · Online'; +const String _dayLabel = 'day to go'; +const String _daysLabel = 'days to go'; +const String _todayLabel = "It's today!"; +const String _afterLabel = 'Thanks for watching'; + +/// A small inline script to place directly after [FlutterCreateCountdown]. +/// +/// It fills in the countdown before the page is first painted, using the +/// same calculation and labels as the component, so the static fallback +/// isn't briefly shown before the component hydrates. +const String flutterCreateCountdownScript = + '(function(){' + 'var s=document.currentScript,e=s&&s.previousElementSibling;' + "if(!e||!e.classList.contains('fc-countdown'))return;" + 'var n=new Date(),t=new Date(n.getFullYear(),n.getMonth(),n.getDate()),' + 'd=Math.round((new Date(2026,10,11)-t)/864e5),' + "l=function(x){var c=document.createElement('span');" + "c.className='fc-countdown-label';c.textContent=x;return c;};" + 'e.textContent="";' + "if(d>0){var m=document.createElement('span');" + "m.className='fc-countdown-number';m.textContent=String(d);" + "e.append(m,l(d===1?'$_dayLabel':'$_daysLabel'));}" + "else e.append(l(d===0?\"$_todayLabel\":'$_afterLabel'));" + '})();'; + +/// A count of the days remaining until the Flutter Create livestream. +@client +class FlutterCreateCountdown extends StatefulComponent { + const FlutterCreateCountdown({super.key}); + + @override + State createState() => _FlutterCreateCountdownState(); +} + +class _FlutterCreateCountdownState extends State { + int? _daysLeft; + Timer? _timer; + + @override + void initState() { + super.initState(); + if (!kIsWeb) return; + + _update(); + _timer = Timer.periodic(const Duration(minutes: 1), (_) => _update()); + } + + void _update() { + final now = DateTime.now(); + final today = DateTime(now.year, now.month, now.day); + setState(() => _daysLeft = _eventDate.difference(today).inDays); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Component build(BuildContext context) { + final daysLeft = _daysLeft; + return div(classes: 'fc-countdown', [ + if (daysLeft == null) + const span(classes: 'fc-countdown-label', [.text(_fallbackLabel)]) + else if (daysLeft > 0) ...[ + span(classes: 'fc-countdown-number', [.text('$daysLeft')]), + span(classes: 'fc-countdown-label', [ + .text(daysLeft == 1 ? _dayLabel : _daysLabel), + ]), + ] else if (daysLeft == 0) + const span(classes: 'fc-countdown-label', [.text(_todayLabel)]) + else + const span(classes: 'fc-countdown-label', [.text(_afterLabel)]), + ]); + } +} diff --git a/sites/www/lib/src/components/pages/flutter_create_signup.dart b/sites/www/lib/src/components/pages/flutter_create_signup.dart new file mode 100644 index 0000000000..e49fa4ed08 --- /dev/null +++ b/sites/www/lib/src/components/pages/flutter_create_signup.dart @@ -0,0 +1,526 @@ +// Copyright 2026, the Flutter authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'dart:async'; +import 'dart:convert'; + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; +import 'package:site_shared/developer_profiles.dart'; +import 'package:site_shared/util.dart'; +import 'package:universal_web/js_interop.dart'; +import 'package:universal_web/web.dart' as web; + +import '../common/icon.dart'; + +/// The OAuth 2.0 client ID used to request +/// Google Developer Profiles API access tokens. +/// +/// Provide it at build time with +/// `--dart-define=GDP_OAUTH_CLIENT_ID=`. +const String _oauthClientId = String.fromEnvironment('GDP_OAUTH_CLIENT_ID'); + +/// An optional API key sent along with Developer Profiles API requests. +/// +/// Provide it at build time with `--dart-define=GDP_API_KEY=`. +const String _apiKey = String.fromEnvironment('GDP_API_KEY'); + +const String _gisScriptUrl = 'https://accounts.google.com/gsi/client'; + +/// A sign-up form that uses Google Sign-In to +/// create or update the user's Google Developer profile, +/// opting them in to event emails and adding Flutter as an interest. +@client +class FlutterCreateSignup extends StatefulComponent { + /// Creates a sign-up form. + /// + /// If [compact] is `true`, renders only a sign-in button and a short + /// caption, suitable for placing on the dark hero background. + const FlutterCreateSignup({this.compact = false, super.key}); + + /// Whether to render the compact hero variant of the form. + final bool compact; + + @override + State createState() => _FlutterCreateSignupState(); +} + +enum _SignupStatus { idle, working, success, error } + +/// The `localStorage` key that records a successful sign-up in this browser. +const String _signupStorageKey = 'flutter-create-2026-signup'; + +/// The moment after which a recorded sign-up is no longer relevant, +/// the day after the livestream. +final DateTime _signupExpiry = DateTime(2026, 11, 12); + +/// The class added to the root `` element when +/// this browser has a recorded sign-up. +const String _signedUpClass = 'fc-signed-up'; + +/// A small inline script to run before the sign-up forms are painted. +/// +/// If this browser has a recorded, unexpired sign-up, it adds a class to the +/// root element so the forms show their success state from the first paint, +/// without briefly showing the sign-in button. +const String flutterCreateSignupStateScript = + "try{if(localStorage.getItem('$_signupStorageKey')&&" + 'Date.now() _signupChanges = + StreamController.broadcast(); + +/// Records a successful sign-up in this browser and +/// shows the success state in every form on the page. +void _recordSignup({required bool created}) { + _sharedSignupResult = created; + web.document.documentElement?.classList.add(_signedUpClass); + _writeStorage( + jsonEncode({ + 'created': created, + 'at': DateTime.now().toUtc().toIso8601String(), + }), + ); + _signupChanges.add(created); +} + +/// Forgets the recorded sign-up, so every form on the page +/// shows its sign-in button again. +void _clearSignup() { + _sharedSignupResult = null; + web.document.documentElement?.classList.remove(_signedUpClass); + _writeStorage(null); + _signupChanges.add(null); +} + +/// Reads the sign-up recorded in `localStorage`, if any. +/// +/// Returns `null`, and removes the record, if it has expired or is invalid. +bool? _readStoredSignup() { + if (!kIsWeb) return null; + try { + final stored = web.window.localStorage.getItem(_signupStorageKey); + if (stored == null) return null; + if (DateTime.now().isAfter(_signupExpiry)) { + _writeStorage(null); + web.document.documentElement?.classList.remove(_signedUpClass); + return null; + } + return switch (jsonDecode(stored)) { + {'created': final bool created} => created, + _ => null, + }; + } on Object { + // Storage might be unavailable, such as in some private browsing modes, + // or contain an unexpected value. + return null; + } +} + +/// Writes [value] to `localStorage`, or removes the entry if it's `null`. +void _writeStorage(String? value) { + try { + if (value == null) { + web.window.localStorage.removeItem(_signupStorageKey); + } else { + web.window.localStorage.setItem(_signupStorageKey, value); + } + } on Object { + // Ignore unavailable storage; the state then only lasts for this visit. + } +} + +class _FlutterCreateSignupState extends State { + _SignupStatus _status = .idle; + bool _createdProfile = false; + String? _errorMessage; + + StreamSubscription? _signupSubscription; + + @override + void initState() { + super.initState(); + if (kIsWeb) { + if (_sharedSignupResult != null) { + // Only describe a newly created profile right after signing up, + // so the message doesn't change between visits. + _status = .success; + } + _signupSubscription = _signupChanges.stream.listen((created) { + if (!mounted) return; + setState(() { + _errorMessage = null; + if (created == null) { + _status = .idle; + } else { + _status = .success; + _createdProfile = created; + } + }); + }); + } + // Load Google Identity Services up front, so that clicking the button + // can open the sign-in popup synchronously, within the user gesture. + // Otherwise, browsers might block the popup. + if (kIsWeb && _oauthClientId.isNotEmpty) { + unawaited(_loadGoogleIdentityServices().catchError((Object _) {})); + } + } + + @override + void dispose() { + unawaited(_signupSubscription?.cancel()); + super.dispose(); + } + + Future _onSignIn() async { + setState(() { + _status = .working; + _errorMessage = null; + }); + + try { + final accessToken = await _requestAccessToken(); + final client = DeveloperProfilesClient( + accessToken: accessToken, + environment: .staging, + apiKey: _apiKey.isEmpty ? null : _apiKey, + ); + + try { + final profile = await client.ensureProfile( + eventEmail: .enabled, + originUri: web.window.location.href, + ); + await client.addInterests(profile.name, const [flutterInterestNodeId]); + + // Persist the sign-up and notify every form on the page, + // including this one. + _recordSignup(created: profile.created); + } finally { + client.close(); + } + } on Object catch (e) { + web.console.error('Flutter Create sign-in failed: $e'.toJS); + if (!mounted) return; + setState(() { + _status = .error; + _errorMessage = switch (e) { + _SignInError(:final message) => message, + DeveloperProfilesException(:final statusCode, :final message) + when !productionBuild => + 'Developer Profiles API error ($statusCode): $message', + _ => 'Something went wrong. Please try again later.', + }; + }); + } + } + + @override + Component build(BuildContext context) { + // Both the sign-in and success states are always rendered, stacked in + // the same grid cell, so the form keeps a stable size when switching. + // Which one is visible is controlled by CSS through the `fc-signed-up` + // class on the root element. That class is set before the first paint + // by an inline script, so returning visitors never see the sign-in UI. + if (component.compact) { + return div(classes: 'fc-hero-signup fc-swap', [ + div(classes: 'fc-swap-signin', [ + _buildSignInButton(hero: true), + const p(classes: 'fc-hero-signup-caption', [ + .text( + "Sign in and we'll email you with updates to the livestream.", + ), + ]), + ..._buildErrors(), + const _FinePrint(), + ]), + const div(classes: 'fc-swap-done', [ + div(classes: 'fc-hero-signup-done', [ + Icon(symbol: 'check_circle', filled: true), + span([.text("We'll keep you informed about the stream.")]), + ]), + _SwitchAccountButton(), + ]), + ]); + } + + return div(classes: 'flutter-create-signup fc-swap', [ + div(classes: 'fc-swap-signin', [ + const p(classes: 'fc-signup-lede', [ + .text( + 'Sign in with Google to stay informed about updates to the livestream.', + ), + ]), + _buildSignInButton(hero: false), + ..._buildErrors(), + const _FinePrint(), + ]), + div(classes: 'fc-swap-done fc-signup-success', [ + const span(classes: 'fc-success-icon', [Icon(symbol: 'celebration')]), + const h3([.text("You're on the list!")]), + p([ + .text( + _status == .success && _createdProfile + ? 'We created your Google Developer Program profile and ' + "added Flutter to your interests. We'll let you know " + 'when the livestream is about to start.' + : "We'll let you know when the livestream is about to start.", + ), + ]), + const div(classes: 'fc-signup-success-actions', [ + a( + classes: 'btn fc-btn-outline-dark', + href: 'https://developers.google.com/profile/u/me', + target: Target.blank, + [.text('View your profile')], + ), + _SwitchAccountButton(), + ]), + ]), + ]); + } + + Component _buildSignInButton({required bool hero}) { + return button( + classes: hero ? 'fc-google-btn fc-google-btn-hero' : 'fc-google-btn', + disabled: _status == .working || _oauthClientId.isEmpty, + onClick: () => unawaited(_onSignIn()), + [ + const _GoogleLogo(), + span([ + .text( + _status == .working ? 'Signing you in…' : 'Sign in with Google', + ), + ]), + ], + ); + } + + List _buildErrors() => [ + if (_oauthClientId.isEmpty) + const p(classes: 'fc-signup-error', [ + .text('Sign-in is not configured for this build.'), + ]), + if (_errorMessage case final message?) + p(classes: 'fc-signup-error', [.text(message)]), + ]; +} + +/// A link-style button that forgets the recorded sign-up, +/// so a different Google account can sign up. +class _SwitchAccountButton extends StatelessComponent { + const _SwitchAccountButton(); + + @override + Component build(BuildContext context) { + return const button( + classes: 'fc-switch-account', + type: ButtonType.button, + onClick: _clearSignup, + [.text('Use a different account')], + ); + } +} + +/// The disclosure shown next to each sign-in button. +class _FinePrint extends StatelessComponent { + const _FinePrint(); + + @override + Component build(BuildContext context) { + return const p(classes: 'fc-fine-print', [ + .text( + 'Signing up creates or updates your Google Developer Program ' + 'profile, adds Flutter to your interests, and opts you in to ' + 'event emails. ', + ), + a( + href: 'https://developers.google.com/profile/terms', + target: Target.blank, + [.text('Program terms')], + ), + .text(' · '), + a( + href: 'https://policies.google.com/privacy', + target: Target.blank, + [.text('Privacy policy')], + ), + ]); + } +} + +/// The multicolor Google "G" logo. +class _GoogleLogo extends StatelessComponent { + const _GoogleLogo(); + + @override + Component build(BuildContext context) { + return const RawText( + '', + ); + } +} + +/// Thrown when Google Sign-In doesn't return an access token. +final class _SignInError implements Exception { + const _SignInError(this.message, {this.code}); + + /// Creates an error for the specified Google Identity Services error [code]. + factory _SignInError.fromCode(String code, {String? description}) { + final message = switch (code) { + 'popup_closed' || + 'access_denied' => 'Sign-in was cancelled. Please try again.', + 'popup_failed_to_open' => + 'Your browser blocked the sign-in popup. ' + 'Allow popups for this site and try again.', + _ when productionBuild => + "Google couldn't sign you in. Please try again later.", + _ => + "Google couldn't sign you in ($code)" + '${description != null ? ': $description' : ''}.', + }; + return _SignInError(message, code: code); + } + + /// A user-facing description of the error. + final String message; + + /// The Google Identity Services error code, if any. + final String? code; + + @override + String toString() => '_SignInError(${code ?? 'unknown'}): $message'; +} + +/// Prompts the user to sign in with Google and +/// returns an access token for the Developer Profiles API. +Future _requestAccessToken() async { + // Only wait if the library isn't loaded yet, since awaiting can cause + // the browser to treat the popup as not initiated by the user. + if (!_gisLoaded) await _loadGoogleIdentityServices(); + + final completer = Completer(); + final tokenClient = _initTokenClient( + _TokenClientConfig( + client_id: _oauthClientId, + scope: developerProfilesScope, + callback: (_TokenResponse response) { + if (completer.isCompleted) return; + if (response.access_token case final token? when token.isNotEmpty) { + completer.complete(token); + } else { + completer.completeError( + _SignInError.fromCode( + response.error ?? 'unknown', + description: response.error_description, + ), + ); + } + }.toJS, + error_callback: (_TokenClientError error) { + if (completer.isCompleted) return; + completer.completeError( + _SignInError.fromCode( + error.type ?? 'unknown', + description: error.message, + ), + ); + }.toJS, + ), + ); + tokenClient.requestAccessToken(); + + return await completer.future; +} + +/// Whether the Google Identity Services library has finished loading. +bool _gisLoaded = false; + +Future? _gisLoading; + +/// Loads the Google Identity Services client library, at most once. +Future _loadGoogleIdentityServices() { + return _gisLoading ??= () { + final completer = Completer(); + final script = web.HTMLScriptElement() + ..src = _gisScriptUrl + ..async = true + ..defer = true; + script + ..addEventListener( + 'load', + (web.Event _) { + _gisLoaded = true; + completer.complete(); + }.toJS, + ) + ..addEventListener( + 'error', + (web.Event _) { + _gisLoading = null; + completer.completeError( + StateError('Failed to load Google Identity Services.'), + ); + }.toJS, + ); + web.document.head!.append(script); + return completer.future; + }(); +} + +@JS('google.accounts.oauth2.initTokenClient') +external _TokenClient _initTokenClient(_TokenClientConfig config); + +extension type _TokenClient(JSObject _) implements JSObject { + external void requestAccessToken(); +} + +// The property names must match the snake_case names +// expected by Google Identity Services. +// ignore_for_file: non_constant_identifier_names + +@anonymous +extension type _TokenClientConfig._(JSObject _) implements JSObject { + external factory _TokenClientConfig({ + required String client_id, + required String scope, + required JSFunction callback, + JSFunction? error_callback, + }); +} + +extension type _TokenResponse(JSObject _) implements JSObject { + external String? get access_token; + external String? get error; + external String? get error_description; +} + +extension type _TokenClientError(JSObject _) implements JSObject { + external String? get type; + external String? get message; +} diff --git a/sites/www/lib/src/pages/flutter_create_page.dart b/sites/www/lib/src/pages/flutter_create_page.dart new file mode 100644 index 0000000000..583d495a06 --- /dev/null +++ b/sites/www/lib/src/pages/flutter_create_page.dart @@ -0,0 +1,240 @@ +// Copyright 2026, the Flutter authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that +// can be found in the LICENSE file. + +import 'package:jaspr/dom.dart'; +import 'package:jaspr/jaspr.dart'; + +import '../components/common/icon.dart'; +import '../components/pages/flutter_create_countdown.dart'; +import '../components/pages/flutter_create_signup.dart'; +import '../utils/asset_utils.dart'; + +/// The landing page for Flutter Create, +/// Flutter's Q4 2026 flagship livestream event. +class FlutterCreatePage extends StatelessComponent { + const FlutterCreatePage({super.key}); + + @override + Component build(BuildContext context) { + return const main_([ + // Runs before the forms are painted to avoid + // briefly showing sign-in to visitors who already signed up. + script(content: flutterCreateSignupStateScript), + _Hero(), + _About(), + _Topics(), + _Signup(), + ]); + } +} + +class _Hero extends StatelessComponent { + const _Hero(); + + @override + Component build(BuildContext context) { + return section(classes: 'fc-hero', [ + const div(classes: 'fc-hero-glow', []), + div(classes: 'fc-hero-stage', [ + const div(classes: 'fc-hero-copy', [ + p(classes: 'fc-pill', [ + span(classes: 'fc-live-dot', []), + .text('Livestream · November 11, 2026'), + ]), + h1([ + .text('Flutter '), + span(classes: 'fc-gradient-text', [.text('Everywhere')]), + .text(', Everyday'), + ]), + p(classes: 'fc-hero-lede', [ + .text( + 'Join the Flutter team, partners, and community for a day ' + 'celebrating how Flutter shows up in everyday life, ' + 'everywhere you go.', + ), + ]), + FlutterCreateSignup(compact: true), + ]), + div(classes: 'fc-hero-visual', [ + _StreamCard( + logoUrl: context.asset('/images/flutter-logo-mark-320x.png'), + ), + ]), + ]), + ]); + } +} + +/// A stylized livestream player with a `flutter create` terminal prompt. +class _StreamCard extends StatelessComponent { + const _StreamCard({required this.logoUrl}); + + final String logoUrl; + + @override + Component build(BuildContext context) { + return div(classes: 'fc-stream', [ + const div(classes: 'fc-stream-bar', [ + span(classes: 'fc-stream-badge', [ + span(classes: 'fc-live-dot', []), + .text('Live soon'), + ]), + span(classes: 'fc-stream-dots', [span([]), span([]), span([])]), + ]), + div(classes: 'fc-stream-screen', [ + img(src: logoUrl, alt: '', width: 72, height: 72), + const p(classes: 'fc-terminal', [ + span(classes: 'fc-terminal-prompt', [.text(r'$ ')]), + .text('flutter create '), + span(classes: 'fc-terminal-arg', [.text('everywhere')]), + span(classes: 'fc-terminal-cursor', []), + ]), + ]), + const FlutterCreateCountdown(), + // Fills in the countdown before the first paint. + const script(content: flutterCreateCountdownScript), + ]); + } +} + +class _About extends StatelessComponent { + const _About(); + + static const List<({String icon, String label})> _highlights = [ + (icon: 'live_tv', label: 'Single-day livestream'), + (icon: 'rocket_launch', label: 'Launches with the latest release'), + (icon: 'diversity_3', label: 'Co-hosted with partners and community'), + ]; + + @override + Component build(BuildContext context) { + return section(classes: 'fc-section fc-about', [ + div(classes: 'fc-container fc-about-grid', [ + const div(classes: 'fc-about-copy', [ + p(classes: 'fc-kicker', [.text('About the event')]), + h2([.text('The newest Flutter and Dart features')]), + p(classes: 'fc-body', [ + .text( + 'Flutter Create is a virtual event streamed live alongside ' + 'the latest Flutter release. Each session pairs practical, ' + 'how-to technical content with real stories from teams ' + 'shipping Flutter apps across platforms and industries.', + ), + ]), + ]), + div(classes: 'fc-about-side', [ + ul(classes: 'fc-chips', [ + for (final highlight in _highlights) + li(classes: 'fc-chip', [ + span(classes: 'fc-chip-icon', [Icon(symbol: highlight.icon)]), + span([.text(highlight.label)]), + ]), + ]), + ]), + ]), + ]); + } +} + +class _Topics extends StatelessComponent { + const _Topics(); + + static const List< + ({String icon, String tint, String title, String description}) + > + _topics = [ + ( + icon: 'new_releases', + tint: 'blue', + title: "What's new in Flutter", + description: + 'A tour of the latest Flutter and Dart release, ' + 'launching the same day.', + ), + ( + icon: 'auto_awesome', + tint: 'violet', + title: 'AI on every screen', + description: + 'Generative UI, on-device models, and AI developer tools ' + 'that keep your agents reliable.', + ), + ( + icon: 'devices', + tint: 'green', + title: 'Desktop and embedded', + description: + 'Multi-window desktop apps and ' + 'Flutter on new kinds of devices.', + ), + ( + icon: 'integration_instructions', + tint: 'yellow', + title: 'Native interop', + description: + 'Call platform APIs directly from Dart with generated ' + 'bindings for Swift, Objective-C, Kotlin, and Java.', + ), + ( + icon: 'palette', + tint: 'pink', + title: 'Design systems', + description: + 'New ways to build and maintain ' + 'custom design libraries with Flutter.', + ), + ( + icon: 'bolt', + tint: 'blue', + title: 'Dart and WebAssembly', + description: + 'What Dart and Wasm unlock for fast, ' + 'portable apps on the web and beyond.', + ), + ]; + + @override + Component build(BuildContext context) { + return section(classes: 'fc-section fc-topics', [ + div(classes: 'fc-container', [ + const div(classes: 'fc-section-header', [ + p(classes: 'fc-kicker', [.text('What to expect')]), + h2([.text('Sessions for every kind of builder')]), + p(classes: 'fc-sub', [ + .text( + 'Deep dives from the Flutter team, paired with ' + 'stories from the teams who ship with it.', + ), + ]), + ]), + div(classes: 'fc-topic-grid', [ + for (final topic in _topics) + div(classes: 'fc-topic tint-${topic.tint}', [ + div(classes: 'fc-topic-icon', [Icon(symbol: topic.icon)]), + h3([.text(topic.title)]), + p([.text(topic.description)]), + ]), + ]), + ]), + ]); + } +} + +class _Signup extends StatelessComponent { + const _Signup(); + + @override + Component build(BuildContext context) { + return const section(id: 'signup', classes: 'fc-section fc-signup', [ + div(classes: 'fc-container', [ + div(classes: 'fc-signup-card', [ + div(classes: 'fc-signup-copy', [ + h2([.text('Get notified when we go live')]), + ]), + div(classes: 'fc-signup-panel', [FlutterCreateSignup()]), + ]), + ]), + ]); + } +} diff --git a/sites/www/lib/styles/pages/_flutter_create.scss b/sites/www/lib/styles/pages/_flutter_create.scss new file mode 100644 index 0000000000..eca5da9818 --- /dev/null +++ b/sites/www/lib/styles/pages/_flutter_create.scss @@ -0,0 +1,786 @@ +@use '../core/breakpoints'; + +// Styles for the Flutter Create 2026 landing page (`/create-2026`). +// +// Written mobile-first, following the visual language of the +// "Why Flutter" page: a blue gradient hero, generous type, tinted cards, +// and chip highlights. + +body.flutter-create { + --fc-teal: #6cf7d6; + --fc-navy: #031a33; + + .fc-container { + margin-inline: auto; + max-width: 1200px; + padding-inline: var(--gutter-sm); + + @include breakpoints.screen(lg) { + padding-inline: var(--gutter); + } + } + + .fc-section { + padding-block: var(--gutter-md); + + @include breakpoints.screen(lg) { + padding-block: var(--gutter-lg); + } + } + + .fc-kicker { + color: var(--primary); + font-size: var(--font-size-heading-4); + font-weight: var(--font-weight-bold); + line-height: 1.2; + margin: 0 0 var(--spacer-sm); + } + + h2 { + font-size: var(--font-size-heading-2); + font-weight: var(--font-weight-bold); + letter-spacing: -0.3px; + line-height: 1.1; + margin: 0 0 var(--spacer-sm); + + @include breakpoints.screen(md) { + font-size: var(--font-size-heading-1); + } + } + + .fc-sub { + color: var(--grey-6); + font-size: var(--font-size-heading-3); + line-height: 1.3; + margin: 0; + max-width: 720px; + } + + .fc-body { + color: var(--grey-6); + font-size: var(--font-size-heading-4); + line-height: 1.5; + margin: var(--spacer-sm) 0 0; + } + + .fc-live-dot { + animation: fc-pulse 1.8s ease-out infinite; + background: #ff4e4e; + border-radius: 50%; + display: inline-block; + flex-shrink: 0; + height: 8px; + width: 8px; + } + + .fc-terminal-prompt { + color: var(--fc-teal); + } +} + +// Each sign-up form renders its sign-in and success states stacked in a +// single grid cell, so the form reserves the height of the taller state and +// the page doesn't shift when switching between them. +// +// The visible state is chosen by the `fc-signed-up` class on the root +// element, which an inline script sets before the first paint for visitors +// with a recorded sign-up, and the forms keep in sync afterwards. +body.flutter-create .fc-swap { + display: grid; + + > .fc-swap-signin, + > .fc-swap-done { + grid-area: 1 / 1; + min-width: 0; + } + + > .fc-swap-done { + visibility: hidden; + } +} + +html.fc-signed-up body.flutter-create .fc-swap { + > .fc-swap-signin { + visibility: hidden; + } + + > .fc-swap-done { + visibility: visible; + } +} + +// A button that looks like a text link. +body.flutter-create .fc-switch-account { + background: none; + border: none; + cursor: pointer; + font-family: var(--font-ui); + font-size: var(--font-size-default); + padding: 0; + text-decoration: underline; + text-underline-offset: 3px; +} + +@keyframes fc-pulse { + 0% { box-shadow: 0 0 0 0 rgba(255, 78, 78, 0.6); } + 70% { box-shadow: 0 0 0 8px rgba(255, 78, 78, 0); } + 100% { box-shadow: 0 0 0 0 rgba(255, 78, 78, 0); } +} + +@keyframes fc-blink { + 50% { opacity: 0; } +} + +// ── Hero ────────────────────────────────────────────────────────────────── + +body.flutter-create .fc-hero { + background: linear-gradient(180deg, var(--blue-4) 0%, var(--blue-8) 100%); + color: white; + overflow: hidden; + position: relative; + + // `core/_base.scss` sets colours directly on headings and paragraphs, + // so text on the dark background restates white. + h1, + p { + color: white; + } + + .fc-hero-glow { + background: radial-gradient(ellipse 70% 60% at 30% 50%, var(--blue-8) 30%, transparent 100%); + filter: blur(70px); + inset: -10%; + pointer-events: none; + position: absolute; + } + + .fc-hero-stage { + align-items: center; + display: grid; + gap: var(--spacer); + grid-template-columns: 1fr; + margin-inline: auto; + max-width: 1200px; + padding: var(--gutter-md) var(--gutter-sm); + position: relative; + + @include breakpoints.screen(lg) { + gap: var(--gutter); + grid-template-columns: 1.3fr 1fr; + padding: var(--gutter-lg) var(--gutter); + } + } + + .fc-hero-copy { + text-align: center; + + @include breakpoints.screen(lg) { + text-align: left; + } + + h1 { + font-size: var(--font-size-heading-1); + font-weight: var(--font-weight-bolder); + letter-spacing: -1px; + line-height: 1.05; + margin: var(--spacer-md) 0 var(--spacer-md); + + @include breakpoints.screen(md) { + font-size: 64px; + } + + @include breakpoints.screen(xl) { + font-size: 72px; + } + } + } + + .fc-gradient-text { + background: linear-gradient(90deg, var(--fc-teal) 0%, var(--blue-2) 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + } + + .fc-pill { + align-items: center; + backdrop-filter: blur(8px); + background: rgba(255, 255, 255, 0.12); + border: 1px solid rgba(255, 255, 255, 0.25); + border-radius: 999px; + display: inline-flex; + font-size: var(--font-size-heading-5); + font-weight: var(--font-weight-bold); + gap: 10px; + margin: 0; + padding: 8px 16px; + } + + .fc-hero-lede { + font-size: var(--font-size-heading-4); + line-height: 1.5; + margin: 0 auto var(--spacer); + max-width: 520px; + opacity: 0.92; + + @include breakpoints.screen(lg) { + font-size: 20px; + margin-inline: 0; + } + } + + .fc-hero-signup { + > .fc-swap-signin, + > .fc-swap-done { + align-items: center; + align-self: start; + display: flex; + flex-direction: column; + gap: 12px; + + @include breakpoints.screen(lg) { + align-items: flex-start; + } + } + + // Keep "Use a different account" below the confirmation pill, nudged + // in to line up with the pill's check icon on wide screens. + > .fc-swap-done { + @include breakpoints.screen(lg) { + .fc-switch-account { + margin-left: 18px; + } + } + } + + .fc-google-btn-hero { + align-items: center; + background: white; + border: none; + border-radius: 999px; + box-shadow: 0 10px 30px rgba(2, 16, 40, 0.35); + color: #1f1f1f; + cursor: pointer; + display: inline-flex; + font-family: var(--font-ui); + font-size: var(--font-size-heading-5); + font-weight: var(--font-weight-bold); + gap: 10px; + height: 52px; + padding-inline: 22px; + white-space: nowrap; + + @include breakpoints.screen(md) { + font-size: var(--font-size-heading-4); + gap: 12px; + height: 56px; + padding-inline: 28px; + } + transition: transform 0.2s var(--ui-anim-func), box-shadow 0.2s; + + &:hover:not(:disabled) { + box-shadow: 0 14px 36px rgba(2, 16, 40, 0.45); + transform: translateY(-2px); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.7; + } + } + + .fc-google-logo { + height: 22px; + width: 22px; + } + + .fc-hero-signup-caption { + font-size: var(--font-size-heading-5); + margin: 0; + opacity: 0.85; + } + + .fc-fine-print { + font-size: 13px; + line-height: 1.5; + margin: 4px 0 0; + max-width: 480px; + opacity: 0.75; + + a { + color: white; + text-decoration: underline; + } + } + + .fc-signup-error { + background: rgba(255, 255, 255, 0.92); + border-radius: var(--ui-border-radius-sm); + color: var(--red); + font-size: var(--font-size-default); + margin: 0; + padding: 8px 12px; + } + + .fc-hero-signup-done { + align-items: center; + background: rgba(255, 255, 255, 0.14); + border: 1px solid rgba(255, 255, 255, 0.3); + border-radius: 999px; + display: inline-flex; + font-size: var(--font-size-heading-5); + font-weight: var(--font-weight-bold); + gap: 10px; + padding: 10px 18px; + + .icon { + color: var(--fc-teal); + } + } + + .fc-switch-account { + color: white; + font-size: 13px; + opacity: 0.85; + + &:hover { + opacity: 1; + } + } + } +} + +// The faux livestream player. +body.flutter-create .fc-stream { + background: linear-gradient(160deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.04)); + border: 1px solid rgba(255, 255, 255, 0.22); + border-radius: var(--ui-border-radius); + box-shadow: 0 30px 80px rgba(2, 16, 40, 0.45); + margin-inline: auto; + max-width: 560px; + padding: 14px; + transform: perspective(1400px) rotateY(-6deg) rotateX(2deg); + width: 100%; + + @media (prefers-reduced-motion: no-preference) { + transition: transform 0.6s var(--ui-anim-func); + + &:hover { + transform: perspective(1400px) rotateY(0) rotateX(0); + } + } + + .fc-stream-bar { + align-items: center; + display: flex; + justify-content: space-between; + padding: 4px 6px 12px; + } + + .fc-stream-badge { + align-items: center; + background: rgba(3, 26, 51, 0.55); + border-radius: 999px; + display: inline-flex; + font-size: 13px; + font-weight: var(--font-weight-bold); + gap: 8px; + letter-spacing: 0.06em; + padding: 6px 12px; + text-transform: uppercase; + } + + .fc-stream-dots { + display: inline-flex; + gap: 6px; + + span { + background: rgba(255, 255, 255, 0.45); + border-radius: 50%; + height: 8px; + width: 8px; + } + } + + .fc-stream-screen { + align-items: center; + aspect-ratio: 16 / 9; + background: + radial-gradient(circle at 50% 35%, rgba(19, 185, 253, 0.35), transparent 60%), + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 28px 28px, + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px) 0 0 / 28px 28px, + var(--fc-navy); + border-radius: 16px; + display: flex; + flex-direction: column; + gap: 20px; + justify-content: center; + padding: var(--gutter-sm); + + img { + filter: drop-shadow(0 8px 24px rgba(19, 185, 253, 0.5)); + height: 64px; + width: 64px; + + @include breakpoints.screen(md) { + height: 80px; + width: 80px; + } + } + } + + .fc-terminal { + background: rgba(0, 0, 0, 0.35); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 10px; + font-family: var(--font-code); + font-size: 15px; + margin: 0; + padding: 10px 16px; + + @include breakpoints.screen(md) { + font-size: 18px; + } + } + + .fc-terminal-arg { + color: var(--blue-3); + } + + .fc-terminal-cursor { + animation: fc-blink 1.1s steps(1) infinite; + background: white; + display: inline-block; + height: 1.1em; + margin-left: 4px; + vertical-align: text-bottom; + width: 0.55em; + } + + .fc-countdown { + align-items: baseline; + display: flex; + gap: 10px; + justify-content: center; + padding: 14px 6px 4px; + } + + .fc-countdown-number { + font-size: 36px; + font-weight: var(--font-weight-bolder); + line-height: 1; + } + + .fc-countdown-label { + font-size: var(--font-size-heading-4); + font-weight: var(--font-weight-bold); + opacity: 0.9; + } +} + +// ── About ───────────────────────────────────────────────────────────────── + +body.flutter-create .fc-about { + .fc-about-grid { + display: grid; + gap: var(--spacer); + grid-template-columns: 1fr; + + @include breakpoints.screen(lg) { + align-items: center; + gap: var(--gutter-lg); + grid-template-columns: 1.1fr 1fr; + } + } + + .fc-chips { + display: flex; + flex-direction: column; + gap: 12px; + list-style: none; + margin: 0; + padding: 0; + } + + .fc-chip { + align-items: center; + background: #f2f9f5; + border-radius: 999px; + display: flex; + font-size: var(--font-size-heading-4); + gap: 12px; + margin: 0; + padding: 6px 24px 6px 6px; + + &:nth-child(2) { background: var(--blue-1); } + &:nth-child(3) { background: #eeeafe; } + } + + .fc-chip-icon { + align-items: center; + background: white; + border-radius: 50%; + color: var(--blue-7); + display: flex; + height: 40px; + justify-content: center; + width: 40px; + } +} + +// ── Topics ──────────────────────────────────────────────────────────────── + +body.flutter-create .fc-topics { + background: linear-gradient(180deg, var(--grey-1) 0%, white 100%); + + .fc-section-header { + margin-bottom: var(--spacer); + } + + .fc-topic-grid { + display: grid; + gap: 20px; + grid-template-columns: 1fr; + + @include breakpoints.screen(md) { + grid-template-columns: repeat(2, 1fr); + } + + @include breakpoints.screen(lg) { + grid-template-columns: repeat(3, 1fr); + } + } + + .fc-topic { + background: white; + border-radius: var(--ui-border-radius-sm); + box-shadow: inset 0 0 0 1px #e5f0fc; + display: flex; + flex-direction: column; + gap: var(--spacer-sm); + padding: var(--spacer-md); + transition: box-shadow 0.3s, transform 0.3s var(--ui-anim-func); + + &:hover { + box-shadow: inset 0 0 0 1px #d2e5fb, var(--ui-drop-shadow-sm); + transform: translateY(-4px); + } + + h3 { + color: var(--blue-9); + font-size: var(--font-size-heading-3); + font-weight: var(--font-weight-bold); + line-height: 1.2; + margin: var(--spacer-sm) 0 0; + } + + p { + color: var(--grey-6); + font-size: var(--font-size-heading-4); + line-height: 1.45; + margin: 0; + } + + &.tint-blue { --fc-tint: #ebfafd; } + &.tint-violet { --fc-tint: #eeeafe; } + &.tint-green { --fc-tint: #edfffb; } + &.tint-yellow { --fc-tint: #fcfcf2; } + &.tint-pink { --fc-tint: #fdeef4; } + } + + .fc-topic-icon { + align-items: center; + background: var(--fc-tint); + border-radius: var(--ui-border-radius-sm); + color: var(--blue-9); + display: flex; + height: 64px; + justify-content: center; + width: 64px; + + .icon { + font-size: 30px; + } + } +} + +// ── Sign-up ─────────────────────────────────────────────────────────────── + +body.flutter-create .fc-signup { + padding-top: 0; + + .fc-signup-card { + background: linear-gradient(120deg, var(--blue-8) 0%, var(--blue-6) 55%, var(--blue-4) 100%); + border-radius: var(--ui-border-radius); + color: white; + display: grid; + gap: var(--spacer); + grid-template-columns: 1fr; + overflow: hidden; + padding: var(--spacer-md) var(--gutter-sm); + position: relative; + + &::after { + background: radial-gradient(circle, rgba(108, 247, 214, 0.35), transparent 65%); + content: ''; + height: 420px; + pointer-events: none; + position: absolute; + right: -120px; + top: -160px; + width: 420px; + } + + @include breakpoints.screen(lg) { + align-items: center; + gap: var(--gutter); + grid-template-columns: 1.1fr 1fr; + padding: var(--gutter-md); + } + } + + .fc-signup-copy { + position: relative; + z-index: 1; + + .fc-kicker { + color: var(--fc-teal); + } + + h2 { + color: white; + } + } + + .fc-signup-panel { + background: white; + border-radius: var(--ui-border-radius-sm); + box-shadow: var(--ui-drop-shadow); + color: var(--site-base-fgColor); + padding: var(--spacer-md); + position: relative; + z-index: 1; + } +} + +body.flutter-create .flutter-create-signup { + > .fc-swap-signin { + display: flex; + flex-direction: column; + gap: 16px; + } + + > .fc-swap-done { + align-self: center; + } + + h3 { + color: var(--blue-9); + font-size: var(--font-size-heading-3); + font-weight: var(--font-weight-bold); + margin: 0; + } + + .fc-signup-lede { + color: var(--grey-6); + font-size: var(--font-size-heading-5); + margin: 0; + } + + .fc-google-btn { + align-items: center; + background: white; + border: 1px solid var(--grey-4); + border-radius: 999px; + color: #1f1f1f; + cursor: pointer; + display: flex; + font-family: var(--font-ui); + font-size: var(--font-size-heading-5); + font-weight: var(--font-weight-bold); + gap: 12px; + height: 52px; + justify-content: center; + transition: background 0.2s, box-shadow 0.2s; + width: 100%; + + &:hover:not(:disabled) { + background: var(--grey-1); + box-shadow: var(--ui-drop-shadow-sm); + } + + &:disabled { + cursor: not-allowed; + opacity: 0.55; + } + } + + .fc-google-logo { + height: 20px; + width: 20px; + } + + .fc-fine-print { + color: var(--grey-6); + font-size: 13px; + line-height: 1.5; + margin: 0; + } + + .fc-signup-error { + background: #fdecea; + border-radius: var(--ui-border-radius-sm); + color: var(--red); + font-size: var(--font-size-default); + margin: 0; + padding: 8px 12px; + } + + .fc-signup-success { + display: flex; + flex-direction: column; + gap: 12px; + + p { + color: var(--grey-6); + margin: 0; + } + } + + .fc-success-icon { + align-items: center; + background: #edfffb; + border-radius: 50%; + color: var(--green-3); + display: flex; + height: 56px; + justify-content: center; + width: 56px; + } + + .fc-signup-success-actions { + align-items: center; + display: flex; + flex-wrap: wrap; + gap: 8px 20px; + } + + .fc-switch-account { + color: var(--blue-6); + } + + .fc-btn-outline-dark { + align-self: flex-start; + border: 1px solid var(--blue-6); + color: var(--blue-6); + transition: background-color 0.2s, color 0.2s; + + // Override the site-wide `main .btn:hover` fill, which would otherwise + // pair a blue background with this button's blue text. + &:hover, + &:focus-visible { + background-color: var(--blue-6); + color: white; + } + } +} diff --git a/sites/www/lib/styles/styles.scss b/sites/www/lib/styles/styles.scss index 966964cb6b..0da8f4abed 100644 --- a/sites/www/lib/styles/styles.scss +++ b/sites/www/lib/styles/styles.scss @@ -37,6 +37,7 @@ @use 'pages/ecosystem'; @use 'pages/events'; @use 'pages/flip'; +@use 'pages/flutter_create'; @use 'pages/games'; @use 'pages/home'; @use 'pages/integrations';