From 5f7e688652cf0e7f1b92c0d906d5924b8e2742bb Mon Sep 17 00:00:00 2001 From: abdallahshaban557 Date: Wed, 14 Oct 2026 22:16:23 +0000 Subject: [PATCH] Add Flutter Create 2026 landing page with GDP sign-up Adds /flutter-create-2026, a landing page for the Flutter Create 2026 livestream. Visitors sign in with Google to create or update their Google Developer Program profile, opt in to event emails, and add Flutter as an interest. Also adds a reusable Developer Profiles API client to site_shared. --- .../site_shared/lib/developer_profiles.dart | 9 + .../src/utils/developer_profiles_client.dart | 240 ++++++ sites/www/content/flutter-create-2026.md | 10 + sites/www/lib/main.client.options.dart | 14 + sites/www/lib/main.server.dart | 2 + sites/www/lib/main.server.options.dart | 16 + .../pages/flutter_create_countdown.dart | 68 ++ .../pages/flutter_create_signup.dart | 526 ++++++++++++ .../lib/src/pages/flutter_create_page.dart | 238 ++++++ .../www/lib/styles/pages/_flutter_create.scss | 786 ++++++++++++++++++ sites/www/lib/styles/styles.scss | 1 + 11 files changed, 1910 insertions(+) create mode 100644 packages/site_shared/lib/developer_profiles.dart create mode 100644 packages/site_shared/lib/src/utils/developer_profiles_client.dart create mode 100644 sites/www/content/flutter-create-2026.md create mode 100644 sites/www/lib/src/components/pages/flutter_create_countdown.dart create mode 100644 sites/www/lib/src/components/pages/flutter_create_signup.dart create mode 100644 sites/www/lib/src/pages/flutter_create_page.dart create mode 100644 sites/www/lib/styles/pages/_flutter_create.scss 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/flutter-create-2026.md b/sites/www/content/flutter-create-2026.md new file mode 100644 index 0000000000..e58663d1da --- /dev/null +++ b/sites/www/content/flutter-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 ad3623362a..6b2e49ca8e 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..03a1e1f438 --- /dev/null +++ b/sites/www/lib/src/components/pages/flutter_create_countdown.dart @@ -0,0 +1,68 @@ +// 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); + +/// 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('November 11 · Online'), + ]) + else if (daysLeft > 0) ...[ + span(classes: 'fc-countdown-number', [.text('$daysLeft')]), + span(classes: 'fc-countdown-label', [ + .text(daysLeft == 1 ? 'day to go' : 'days to go'), + ]), + ] else if (daysLeft == 0) + const span(classes: 'fc-countdown-label', [.text("It's today!")]) + else + const span(classes: 'fc-countdown-label', [ + .text('Thanks for watching'), + ]), + ]); + } +} 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..14df0915e6 --- /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 when the livestream is about to start.", + ), + ]), + ..._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..ce5d1e04ce --- /dev/null +++ b/sites/www/lib/src/pages/flutter_create_page.dart @@ -0,0 +1,238 @@ +// 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(), + ]); + } +} + +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..128c80f240 --- /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 (`/flutter-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';