4 Commits
Author SHA1 Message Date
ayushyaandClaude Sonnet 5 5f4caa2a3b Fix floating bottom bar filling the screen when search is enabled
Build APK / build (push) Successful in 5m40s
The Row wrapping the pill + search satellite used
crossAxisAlignment.stretch, but Scaffold.bottomNavigationBar gives its
child a loose (unbounded-max) height constraint - stretch on an
unbounded cross axis blows up to fill all available space in
profile/release builds (the debug assertion that would catch it is
stripped there), same class of bug files_controls_row.dart hit earlier
from a different cause. Both children already sized themselves
explicitly, so stretch was never actually needed - wrapping the Row in
a fixed-height SizedBox instead fixes it.

Added a regression test that reproduces this inside a real Scaffold
(the bare SizedBox host the other tests use gives a bounded height and
doesn't trigger it) - confirmed it fails without the fix (height 600,
the full test viewport) and passes with it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:59:52 -04:00
ayushyaandClaude Sonnet 5 4927416277 Add product website (deploy target: noo.ayushya.dev)
A single-page, static site under website/ - no build step, no
framework, just HTML/CSS/vanilla JS plus the two Google Fonts the app
already uses. Colors, radii, type scale and motion timing are copied
from lib/theme/design_tokens.dart so it reads as the same product as
the app, not a separate marketing skin.

Sections: hero with an exploded, parallaxing stack of the app's own UI
pieces (file row, photo grid, accent swatches, a toggle, the floating
bar) that drift apart on scroll and tilt toward the pointer; feature
highlights; a Customize section with two live interactive demos
(accent color, floating vs attached bottom bar); a privacy section
covering Login Flow v2; and a closing CTA.

GitHub/download links are placeholders (documented in website/README.md)
until real release URLs exist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:25:18 -04:00
ayushyaandClaude Sonnet 5 58312511c5 Add "Search in bottom bar" to Settings
Build APK / build (push) Successful in 5m30s
New Settings > Appearance toggle moves Search into the bottom bar and
out of the top bar, so there's only one search entry point on screen.
Tapping it pushes SearchView same as before - it's never highlighted,
since it isn't a real destination.

- Attached: Search becomes the row's last item, styled like any other
  tab but permanently idle.
- Floating: Search gets its own separate, always fully round satellite
  circle beside the bar instead.

Enabling it costs one regular tab: SettingsController.maxVisibleTabs
is now a dynamic getter (defaultMaxVisibleTabs - 1 while the toggle is
on) instead of the old flat constant, re-enforced immediately if the
user already has all 5 slots filled - the same cleanup path used for
a fresh install or an app update that added a new tab.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 22:19:13 -04:00
ayushyaandClaude Sonnet 5 a70be19f80 Add a floating bottom bar style, user-configurable in Settings
Settings > Appearance > "Bottom bar" now switches between the
original edge-to-edge bar and a floating one - inset 16px from both
side edges and clear of the bottom safe area, 28px corners (the same
radius the sheet top/drawer edge use), a 1px line border standing in
for elevation instead of a shadow (product UI stays flat). Persisted
via NooBottomBarStyle, defaulting to the existing attached behavior.

On Android, floating's idle tabs drop their reserved label space -
with no label to leave room for, the icon centers in the whole button
and renders a touch bigger (27 vs 24px) instead of sitting high with a
gap held open under it; the active tab's icon/pill/label position is
unchanged. iOS's tab content is unchanged either way, only the
container.

main.dart sets Scaffold.extendBody while floating so the body scrolls
behind the bar's transparent margin instead of stopping short of it,
matching how the design canvas explored it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-29 16:21:13 -04:00
17 changed files with 1853 additions and 56 deletions
@@ -273,6 +273,8 @@ Mobile always has **5 pinned tabs in the bottom bar**. The rest go in the drawer
| Status bar | 54px, Dynamic Island | 40px |
| Top bar | 44px row: `menu` on the left; `plus` and avatar on the right (all accent-text). Then a 34px large title, then the search field. | 64px: `menu`, 22px title, `search`, avatar |
| Bottom bar | Surface fill, 1px top line. Icon 24 above a 10px label. Active: accent-text. Idle: fg-3. 34px home indicator below. | Surface fill, 80px. Icon 24 above a 12px label (label space is always reserved, just invisible when idle, so nothing shifts on selection). The active icon sits inside a 56×32 accent-soft pill that slides between tabs as one shared indicator, rather than popping in/out per tab. 20px gesture bar below. |
| Bottom bar - floating (Settings → Appearance → "Bottom bar", both platforms) | Same content, inset 16px from both side edges and clear of the bottom safe area instead of edge to edge, radius 28 (the sheet-top/drawer-edge radius) rather than square corners. No shadow (product UI stays flat - see 1.4); a 1px `line` border stands in for elevation instead. Row height drops slightly (64/72 vs 50/80) to suit. Android's idle tabs drop their reserved label space in this mode - the icon just centers in the button and renders a touch bigger (27 vs 24px) - rather than sitting high with a gap held open under it. `Scaffold.extendBody: true` while floating, so the body scrolls behind the bar's transparent margin instead of stopping short of it. |
| Bottom bar - "Search in bottom bar" (Settings → Appearance, either bar style) | Adds a Search entry that's never highlighted (tapping it pushes Search, same as the top bar's own search action, which this replaces so there's only one entry point on screen) - the row's last item when attached (same styling as a real tab, just permanently idle), or its own separate, always fully round satellite circle beside the bar when floating. Costs one regular tab: `SettingsController.maxVisibleTabs` drops from 5 to 4 while this is on. |
| Upload | `plus` in the nav bar | Extended FAB |
| Overflow icon | `ellipsis` | `ellipsis-vertical` |
| Settings | Pushed screen with a "‹ Files" back button and a large title. No bottom bar. | Pushed screen with a back arrow and a title. No bottom bar. |
+12 -1
View File
@@ -191,7 +191,18 @@ blocks are noted where they matter:
opacity/blur knob - flat surfaces per the design system), grid tiles are
`NooGridCard`, swipe actions are `NooSwipeAction`, and per-item sync
status is `NooFileRow`/`NooFileTableRow`'s built-in `NooStatusIcon` list
instead of a corner badge.
instead of a corner badge. `NooBottomBar` later gained its own, unrelated
`NooBottomBarStyle.floating` (Settings → Appearance → "Bottom bar") -
don't confuse the two: this one is still flat/non-blurred, just inset
with a `line` border instead of edge to edge (no opacity/blur knob
either). The host `Scaffold` needs `extendBody: true` while it's active
(`main.dart` already wires this off `SettingsController.bottomBarStyle`).
Settings' separate "Search in bottom bar" toggle
(`SettingsController.searchInBottomBar`) adds a never-highlighted Search
entry to either bar style (`NooBottomBar`'s `searchDestination`/
`onSearchTap`) and lowers `SettingsController.maxVisibleTabs` by one -
use that getter, not `defaultMaxVisibleTabs` from `models/app_tab.dart`,
anywhere that needs the *current* cap on regular tabs.
- [`SyncedHeaderScaffold`](../../lib/widgets/synced_header_scaffold.dart) —
the pull-to-sync `CustomScrollView` header with the persistent sync-status
chip and pull-to-refresh gesture/spinner. Every screen (including
+17 -1
View File
@@ -472,13 +472,27 @@ class _MainShellViewState extends State<MainShellView> {
);
} else {
final navStyle = NooLayout.navStyle(context);
final bottomBarStyle = settings.bottomBarStyle;
// Picking/offline already override the tab set itself (see
// pinnedTabs above) - Search doesn't belong in either: there's
// nothing to search for a file-picking flow, and Search needs the
// network Offline mode doesn't have.
final showBottomBarSearch = !overrideActive && settings.searchInBottomBar;
scaffold = Scaffold(
backgroundColor: colors.bg,
drawerScrimColor: colors.scrim,
appBar: pickRequest == null
? AppTopBar(style: navStyle, tab: selectedTab)
? AppTopBar(
style: navStyle,
tab: selectedTab,
searchInBottomBar: showBottomBarSearch,
)
: null,
drawer: pickRequest == null ? const AppDrawer() : null,
// Floating needs the body to draw behind the bar's own transparent
// margin (see NooBottomBarStyle's doc comment) instead of stopping
// short of it like attached does.
extendBody: bottomBarStyle == NooBottomBarStyle.floating,
// Android-only extended Upload FAB - iOS uses the top bar's `plus`
// instead (see AppTopBar). Stays mounted across every tab (picking
// aside) and collapses to an icon-only circle off Files/Photos,
@@ -494,8 +508,10 @@ class _MainShellViewState extends State<MainShellView> {
body: tabStack,
bottomNavigationBar: BottomNavBar(
style: navStyle,
barStyle: bottomBarStyle,
tabs: pinnedTabs,
selectedIndex: pinnedIndex,
onSearchTap: showBottomBarSearch ? () => openSearch(context) : null,
onDestinationSelected: (index) {
final tappedTab = pinnedTabs[index];
if (tappedTab == _currentTab) {
+7 -2
View File
@@ -16,8 +16,13 @@ enum AppTab {
}
/// At most this many tabs may be visible in the bottom nav bar at once —
/// the rest are reachable through the "more" dropdown instead.
const int maxVisibleTabs = 5;
/// the rest are reachable through the "more" dropdown instead. The
/// effective cap a screen should actually use is
/// [SettingsController.maxVisibleTabs], not this directly - it's one
/// lower than this while `searchInBottomBar` is on, since Search then
/// takes the freed-up slot itself rather than competing with the user's
/// own reorderable tabs for one of these.
const int defaultMaxVisibleTabs = 5;
extension AppTabInfo on AppTab {
String get label {
+43
View File
@@ -3,6 +3,7 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../models/app_tab.dart';
import '../models/selection_action.dart';
import '../theme/app_theme.dart';
import '../widgets/noo/nav/noo_nav_style.dart';
/// What swiping a Files list-view item left/right does, user-configurable
/// in Settings.
@@ -30,6 +31,8 @@ class SettingsController extends ChangeNotifier {
static const _prefDefaultTab = 'ui_default_tab';
static const _prefSwipeLeftAction = 'ui_swipe_left_action';
static const _prefSwipeRightAction = 'ui_swipe_right_action';
static const _prefBottomBarStyle = 'ui_bottom_bar_style';
static const _prefSearchInBottomBar = 'ui_search_in_bottom_bar';
static const _prefAmoledDark = 'ui_amoled_dark';
static const _prefMediaProgressBarStyle = 'ui_media_progress_bar_style';
static const _prefTapTabToScrollTop = 'ui_tap_tab_to_scroll_top';
@@ -39,6 +42,8 @@ class SettingsController extends ChangeNotifier {
Color _seedColor = AppTheme.defaultAccent;
ThemeMode _themeMode = ThemeMode.system;
NooBottomBarStyle _bottomBarStyle = NooBottomBarStyle.attached;
bool _searchInBottomBar = false;
bool _useDynamicColor = true;
bool _amoledDark = false;
MediaProgressBarStyle _mediaProgressBarStyle = MediaProgressBarStyle.wavy;
@@ -80,11 +85,21 @@ class SettingsController extends ChangeNotifier {
Color get seedColor => _seedColor;
ThemeMode get themeMode => _themeMode;
NooBottomBarStyle get bottomBarStyle => _bottomBarStyle;
bool get searchInBottomBar => _searchInBottomBar;
bool get useDynamicColor => _useDynamicColor;
bool get amoledDark => _amoledDark;
MediaProgressBarStyle get mediaProgressBarStyle => _mediaProgressBarStyle;
bool get tapTabToScrollTop => _tapTabToScrollTop;
/// The cap a screen should actually enforce for the *regular*, user-
/// reorderable tabs - one below [defaultMaxVisibleTabs] while
/// [searchInBottomBar] is on, since Search then takes that freed-up slot
/// itself (the row's last tab when attached, its own satellite circle
/// when floating - see `NooBottomBar`) rather than counting against it.
int get maxVisibleTabs =>
defaultMaxVisibleTabs - (_searchInBottomBar ? 1 : 0);
/// Every tab in the user's configured order, including hidden ones — used
/// by the reorder/visibility settings UI.
List<AppTab> get tabOrder => _tabOrder;
@@ -115,6 +130,15 @@ class SettingsController extends ChangeNotifier {
orElse: () => ThemeMode.system,
);
}
final bottomBarStyleName = prefs.getString(_prefBottomBarStyle);
if (bottomBarStyleName != null) {
_bottomBarStyle = NooBottomBarStyle.values.firstWhere(
(s) => s.name == bottomBarStyleName,
orElse: () => _bottomBarStyle,
);
}
_searchInBottomBar =
prefs.getBool(_prefSearchInBottomBar) ?? _searchInBottomBar;
_useDynamicColor =
prefs.getBool(_prefUseDynamicColor) ?? _useDynamicColor;
_amoledDark = prefs.getBool(_prefAmoledDark) ?? _amoledDark;
@@ -256,6 +280,25 @@ class SettingsController extends ChangeNotifier {
_prefsFuture.then((p) => p.setString(_prefThemeMode, mode.name));
}
void setBottomBarStyle(NooBottomBarStyle style) {
if (_bottomBarStyle == style) return;
_bottomBarStyle = style;
notifyListeners();
_prefsFuture.then((p) => p.setString(_prefBottomBarStyle, style.name));
}
/// Turning this on lowers [maxVisibleTabs] by one, so re-enforces the cap
/// immediately in case the user already has a full 5 regular tabs pinned
/// - same cleanup [_enforceMaxVisibleTabs] already does for a fresh
/// install/an app update adding a new tab.
void setSearchInBottomBar(bool value) {
if (_searchInBottomBar == value) return;
_searchInBottomBar = value;
if (value) _enforceMaxVisibleTabs();
notifyListeners();
_prefsFuture.then((p) => p.setBool(_prefSearchInBottomBar, value));
}
void setTabOrder(List<AppTab> order) {
_tabOrder = order;
notifyListeners();
+15 -4
View File
@@ -19,13 +19,24 @@ import 'shell/shell_common.dart';
/// [style] is resolved by the caller (`NooLayout.navStyle`) rather than
/// read from context here, since [preferredSize] is a plain getter with no
/// BuildContext - the same reason [NooTopBar] itself takes it as a field.
/// [searchInBottomBar] is [SettingsController.searchInBottomBar], passed in
/// for the same reason: it hides this bar's own search field/icon so
/// there's only ever one search entry point on screen once the bottom bar
/// has taken it over.
class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
final NooNavStyle style;
final AppTab tab;
final bool searchInBottomBar;
const AppTopBar({super.key, required this.style, required this.tab});
const AppTopBar({
super.key,
required this.style,
required this.tab,
this.searchInBottomBar = false,
});
bool get _ios => style == NooNavStyle.ios;
bool get _showSearch => !searchInBottomBar;
@override
Size get preferredSize => NooTopBar(
@@ -34,7 +45,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
// A throwaway placeholder purely to make preferredSize's "is there a
// search slot" check match what build() actually shows - never
// inserted into the tree, so its content doesn't matter.
search: _ios ? const SizedBox.shrink() : null,
search: _ios && _showSearch ? const SizedBox.shrink() : null,
).preferredSize;
@override
@@ -44,7 +55,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
title: tab.label,
onMenu: () => Scaffold.of(context).openDrawer(),
actions: [
if (!_ios)
if (!_ios && _showSearch)
NooTopBarButton(
icon: LucideIcons.search,
tooltip: 'Search',
@@ -58,7 +69,7 @@ class AppTopBar extends StatelessWidget implements PreferredSizeWidget {
),
ShellAvatarButton(hitBox: _ios ? 44 : 48),
],
search: _ios ? const ShellSearchLauncher(ios: true) : null,
search: _ios && _showSearch ? const ShellSearchLauncher(ios: true) : null,
);
}
}
+15
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import 'package:lucide_icons_flutter/lucide_icons.dart';
import '../models/app_tab.dart';
import 'noo/nav/noo_bottom_bar.dart';
@@ -7,30 +8,44 @@ import 'noo/nav/noo_bottom_bar.dart';
/// doesn't need to import `noo/nav` itself. [selectedIndex] may be -1 (no
/// destination highlighted) when the shell is showing a tab opened from the
/// drawer's "More" list that isn't one of the pinned [tabs].
///
/// [onSearchTap] (from Settings' "Search in bottom bar") adds the Search
/// entry [NooBottomBar] itself knows how to place (row's last item when
/// attached, its own satellite when floating) - null leaves the bar
/// exactly as before.
class BottomNavBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<AppTab> tabs;
final int selectedIndex;
final ValueChanged<int> onDestinationSelected;
final VoidCallback? onSearchTap;
const BottomNavBar({
super.key,
required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.tabs,
required this.selectedIndex,
required this.onDestinationSelected,
this.onSearchTap,
});
@override
Widget build(BuildContext context) {
return NooBottomBar(
style: style,
barStyle: barStyle,
destinations: [
for (final tab in tabs)
NooNavDestination(icon: tab.icon, label: tab.label),
],
selectedIndex: selectedIndex,
onSelected: onDestinationSelected,
searchDestination: onSearchTap == null
? null
: const NooNavDestination(icon: LucideIcons.search, label: 'Search'),
onSearchTap: onSearchTap,
);
}
}
+187 -28
View File
@@ -5,8 +5,11 @@ import 'noo_nav_style.dart';
export 'noo_nav_style.dart';
/// Android indicator geometry, shared between the sliding pill and each
/// item's own icon box so they line up exactly.
const double _kAndroidPillTop = 14;
/// item's own icon box so they line up exactly. Floating's own top offset
/// is shorter than attached's - its row is 8px shorter overall (72 vs 80)
/// and there's no edge-to-edge safe-area strip inside it eating into that.
const double _kAttachedPillTop = 14;
const double _kFloatingPillTop = 10;
const double _kAndroidPillWidth = 56;
const double _kAndroidPillHeight = 32;
@@ -23,29 +26,109 @@ const double _kAndroidPillHeight = 32;
/// 56x32 accent-soft pill that slides between tabs as selection moves,
/// rather than popping in/out on the destination item itself.
///
/// [barStyle] (user-configurable in Settings, Appearance) picks between
/// that edge-to-edge [NooBottomBarStyle.attached] bar and
/// [NooBottomBarStyle.floating] - inset 16px from both side edges, 28px
/// corners, a 1px `line` border instead of a shadow (see that enum's own
/// doc comment for why). Floating's Android row is 8px shorter (72 vs 80)
/// and drops its idle tabs' reserved label space - with no label to leave
/// room for, an idle icon just centers in the whole button and renders a
/// touch bigger (27 vs 24px) instead of sitting high with a gap under it.
/// The home indicator / gesture bar area below the row comes from the
/// bottom safe-area inset rather than a fixed 34/20px spacer, so it's
/// right on every device. Meant for `Scaffold.bottomNavigationBar`.
/// right on every device. Meant for `Scaffold.bottomNavigationBar` - the
/// host `Scaffold` needs `extendBody: true` while floating.
///
/// [searchDestination]/[onSearchTap] (set together, from Settings'
/// "Search in bottom bar" - see `DESIGN_SYSTEM.md`'s floating bottom bar
/// entry) add a Search entry that's never highlighted (tapping it pushes
/// `SearchView`, it doesn't select anything) - the row's last item when
/// attached, or its own satellite circle beside the bar when floating,
/// always fully round regardless of the bar's own corner radius.
class NooBottomBar extends StatelessWidget {
final NooNavStyle style;
final NooBottomBarStyle barStyle;
final List<NooNavDestination> destinations;
final int selectedIndex;
final ValueChanged<int> onSelected;
final NooNavDestination? searchDestination;
final VoidCallback? onSearchTap;
const NooBottomBar({
super.key,
required this.style,
this.barStyle = NooBottomBarStyle.attached,
required this.destinations,
required this.selectedIndex,
required this.onSelected,
this.searchDestination,
this.onSearchTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
final ios = style == NooNavStyle.ios;
final floating = barStyle == NooBottomBarStyle.floating;
final hasSearch = searchDestination != null && onSearchTap != null;
// Attached folds Search into the row itself (last item); floating
// gives it a separate satellite circle instead (built below), so the
// row builders only ever see it as a trailing item in the former case.
final rowSearch = hasSearch && !floating ? searchDestination : null;
final row = ios ? _buildIosRow() : _buildAndroidRow(colors);
final row = ios
? _buildIosRow(trailingSearch: rowSearch)
: _buildAndroidRow(
colors,
floating: floating,
trailingSearch: rowSearch,
);
final barHeight = ios ? (floating ? 64.0 : 50.0) : (floating ? 72.0 : 80.0);
if (floating) {
final pill = Container(
height: barHeight,
clipBehavior: Clip.antiAlias,
decoration: BoxDecoration(
color: colors.surface,
border: Border.all(color: colors.line),
borderRadius: BorderRadius.circular(28),
),
child: row,
);
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 12),
child: hasSearch
// A fixed-height SizedBox, not just a Row with
// crossAxisAlignment.stretch - the bottomNavigationBar slot
// gives this widget a *loose* (unbounded-max) height
// constraint, and stretch on an unbounded cross axis blows up
// to fill the screen in profile/release (the assertion that
// would catch it in debug is stripped there) - the same
// silent-oversizing bug files_controls_row.dart hit earlier
// from a different cause. Both children already size
// themselves explicitly (pill's own `height`, the
// satellite's `size`), so stretch was never actually needed.
? SizedBox(
height: barHeight,
child: Row(
children: [
Expanded(child: pill),
const SizedBox(width: 8),
_SearchSatellite(
destination: searchDestination!,
onTap: onSearchTap!,
size: barHeight,
),
],
),
)
: pill,
),
);
}
return Container(
decoration: BoxDecoration(
@@ -54,12 +137,12 @@ class NooBottomBar extends StatelessWidget {
),
child: SafeArea(
top: false,
child: SizedBox(height: ios ? 50 : 80, child: row),
child: SizedBox(height: barHeight, child: row),
),
);
}
Widget _buildIosRow() {
Widget _buildIosRow({NooNavDestination? trailingSearch}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -71,20 +154,34 @@ class NooBottomBar extends StatelessWidget {
onTap: () => onSelected(i),
),
),
if (trailingSearch != null)
Expanded(
child: _IosItem(
destination: trailingSearch,
selected: false,
onTap: onSearchTap!,
),
),
],
);
}
Widget _buildAndroidRow(NooColors colors) {
Widget _buildAndroidRow(
NooColors colors, {
required bool floating,
NooNavDestination? trailingSearch,
}) {
final pillTop = floating ? _kFloatingPillTop : _kAttachedPillTop;
final itemCount = destinations.length + (trailingSearch != null ? 1 : 0);
return LayoutBuilder(
builder: (context, constraints) {
final itemWidth = constraints.maxWidth / destinations.length;
final itemWidth = constraints.maxWidth / itemCount;
return Stack(
children: [
AnimatedPositioned(
duration: NooMotion.base,
curve: NooMotion.ease,
top: _kAndroidPillTop,
top: pillTop,
left:
itemWidth * selectedIndex +
(itemWidth - _kAndroidPillWidth) / 2,
@@ -105,9 +202,19 @@ class NooBottomBar extends StatelessWidget {
child: _AndroidItem(
destination: destinations[i],
selected: i == selectedIndex,
floating: floating,
onTap: () => onSelected(i),
),
),
if (trailingSearch != null)
Expanded(
child: _AndroidItem(
destination: trailingSearch,
selected: false,
floating: floating,
onTap: onSearchTap!,
),
),
],
),
],
@@ -117,6 +224,45 @@ class NooBottomBar extends StatelessWidget {
}
}
/// The floating bar's own Search entry point, next to the pill rather than
/// inside it - always fully round (see [NooBottomBar]'s doc comment),
/// same fill/border as the pill so the two still read as one family.
class _SearchSatellite extends StatelessWidget {
final NooNavDestination destination;
final VoidCallback onTap;
final double size;
const _SearchSatellite({
required this.destination,
required this.onTap,
required this.size,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Semantics(
button: true,
label: destination.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Container(
width: size,
height: size,
alignment: Alignment.center,
decoration: BoxDecoration(
color: colors.surface,
border: Border.all(color: colors.line),
shape: BoxShape.circle,
),
child: Icon(destination.icon, size: 24, color: colors.fg1),
),
),
);
}
}
class _IosItem extends StatelessWidget {
final NooNavDestination destination;
final bool selected;
@@ -165,44 +311,47 @@ class _IosItem extends StatelessWidget {
}
/// Icon (over the shared sliding pill, drawn separately by [NooBottomBar])
/// with its label always below it - the label's space is reserved
/// whether or not it's showing (only its opacity changes), so the icon
/// never shifts vertically as selection changes, and stays aligned with
/// the pill's fixed [_kAndroidPillTop]/[_kAndroidPillHeight].
/// with its label below it. Attached always reserves the label's space
/// (just invisible when idle, per [_kAttachedPillTop]'s doc comment on
/// [NooBottomBar]) so the icon never shifts vertically as selection
/// changes. Floating drops that reserved space when idle instead: there's
/// no label to leave room for, so the icon centers in the whole button and
/// renders a touch bigger.
class _AndroidItem extends StatelessWidget {
final NooNavDestination destination;
final bool selected;
final bool floating;
final VoidCallback onTap;
const _AndroidItem({
required this.destination,
required this.selected,
required this.floating,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: Padding(
padding: const EdgeInsets.only(top: _kAndroidPillTop),
final iconColor = selected ? colors.accentText : colors.fg2;
final Widget content;
if (floating && !selected) {
content = Center(
child: Icon(destination.icon, size: 27, color: iconColor),
);
} else {
content = Padding(
padding: EdgeInsets.only(
top: floating ? _kFloatingPillTop : _kAttachedPillTop,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
children: [
SizedBox(
height: _kAndroidPillHeight,
child: Center(
child: Icon(
destination.icon,
size: 24,
color: selected ? colors.accentText : colors.fg2,
),
child: Icon(destination.icon, size: 24, color: iconColor),
),
),
const SizedBox(height: 4),
@@ -222,7 +371,17 @@ class _AndroidItem extends StatelessWidget {
),
],
),
),
);
}
return Semantics(
button: true,
selected: selected,
label: destination.label,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: onTap,
child: content,
),
);
}
+12
View File
@@ -17,6 +17,18 @@ enum NooNavStyle {
};
}
/// How [NooBottomBar] sits on the screen - user-configurable in Settings
/// (Appearance). [attached] is the original edge-to-edge bar with no
/// side/bottom margin. [floating] insets it 16px from both side edges and
/// clear of the bottom safe area, rounded (28px - the same radius the
/// sheet top/drawer edge use) rather than square, with a 1px `line` border
/// standing in for elevation instead of a shadow (product UI stays flat -
/// DESIGN_SYSTEM.md 1.4 - so this is the one place that border does that
/// job). The host `Scaffold` needs `extendBody: true` while floating, so
/// its body scrolls behind the bar's transparent margin instead of
/// stopping short of it like [attached] does.
enum NooBottomBarStyle { attached, floating }
/// One destination in a [NooBottomBar]. Data-agnostic on purpose - the app
/// maps its own tab model (e.g. `AppTab`) onto these.
@immutable
+75 -1
View File
@@ -7,6 +7,7 @@ import '../../theme/design_tokens.dart';
import '../noo/core/noo_segmented_control.dart';
import '../noo/core/noo_toggle.dart';
import '../noo/lists/noo_settings_row.dart';
import '../noo/nav/noo_nav_style.dart';
import '../noo/noo_layout.dart';
import '../noo/overlays/noo_dialog.dart';
import '../noo/overlays/noo_sheet.dart';
@@ -47,7 +48,22 @@ class SettingsAppearanceSection extends StatelessWidget {
children: [
SettingsSection(
title: 'Appearance',
children: [_ThemeRow(settings: settings)],
children: [
_ThemeRow(settings: settings),
_BottomBarStyleRow(settings: settings),
NooSettingsRow(
icon: LucideIcons.search,
label: const Text('Search in bottom bar'),
subtitle: const Text(
'Adds Search to the bottom bar and removes it from the top '
'bar - leaves room for one fewer regular tab',
),
trailing: NooToggle(
checked: settings.searchInBottomBar,
onChanged: settings.setSearchInBottomBar,
),
),
],
),
const SizedBox(height: NooSpace.xl),
SettingsSection(
@@ -156,6 +172,64 @@ class _ThemeRow extends StatelessWidget {
}
}
/// The "Bottom bar" row: same "label, then a full-width segmented control
/// below it" shape as [_ThemeRow] right above it. Attached is the original
/// edge-to-edge bar; floating insets it from both side/bottom edges with
/// rounded corners - see [NooBottomBarStyle]'s own doc comment for the
/// full visual rationale.
class _BottomBarStyleRow extends StatelessWidget {
final SettingsController settings;
const _BottomBarStyleRow({required this.settings});
@override
Widget build(BuildContext context) {
final colors = context.nooColors;
return Material(
color: colors.surface,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NooSpace.md,
vertical: 12,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(LucideIcons.panelBottom, size: 20, color: colors.fg2),
const SizedBox(width: 14),
Text(
'Bottom bar',
style: NooText.bodyL.copyWith(color: colors.fg1),
),
],
),
const SizedBox(height: 12),
NooSegmentedControl<NooBottomBarStyle>(
fill: true,
value: settings.bottomBarStyle,
onChanged: settings.setBottomBarStyle,
options: const [
NooSegmentOption(
value: NooBottomBarStyle.attached,
icon: LucideIcons.panelBottom,
label: 'Attached',
),
NooSegmentOption(
value: NooBottomBarStyle.floating,
icon: LucideIcons.panelBottomOpen,
label: 'Floating',
),
],
),
],
),
),
);
}
}
/// The current accent-color choice, shown as a small filled circle in the
/// "Accent color" row - a wallpaper icon when following the OS's dynamic
/// color instead of a fixed swatch.
+2 -1
View File
@@ -11,7 +11,8 @@ import '../noo/noo_layout.dart';
import 'settings_section.dart';
/// Settings section 7: bottom-nav tab order, visibility (pinned = shown, up
/// to [maxVisibleTabs]) and default tab.
/// to [SettingsController.maxVisibleTabs] - one fewer while Appearance's
/// "Search in bottom bar" is on) and default tab.
class SettingsTabsSection extends StatelessWidget {
const SettingsTabsSection({super.key});
+200
View File
@@ -137,5 +137,205 @@ void main() {
final labelCenter = tester.getCenter(find.text('Photos'));
expect(labelCenter.dy, greaterThan(iconCenter.dy));
});
testNooWidgets('floating: inset, rounded, bordered - not edge to edge', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
final pillFinder = find.descendant(
of: find.byType(NooBottomBar),
matching: find.byWidgetPredicate(
(w) => w is Container && w.decoration != null,
),
);
final container = tester.widget<Container>(pillFinder);
final decoration = container.decoration as BoxDecoration;
expect(decoration.borderRadius, BorderRadius.circular(28));
expect(decoration.border, Border.all(color: c.line));
// Inset from both side edges of the bar's own host box, not flush
// against it - both rects are in the same (global) coordinate space,
// so comparing them directly still holds regardless of where
// `pumpNoo`'s own harness centers that host box on screen.
final hostRect = tester.getRect(find.byType(NooBottomBar));
final rect = tester.getRect(pillFinder);
expect(rect.left, greaterThan(hostRect.left));
expect(rect.right, lessThan(hostRect.right));
});
testNooWidgets(
'floating android: idle icon has no reserved label space and is '
'bigger than the active one',
(tester, theme, c) async {
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
),
),
theme: theme,
);
// Unlike attached, an idle floating tab has no label at all - not
// just an invisible one - so there's nothing to find here.
expect(find.text('Photos'), findsNothing);
expect(
find.ancestor(
of: find.byIcon(LucideIcons.images),
matching: find.byType(AnimatedOpacity),
),
findsNothing,
);
final activeIcon = tester.widget<Icon>(find.byIcon(LucideIcons.folder));
final idleIcon = tester.widget<Icon>(find.byIcon(LucideIcons.images));
expect(idleIcon.size, greaterThan(activeIcon.size!));
},
);
testNooWidgets(
'attached: search is the row\'s last item and never highlights',
(tester, theme, c) async {
var searchTaps = 0;
var selected = 0;
await pumpNoo(
tester,
StatefulBuilder(
builder: (context, setState) => SizedBox(
width: 360,
height: 80,
child: NooBottomBar(
style: NooNavStyle.android,
destinations: _destinations,
selectedIndex: selected,
onSelected: (i) => setState(() => selected = i),
searchDestination: const NooNavDestination(
icon: LucideIcons.search,
label: 'Search',
),
onSearchTap: () => searchTaps++,
),
),
),
theme: theme,
);
expect(find.byIcon(LucideIcons.search), findsOneWidget);
final iconCenters = [
tester.getCenter(find.byIcon(LucideIcons.folder)),
tester.getCenter(find.byIcon(LucideIcons.images)),
tester.getCenter(find.byIcon(LucideIcons.star)),
tester.getCenter(find.byIcon(LucideIcons.search)),
];
// Evenly spaced, search landing after every real destination.
for (var i = 1; i < iconCenters.length; i++) {
expect(iconCenters[i].dx, greaterThan(iconCenters[i - 1].dx));
}
await tester.tap(find.byIcon(LucideIcons.search));
await tester.pump();
expect(searchTaps, 1);
// Tapping search never selects it or moves the indicator.
expect(selected, 0);
expect(_indicator(tester, c), findsOneWidget);
final indicatorLeft = tester.getTopLeft(_indicator(tester, c)).dx;
expect(indicatorLeft, lessThan(iconCenters[3].dx));
},
);
testNooWidgets('floating: search is a separate round satellite', (
tester,
theme,
c,
) async {
var searchTaps = 0;
await pumpNoo(
tester,
SizedBox(
width: 360,
height: 96,
child: NooBottomBar(
style: NooNavStyle.ios,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
searchDestination: const NooNavDestination(
icon: LucideIcons.search,
label: 'Search',
),
onSearchTap: () => searchTaps++,
),
),
theme: theme,
);
// Not part of the main pill - its own separately-tappable icon.
expect(find.text('Search'), findsNothing);
final satellite = tester.widget<Container>(
find.ancestor(
of: find.byIcon(LucideIcons.search),
matching: find.byType(Container),
),
);
final decoration = satellite.decoration as BoxDecoration;
expect(decoration.shape, BoxShape.circle);
await tester.tap(find.byIcon(LucideIcons.search));
await tester.pump();
expect(searchTaps, 1);
});
testWidgets('floating with search sizes correctly in a real Scaffold '
'(regression: unbounded stretch used to fill the whole screen)', (
tester,
) async {
// A bare SizedBox host (as the other tests use) gives this widget a
// bounded height, masking this exact bug - Scaffold.bottomNavigationBar
// gives its child a *loose*, unbounded-max height instead, which is
// what a Row with crossAxisAlignment.stretch blows up against in
// profile/release (the debug assertion that would catch it is
// stripped there). Only a real Scaffold reproduces that.
await tester.pumpWidget(
MaterialApp(
theme: nooTheme(Brightness.light),
home: Scaffold(
body: const SizedBox.expand(),
bottomNavigationBar: NooBottomBar(
style: NooNavStyle.android,
barStyle: NooBottomBarStyle.floating,
destinations: _destinations,
selectedIndex: 0,
onSelected: (_) {},
searchDestination: const NooNavDestination(
icon: LucideIcons.search,
label: 'Search',
),
onSearchTap: () {},
),
),
),
);
final size = tester.getSize(find.byType(NooBottomBar));
expect(size.height, lessThan(150));
});
});
}
+38
View File
@@ -0,0 +1,38 @@
# Noo website
The product site for Noo, meant to be deployed at `noo.ayushya.dev`.
Static HTML/CSS/vanilla JS, no build step and no dependencies beyond the
two Google Fonts already used by the app (Schibsted Grotesk, Instrument
Sans). Colors, radii, type scale and motion timing are copied from
`lib/theme/design_tokens.dart` so the site and the app read as the same
product.
## Files
- `index.html` — the whole page (hero, features, customize, privacy, CTA).
- `styles.css` — all styling, incl. the design tokens as CSS custom
properties and a dark-mode variant (`prefers-color-scheme` and an
explicit `data-theme` override for a future toggle).
- `script.js` — mobile nav, the hero's exploded-layer parallax
(scroll + pointer), the two interactive Customize demos (accent color,
bottom bar style), and scroll-reveal.
- `favicon.svg`.
## Local preview
Any static file server works, e.g.:
```bash
cd website
python3 -m http.server 8000
```
Then open `http://localhost:8000`.
## Deploying
Point any static host (Cloudflare Pages, Netlify, GitHub Pages, or a
plain nginx `root`) at this folder - there's nothing to build. Update the
`https://github.com` placeholder links and the two "Download for..."
buttons in `index.html` once real release links exist.
+4
View File
@@ -0,0 +1,4 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<rect width="32" height="32" rx="9" fill="#8D0DE3"/>
<rect x="9" y="9" width="14" height="14" rx="5" fill="#FFFFFF" fill-opacity="0.92"/>
</svg>

After

Width:  |  Height:  |  Size: 210 B

+307
View File
@@ -0,0 +1,307 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Noo — a Nextcloud client that feels like it belongs on your phone</title>
<meta name="description" content="Noo is a fast, private, deeply customizable Nextcloud client for Android and iOS. Browse files and photos, sync for offline, and make it look like yours.">
<meta property="og:title" content="Noo — a Nextcloud client that feels like it belongs on your phone">
<meta property="og:description" content="Fast, private, and deeply customizable. Browse files and photos, sync for offline, and make Noo look like yours.">
<meta property="og:type" content="website">
<meta property="og:url" content="https://noo.ayushya.dev">
<meta name="theme-color" content="#F3EFE6">
<link rel="icon" href="favicon.svg" type="image/svg+xml">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600&family=Schibsted+Grotesk:wght@500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<a class="skip-link" href="#main">Skip to content</a>
<header class="site-header">
<div class="wrap header-row">
<a class="wordmark" href="#top">
<span class="wordmark-mark" aria-hidden="true"></span>
noo
</a>
<nav class="site-nav" aria-label="Primary">
<a href="#features">Features</a>
<a href="#customize">Customize</a>
<a href="#privacy">Privacy</a>
</nav>
<a class="btn btn-primary btn-sm" href="#get">Get Noo</a>
<button class="nav-toggle" id="navToggle" aria-label="Open menu" aria-expanded="false">
<span></span><span></span><span></span>
</button>
</div>
<nav class="mobile-nav" id="mobileNav" aria-label="Primary">
<a href="#features">Features</a>
<a href="#customize">Customize</a>
<a href="#privacy">Privacy</a>
<a class="btn btn-primary btn-sm" href="#get">Get Noo</a>
</nav>
</header>
<main id="main">
<!-- ============ HERO ============ -->
<section class="hero" id="top">
<div class="wrap hero-grid">
<div class="hero-copy">
<p class="eyebrow">For Nextcloud, unofficial</p>
<h1>Your cloud.<br>Looking like <em>yours</em>.</h1>
<p class="hero-sub">
Noo is a fast, private Nextcloud client that browses files and
photos the way a native app should — and lets you rebuild its
whole shell around how you actually use your phone.
</p>
<div class="hero-actions">
<a class="btn btn-primary btn-lg" href="#get">Get Noo</a>
<a class="btn btn-outline btn-lg" href="https://github.com" target="_blank" rel="noopener">View source</a>
</div>
<ul class="hero-proof">
<li>No password stored — Login Flow v2</li>
<li>Offline device sync</li>
<li>Open source</li>
</ul>
</div>
<div class="hero-visual" id="heroVisual" aria-hidden="true">
<div class="glow"></div>
<div class="phone" data-depth="0.15">
<div class="phone-notch"></div>
<div class="phone-screen">
<div class="mini-topbar">
<span class="mini-title">Files</span>
<span class="mini-dot"></span>
</div>
<div class="mini-list">
<div class="mini-row"><span class="mini-ic ic-folder"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row"><span class="mini-ic ic-image"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row mini-row-active"><span class="mini-ic ic-file"></span><span class="mini-lines"><b></b><i></i></span></div>
<div class="mini-row"><span class="mini-ic ic-folder"></span><span class="mini-lines"><b></b><i></i></span></div>
</div>
<div class="mini-bar">
<span class="mini-bar-pill"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
<span class="mini-bar-dot"></span>
</div>
</div>
</div>
<div class="chip chip-photo" data-depth="0.55">
<div class="chip-photo-grid">
<span></span><span></span><span></span><span></span>
</div>
</div>
<div class="chip chip-row" data-depth="0.4">
<span class="mini-ic ic-share"></span>
<span class="mini-lines"><b></b><i></i></span>
</div>
<div class="chip chip-swatches" data-depth="0.7">
<span class="swatch" style="--c:#8D0DE3"></span>
<span class="swatch" style="--c:#2940A8"></span>
<span class="swatch" style="--c:#3E7A23"></span>
<span class="swatch" style="--c:#8C5A0E"></span>
</div>
<div class="chip chip-toggle" data-depth="0.5">
<span class="mini-lines mini-lines-toggle"><b></b><i></i></span>
<span class="mini-toggle mini-toggle-on"><i></i></span>
</div>
<div class="chip chip-badge" data-depth="0.85">
<span class="mini-ic ic-lock"></span>
Offline ready
</div>
</div>
</div>
</section>
<!-- ============ FEATURES ============ -->
<section class="section" id="features">
<div class="wrap">
<p class="eyebrow">Features</p>
<h2>Everything your Nextcloud does. Nothing it doesn't.</h2>
<p class="section-sub">
Files, photos, sharing and sync — built as a real mobile app, not a
browser tab pinned to your home screen.
</p>
<div class="grid grid-features">
<article class="card">
<span class="card-ic ic-folder-lg"></span>
<h3>Files, browsed properly</h3>
<p>Grid or list, sorted your way, with the same filters and
controls whether you're picking a file, uploading, or moving
one into place.</p>
</article>
<article class="card">
<span class="card-ic ic-image-lg"></span>
<h3>Photos, grouped by month</h3>
<p>Every image and video across your account in one gallery —
filter to just photos, just videos, or just favorites.</p>
</article>
<article class="card">
<span class="card-ic ic-download-lg"></span>
<h3>Offline, for real</h3>
<p>Pick folders to mirror locally and keep browsing — reading,
opening, previewing — with no connection at all.</p>
</article>
<article class="card">
<span class="card-ic ic-share-lg"></span>
<h3>Sharing, without the detour</h3>
<p>Share with people or a public link, see who has access, and
copy that link — all from one sheet, not three menus.</p>
</article>
<article class="card">
<span class="card-ic ic-clock-lg"></span>
<h3>Activity &amp; recent</h3>
<p>What changed, and where — grouped into today, yesterday and
this week, so catching up takes seconds.</p>
</article>
<article class="card">
<span class="card-ic ic-users-lg"></span>
<h3>Multiple accounts</h3>
<p>Switch between personal and work Nextcloud instances with a
swipe — each with its own sync, cache and lock.</p>
</article>
</div>
</div>
</section>
<!-- ============ CUSTOMIZE ============ -->
<section class="section section-alt" id="customize">
<div class="wrap">
<p class="eyebrow">Customize</p>
<h2>Shape it around your thumb, not the other way round.</h2>
<p class="section-sub">
Every screen bends to a handful of real settings — try a few
right here.
</p>
<div class="customize-grid">
<div class="customize-demo" id="accentDemo">
<div class="demo-preview">
<div class="demo-topbar">
<span class="demo-dot" style="background:var(--demo-accent)"></span>
<span class="demo-line" style="width:38%"></span>
</div>
<div class="demo-pill" style="background:var(--surface3); background:color-mix(in srgb, var(--demo-accent) 16%, transparent); color:var(--demo-accent)">Active tab</div>
<div class="demo-row"><span class="demo-line" style="width:60%"></span></div>
<div class="demo-row"><span class="demo-line" style="width:44%"></span></div>
</div>
<div class="demo-controls">
<h3>Pick your accent</h3>
<p>Six built-in colors, or match your wallpaper with dynamic
color on Android.</p>
<div class="swatch-row" role="group" aria-label="Accent color">
<button class="swatch-btn is-active" style="--c:#8D0DE3" data-accent="#8D0DE3" aria-label="Violet"></button>
<button class="swatch-btn" style="--c:#2940A8" data-accent="#2940A8" aria-label="Blue"></button>
<button class="swatch-btn" style="--c:#3E7A23" data-accent="#3E7A23" aria-label="Green"></button>
<button class="swatch-btn" style="--c:#8C5A0E" data-accent="#8C5A0E" aria-label="Amber"></button>
<button class="swatch-btn" style="--c:#A8202A" data-accent="#A8202A" aria-label="Red"></button>
<button class="swatch-btn" style="--c:#0E0D0B" data-accent="#0E0D0B" aria-label="Wallpaper"></button>
</div>
</div>
</div>
<div class="customize-demo" id="barDemo">
<div class="demo-preview demo-preview-bar">
<div class="demo-screen-fill"></div>
<div class="demo-bar" id="demoBar">
<span class="demo-bar-item is-on"></span>
<span class="demo-bar-item"></span>
<span class="demo-bar-item"></span>
<span class="demo-bar-item"></span>
</div>
</div>
<div class="demo-controls">
<h3>Floating or attached</h3>
<p>The bottom bar can sit flush with the edge, or float free
with rounded corners — your call.</p>
<div class="segmented" role="group" aria-label="Bottom bar style">
<button class="segmented-btn" data-bar="attached">Attached</button>
<button class="segmented-btn is-active" data-bar="floating">Floating</button>
</div>
</div>
</div>
<div class="customize-list">
<h3>And the rest</h3>
<ul class="feature-list">
<li>Reorder tabs, hide the ones you never open</li>
<li>Move Search into the bottom bar</li>
<li>Light, dark, or match the system</li>
<li>Pure black backgrounds in dark mode</li>
<li>Choose what swiping a file left or right does</li>
<li>Reorder the bulk-action bar for multi-select</li>
</ul>
</div>
</div>
</div>
</section>
<!-- ============ PRIVACY ============ -->
<section class="section" id="privacy">
<div class="wrap privacy-grid">
<div>
<p class="eyebrow">Privacy</p>
<h2>It's your server. Noo just visits.</h2>
<p class="section-sub">
Noo never asks for your password. Signing in hands off to your
Nextcloud server's own login page (Login Flow v2), which returns
a revocable app token — not your credentials. Nothing but that
token, and whatever you choose to sync, ever leaves your device.
</p>
</div>
<ul class="privacy-points">
<li><span class="pt-ic ic-lock"></span> No password stored on device</li>
<li><span class="pt-ic ic-server"></span> Talks only to your own server</li>
<li><span class="pt-ic ic-code"></span> Open source, auditable</li>
<li><span class="pt-ic ic-off"></span> No analytics, no trackers</li>
</ul>
</div>
</section>
<!-- ============ GET ============ -->
<section class="section section-cta" id="get">
<div class="wrap cta-inner">
<h2>Bring your Nextcloud with you.</h2>
<p class="section-sub">Free and open source. Point it at your server and go.</p>
<div class="hero-actions cta-actions">
<a class="btn btn-primary btn-lg" href="#">Download for Android</a>
<a class="btn btn-outline btn-lg" href="#">Download for iOS</a>
</div>
<p class="cta-fine">Or <a href="https://github.com" target="_blank" rel="noopener">build it from source</a> — it's just Flutter.</p>
</div>
</section>
</main>
<footer class="site-footer">
<div class="wrap footer-row">
<a class="wordmark wordmark-sm" href="#top">
<span class="wordmark-mark" aria-hidden="true"></span>
noo
</a>
<p class="footer-note">Noo is an independent, unofficial Nextcloud client. Nextcloud is a trademark of Nextcloud GmbH.</p>
<nav class="footer-links" aria-label="Footer">
<a href="https://github.com" target="_blank" rel="noopener">GitHub</a>
<a href="#privacy">Privacy</a>
</nav>
</div>
</footer>
<script src="script.js"></script>
</body>
</html>
+141
View File
@@ -0,0 +1,141 @@
(() => {
"use strict";
/* ---------- mobile nav ---------- */
const navToggle = document.getElementById("navToggle");
const mobileNav = document.getElementById("mobileNav");
if (navToggle && mobileNav) {
navToggle.addEventListener("click", () => {
const open = mobileNav.classList.toggle("is-open");
navToggle.setAttribute("aria-expanded", String(open));
navToggle.setAttribute("aria-label", open ? "Close menu" : "Open menu");
});
mobileNav.querySelectorAll("a").forEach((a) =>
a.addEventListener("click", () => {
mobileNav.classList.remove("is-open");
navToggle.setAttribute("aria-expanded", "false");
})
);
}
/* ---------- exploded hero parallax ---------- */
/* Each [data-depth] layer drifts at its own rate on scroll, and tilts
gently toward the pointer - the "exploded" pieces read as separate
depths rather than a flat collage. Skipped entirely under
prefers-reduced-motion. */
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const heroVisual = document.getElementById("heroVisual");
if (heroVisual && !reduceMotion) {
const layers = Array.from(heroVisual.querySelectorAll("[data-depth]")).map((el) => ({
el,
depth: parseFloat(el.dataset.depth) || 0.3,
}));
let scrollOffset = 0;
let pointer = { x: 0, y: 0 }; // -1..1
let ticking = false;
const apply = () => {
const rect = heroVisual.getBoundingClientRect();
const inView = rect.bottom > 0 && rect.top < window.innerHeight;
if (inView) {
for (const layer of layers) {
const scrollShift = scrollOffset * layer.depth * 40;
const px = pointer.x * layer.depth * 14;
const py = pointer.y * layer.depth * 14;
layer.el.style.transform =
`translate3d(${px}px, ${scrollShift + py}px, 0)`;
}
}
ticking = false;
};
const requestTick = () => {
if (!ticking) {
ticking = true;
requestAnimationFrame(apply);
}
};
const onScroll = () => {
const rect = heroVisual.getBoundingClientRect();
// -1 (scrolled well past) .. 1 (below viewport) - 0 while centered.
scrollOffset = (window.innerHeight / 2 - (rect.top + rect.height / 2)) / window.innerHeight;
requestTick();
};
const onPointerMove = (e) => {
const rect = heroVisual.getBoundingClientRect();
pointer.x = ((e.clientX - rect.left) / rect.width) * 2 - 1;
pointer.y = ((e.clientY - rect.top) / rect.height) * 2 - 1;
requestTick();
};
const onPointerLeave = () => {
pointer = { x: 0, y: 0 };
requestTick();
};
window.addEventListener("scroll", onScroll, { passive: true });
heroVisual.addEventListener("pointermove", onPointerMove);
heroVisual.addEventListener("pointerleave", onPointerLeave);
onScroll();
}
/* ---------- accent color demo ---------- */
const accentDemo = document.getElementById("accentDemo");
if (accentDemo) {
const buttons = accentDemo.querySelectorAll(".swatch-btn");
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
const color = btn.dataset.accent;
accentDemo.style.setProperty("--demo-accent", color);
});
});
}
/* ---------- bottom bar style demo ---------- */
const barDemo = document.getElementById("barDemo");
const demoBar = document.getElementById("demoBar");
if (barDemo && demoBar) {
const buttons = barDemo.querySelectorAll(".segmented-btn");
buttons.forEach((btn) => {
btn.addEventListener("click", () => {
buttons.forEach((b) => b.classList.remove("is-active"));
btn.classList.add("is-active");
demoBar.classList.toggle("is-attached", btn.dataset.bar === "attached");
});
});
}
/* ---------- scroll reveal ---------- */
const revealTargets = document.querySelectorAll(
".card, .customize-demo, .customize-list, .privacy-points li"
);
revealTargets.forEach((el) => el.classList.add("reveal"));
if ("IntersectionObserver" in window && !reduceMotion) {
const io = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
entry.target.classList.add("is-visible");
io.unobserve(entry.target);
}
}
},
{ threshold: 0.15, rootMargin: "0px 0px -40px 0px" }
);
revealTargets.forEach((el) => io.observe(el));
} else {
revealTargets.forEach((el) => el.classList.add("is-visible"));
}
})();
+758
View File
@@ -0,0 +1,758 @@
/* Noo marketing site — tokens mirror lib/theme/design_tokens.dart exactly,
so this reads as the same product, not a separate marketing skin. */
:root {
--bg: #F3EFE6;
--surface: #FFFFFF;
--surface2: #F6F5F2;
--surface3: #EAE8E2;
--line: #EAE8E2;
--fg1: #0E0D0B;
--fg2: #5A5852;
--fg3: #7C7A72;
--accent: #8D0DE3;
--accent-text: #8D0DE3;
--accent-soft: #ECDFFF;
--danger: #A8202A;
--demo-accent: #8D0DE3;
--font-display: 'Schibsted Grotesk', system-ui, sans-serif;
--font-body: 'Instrument Sans', system-ui, sans-serif;
--ease: cubic-bezier(0.7, 0, 0.15, 1);
--motion-fast: 180ms;
--motion-base: 280ms;
--radius-pill: 999px;
--radius-card: 20px;
--radius-input: 14px;
--radius-panel: 28px;
--gutter: clamp(20px, 5vw, 64px);
--content-max: 1120px;
color-scheme: light;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0E0D0B;
--surface: #25241F;
--surface2: #3D3C38;
--surface3: #5A5852;
--line: #3D3C38;
--fg1: #F3EFE6;
--fg2: #BDBAB1;
--fg3: #9E9B92;
--accent: #8D0DE3;
--accent-text: #CBA5FD;
--accent-soft: #2F0454;
--danger: #F49E9E;
color-scheme: dark;
}
}
:root[data-theme="dark"] {
--bg: #0E0D0B;
--surface: #25241F;
--surface2: #3D3C38;
--surface3: #5A5852;
--line: #3D3C38;
--fg1: #F3EFE6;
--fg2: #BDBAB1;
--fg3: #9E9B92;
--accent: #8D0DE3;
--accent-text: #CBA5FD;
--accent-soft: #2F0454;
--danger: #F49E9E;
color-scheme: dark;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
background: var(--bg);
color: var(--fg1);
font-family: var(--font-body);
font-size: 16px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
img, svg { display: block; max-width: 100%; }
a { color: inherit; }
h1, h2, h3 {
font-family: var(--font-display);
font-weight: 600;
margin: 0;
letter-spacing: -0.02em;
}
.wrap {
max-width: var(--content-max);
margin: 0 auto;
padding-inline: var(--gutter);
}
.skip-link {
position: absolute;
left: -9999px;
top: 0;
background: var(--accent);
color: #fff;
padding: 10px 16px;
border-radius: var(--radius-input);
z-index: 100;
}
.skip-link:focus { left: 16px; top: 16px; }
.eyebrow {
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent-text);
margin: 0 0 12px;
}
/* ---------- buttons ---------- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-family: var(--font-body);
font-weight: 600;
text-decoration: none;
border-radius: var(--radius-pill);
border: 1px solid transparent;
cursor: pointer;
transition: transform var(--motion-fast) var(--ease), opacity var(--motion-fast) var(--ease), background var(--motion-fast) var(--ease);
white-space: nowrap;
}
.btn:active { transform: scale(0.98); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-primary:hover { opacity: 0.92; }
.btn-outline { background: transparent; color: var(--fg1); border-color: var(--line); }
.btn-outline:hover { background: var(--surface2); }
.btn-lg { padding: 14px 26px; font-size: 16px; }
.btn-sm { padding: 9px 18px; font-size: 14px; }
/* ---------- header ---------- */
.site-header {
position: sticky;
top: 0;
z-index: 40;
background: var(--bg);
border-bottom: 1px solid var(--line);
}
.header-row {
display: flex;
align-items: center;
gap: 24px;
height: 72px;
}
.wordmark {
display: flex;
align-items: center;
gap: 10px;
font-family: var(--font-display);
font-weight: 700;
font-size: 20px;
text-decoration: none;
color: var(--fg1);
margin-right: auto;
}
.wordmark-sm { font-size: 17px; }
.wordmark-mark {
width: 26px;
height: 26px;
border-radius: 8px;
background: var(--accent);
position: relative;
flex: none;
}
.wordmark-mark::after {
content: "";
position: absolute;
inset: 7px;
border-radius: 4px;
background: #fff;
opacity: 0.92;
}
.site-nav {
display: flex;
gap: 28px;
}
.site-nav a {
font-size: 15px;
font-weight: 500;
text-decoration: none;
color: var(--fg2);
transition: color var(--motion-fast) var(--ease);
}
.site-nav a:hover { color: var(--fg1); }
.nav-toggle {
display: none;
flex-direction: column;
justify-content: center;
gap: 4px;
width: 40px;
height: 40px;
border: none;
background: none;
cursor: pointer;
margin-left: -6px;
}
.nav-toggle span {
display: block;
height: 2px;
border-radius: 2px;
background: var(--fg1);
transition: transform var(--motion-base) var(--ease), opacity var(--motion-base) var(--ease);
}
.nav-toggle[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
.nav-toggle[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
.nav-toggle[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
.mobile-nav {
display: none;
flex-direction: column;
gap: 4px;
padding: 8px var(--gutter) 20px;
border-top: 1px solid var(--line);
}
.mobile-nav a {
padding: 12px 4px;
text-decoration: none;
color: var(--fg1);
font-weight: 500;
border-bottom: 1px solid var(--line);
}
.mobile-nav a:last-child { border-bottom: none; margin-top: 8px; text-align: center; }
.mobile-nav.is-open { display: flex; }
@media (max-width: 760px) {
.site-nav, .header-row > .btn { display: none; }
.nav-toggle { display: flex; }
}
/* ---------- hero ---------- */
.hero { padding: clamp(48px, 8vw, 96px) 0 clamp(40px, 6vw, 72px); overflow: clip; }
.hero-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: clamp(32px, 6vw, 64px);
align-items: center;
}
.hero-copy h1 {
font-size: clamp(38px, 5.2vw, 60px);
line-height: 1.02;
margin-bottom: 20px;
}
.hero-copy h1 em {
font-style: normal;
color: var(--accent-text);
}
.hero-sub {
font-size: clamp(16px, 1.6vw, 18px);
color: var(--fg2);
max-width: 46ch;
margin: 0 0 28px;
}
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; }
.hero-proof {
list-style: none;
margin: 28px 0 0;
padding: 0;
display: flex;
gap: 20px;
flex-wrap: wrap;
font-size: 13px;
color: var(--fg3);
}
.hero-proof li { display: flex; align-items: center; gap: 7px; }
.hero-proof li::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
}
/* ---------- hero exploded visual ---------- */
.hero-visual {
position: relative;
height: clamp(360px, 46vw, 520px);
display: flex;
align-items: center;
justify-content: center;
}
.glow {
position: absolute;
inset: -10%;
background: radial-gradient(closest-side, color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%);
pointer-events: none;
}
.phone {
position: relative;
width: clamp(180px, 19vw, 220px);
aspect-ratio: 9 / 18.5;
background: var(--fg1);
border-radius: 34px;
padding: 10px;
will-change: transform;
}
.phone-notch {
position: absolute;
top: 10px; left: 50%;
transform: translateX(-50%);
width: 34%; height: 16px;
background: var(--fg1);
border-radius: 0 0 12px 12px;
z-index: 2;
}
.phone-screen {
position: relative;
height: 100%;
background: var(--bg);
border-radius: 24px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.mini-topbar {
display: flex;
align-items: center;
justify-content: space-between;
padding: 22px 14px 8px;
}
.mini-title { font-family: var(--font-display); font-weight: 600; font-size: 15px; color: var(--fg1); }
.mini-dot { width: 16px; height: 16px; border-radius: 50%; background: var(--surface2); }
.mini-list { flex: 1; padding: 4px 10px; display: flex; flex-direction: column; gap: 6px; }
.mini-row {
display: flex;
align-items: center;
gap: 8px;
background: var(--surface);
border-radius: 10px;
padding: 7px 8px;
}
.mini-row-active { background: var(--accent-soft); }
.mini-ic { width: 16px; height: 16px; border-radius: 5px; flex: none; }
.ic-folder { background: var(--accent); opacity: 0.85; }
.ic-image { background: #2940A8; opacity: 0.8; }
.ic-file { background: var(--fg2); opacity: 0.6; }
.ic-share { background: #3E7A23; opacity: 0.85; }
.ic-lock { background: var(--fg1); opacity: 0.9; }
.mini-lines { display: flex; flex-direction: column; gap: 4px; flex: 1; min-width: 0; }
.mini-lines b, .mini-lines i {
display: block;
height: 5px;
border-radius: 3px;
background: var(--fg2);
opacity: 0.35;
font-style: normal;
}
.mini-lines b { width: 70%; opacity: 0.55; }
.mini-lines i { width: 40%; }
.mini-bar {
display: flex;
align-items: center;
justify-content: space-around;
padding: 10px 8px;
border-top: 1px solid var(--line);
position: relative;
}
.mini-bar-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--fg3); opacity: 0.4; }
.mini-bar-pill {
position: absolute;
left: 10px;
width: 20px; height: 10px;
border-radius: var(--radius-pill);
background: var(--accent-soft);
}
.chip {
position: absolute;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-card);
will-change: transform;
}
.chip-photo {
top: 6%; right: 2%;
width: 92px; height: 92px;
padding: 8px;
transform: rotate(-6deg);
}
.chip-photo-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; height: 100%; }
.chip-photo-grid span:nth-child(1) { background: #8DCDE2; border-radius: 8px; }
.chip-photo-grid span:nth-child(2) { background: #E5D8BD; border-radius: 8px; }
.chip-photo-grid span:nth-child(3) { background: #DA9AC7; border-radius: 8px; }
.chip-photo-grid span:nth-child(4) { background: #A7D296; border-radius: 8px; }
.chip-row {
bottom: 20%; right: -2%;
display: flex; align-items: center; gap: 10px;
padding: 12px 16px;
transform: rotate(4deg);
width: 160px;
}
.chip-swatches {
bottom: 6%; left: 4%;
display: flex; gap: 8px;
padding: 12px;
transform: rotate(-4deg);
}
.chip-swatches .swatch { width: 18px; height: 18px; border-radius: 50%; background: var(--c); }
.chip-toggle {
top: 16%; left: -4%;
display: flex; align-items: center; gap: 14px;
padding: 12px 14px;
width: 150px;
transform: rotate(3deg);
}
.mini-lines-toggle { flex: 1; }
.mini-toggle { width: 30px; height: 18px; border-radius: var(--radius-pill); background: var(--surface3); position: relative; flex: none; }
.mini-toggle i { position: absolute; top: 2px; left: 2px; width: 14px; height: 14px; border-radius: 50%; background: var(--fg1); opacity: 0.3; }
.mini-toggle-on { background: var(--accent); }
.mini-toggle-on i { left: 14px; background: #fff; opacity: 1; }
.chip-badge {
bottom: -2%; left: 30%;
display: flex; align-items: center; gap: 8px;
padding: 9px 14px;
font-size: 13px;
font-weight: 500;
border-radius: var(--radius-pill);
transform: rotate(-2deg);
}
.chip-badge .mini-ic { width: 14px; height: 14px; }
@media (max-width: 980px) {
.hero-grid { grid-template-columns: 1fr; text-align: center; }
.hero-copy { order: 2; }
.hero-copy .hero-sub { margin-inline: auto; }
.hero-actions, .hero-proof { justify-content: center; }
.hero-visual { order: 1; }
}
/* ---------- sections ---------- */
.section { padding: clamp(56px, 8vw, 104px) 0; }
.section-alt { background: var(--surface); }
.section h2 {
font-size: clamp(28px, 3.4vw, 42px);
line-height: 1.08;
max-width: 20ch;
}
.section-sub {
font-size: clamp(15px, 1.4vw, 17px);
color: var(--fg2);
max-width: 56ch;
margin: 16px 0 0;
}
.grid { display: grid; gap: 20px; margin-top: 44px; }
.grid-features { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.card {
background: var(--surface);
border-radius: var(--radius-card);
padding: 28px;
}
.section-alt .card { background: var(--bg); }
.card h3 {
font-size: 19px;
margin: 16px 0 8px;
}
.card p { margin: 0; color: var(--fg2); font-size: 15px; }
.card-ic, .pt-ic {
display: inline-flex;
align-items: center;
justify-content: center;
width: 44px; height: 44px;
border-radius: var(--radius-input);
background: var(--accent-soft);
position: relative;
}
.card-ic::after, .pt-ic::after {
content: "";
width: 20px; height: 20px;
background: var(--accent-text);
-webkit-mask-size: contain;
mask-size: contain;
-webkit-mask-repeat: no-repeat;
mask-repeat: no-repeat;
-webkit-mask-position: center;
mask-position: center;
}
/* Minimal inline icon set (stroke-style, Lucide-ish) via CSS mask so the
whole page stays dependency-free - no icon font, no SVG sprite fetch. */
.ic-folder-lg::after { -webkit-mask-image: var(--i-folder); mask-image: var(--i-folder); }
.ic-image-lg::after { -webkit-mask-image: var(--i-image); mask-image: var(--i-image); }
.ic-download-lg::after { -webkit-mask-image: var(--i-download); mask-image: var(--i-download); }
.ic-share-lg::after { -webkit-mask-image: var(--i-share); mask-image: var(--i-share); }
.ic-clock-lg::after { -webkit-mask-image: var(--i-clock); mask-image: var(--i-clock); }
.ic-users-lg::after { -webkit-mask-image: var(--i-users); mask-image: var(--i-users); }
.pt-ic.ic-lock::after { -webkit-mask-image: var(--i-lock); mask-image: var(--i-lock); }
.pt-ic.ic-server::after { -webkit-mask-image: var(--i-server); mask-image: var(--i-server); }
.pt-ic.ic-code::after { -webkit-mask-image: var(--i-code); mask-image: var(--i-code); }
.pt-ic.ic-off::after { -webkit-mask-image: var(--i-off); mask-image: var(--i-off); }
:root {
--i-folder: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z'/%3E%3C/svg%3E");
--i-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect width='18' height='18' x='3' y='3' rx='2'/%3E%3Ccircle cx='9' cy='9' r='2'/%3E%3Cpath d='m21 15-3.1-3.1a2 2 0 0 0-2.8 0L6 21'/%3E%3C/svg%3E");
--i-download: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'/%3E%3Cpolyline points='7 10 12 15 17 10'/%3E%3Cline x1='12' y1='15' x2='12' y2='3'/%3E%3C/svg%3E");
--i-share: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='18' cy='5' r='3'/%3E%3Ccircle cx='6' cy='12' r='3'/%3E%3Ccircle cx='18' cy='19' r='3'/%3E%3Cline x1='8.6' y1='10.5' x2='15.4' y2='6.5'/%3E%3Cline x1='8.6' y1='13.5' x2='15.4' y2='17.5'/%3E%3C/svg%3E");
--i-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='9'/%3E%3Cpolyline points='12 7 12 12 15.5 14.5'/%3E%3C/svg%3E");
--i-users: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='9' cy='7' r='4'/%3E%3Cpath d='M22 21v-2a4 4 0 0 0-3-3.87'/%3E%3Cpath d='M16 3.13a4 4 0 0 1 0 7.75'/%3E%3C/svg%3E");
--i-lock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='3' y='11' width='18' height='11' rx='2'/%3E%3Cpath d='M7 11V7a5 5 0 0 1 10 0v4'/%3E%3C/svg%3E");
--i-server: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='2' y='3' width='20' height='7' rx='1.5'/%3E%3Crect x='2' y='14' width='20' height='7' rx='1.5'/%3E%3Cline x1='6' y1='6.5' x2='6.01' y2='6.5'/%3E%3Cline x1='6' y1='17.5' x2='6.01' y2='17.5'/%3E%3C/svg%3E");
--i-code: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='16 18 22 12 16 6'/%3E%3Cpolyline points='8 6 2 12 8 18'/%3E%3C/svg%3E");
--i-off: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'%3E%3Cline x1='2' y1='2' x2='22' y2='22'/%3E%3Cpath d='M16.9 16.9A9 9 0 0 1 4 12a9 9 0 0 1 2.6-6.3'/%3E%3Cpath d='M9.9 4.24A9 9 0 0 1 20 12a9 9 0 0 1-.8 3.7'/%3E%3C/svg%3E");
}
@media (max-width: 900px) { .grid-features { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 600px) { .grid-features { grid-template-columns: 1fr; } }
/* ---------- customize ---------- */
.customize-grid {
margin-top: 44px;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.customize-demo {
background: var(--bg);
border-radius: var(--radius-card);
padding: 24px;
display: flex;
flex-direction: column;
gap: 22px;
}
.customize-demo h3 { font-size: 18px; margin: 0 0 8px; }
.customize-demo p { margin: 0; color: var(--fg2); font-size: 14px; }
.demo-preview {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-input);
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.demo-topbar { display: flex; align-items: center; gap: 10px; }
.demo-dot { width: 12px; height: 12px; border-radius: 50%; transition: background var(--motion-base) var(--ease); }
.demo-line { display: block; height: 8px; border-radius: 4px; background: var(--surface3); }
.demo-pill {
align-self: flex-start;
padding: 6px 14px;
border-radius: var(--radius-pill);
font-size: 13px;
font-weight: 600;
transition: background var(--motion-base) var(--ease), color var(--motion-base) var(--ease);
}
.demo-row { padding: 6px 0; }
.swatch-row { display: flex; gap: 10px; }
.swatch-btn {
width: 32px; height: 32px;
border-radius: 50%;
border: 2px solid transparent;
background: var(--c);
cursor: pointer;
padding: 0;
transition: transform var(--motion-fast) var(--ease), border-color var(--motion-fast) var(--ease);
}
.swatch-btn:last-child {
background: linear-gradient(135deg, #8DCDE2, #DA9AC7 50%, #E5D8BD);
}
.swatch-btn.is-active { border-color: var(--fg1); transform: scale(1.08); }
.swatch-btn:hover { transform: scale(1.08); }
.demo-preview-bar { position: relative; height: 128px; padding: 0; overflow: hidden; }
.demo-screen-fill {
position: absolute; inset: 0;
background: repeating-linear-gradient(180deg, var(--surface2) 0 34px, var(--surface) 34px 68px);
opacity: 0.6;
}
.demo-bar {
position: absolute;
left: 12px; right: 12px; bottom: 12px;
height: 44px;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-pill);
display: flex;
align-items: center;
justify-content: space-around;
transition: left var(--motion-base) var(--ease), right var(--motion-base) var(--ease), bottom var(--motion-base) var(--ease), border-radius var(--motion-base) var(--ease);
}
.demo-bar.is-attached { left: 0; right: 0; bottom: 0; border-radius: 0; border-width: 1px 0 0; }
.demo-bar-item { width: 18px; height: 18px; border-radius: 6px; background: var(--surface3); transition: background var(--motion-fast) var(--ease); }
.demo-bar-item.is-on { background: var(--accent); }
.segmented {
display: inline-flex;
padding: 3px;
background: var(--surface);
border-radius: var(--radius-pill);
gap: 2px;
align-self: flex-start;
}
.segmented-btn {
border: none;
background: transparent;
padding: 8px 16px;
border-radius: var(--radius-pill);
font-family: var(--font-body);
font-size: 13px;
font-weight: 600;
color: var(--fg2);
cursor: pointer;
transition: background var(--motion-fast) var(--ease), color var(--motion-fast) var(--ease);
}
.segmented-btn.is-active { background: var(--accent-soft); color: var(--accent-text); }
.customize-list {
grid-column: 1 / -1;
background: var(--bg);
border-radius: var(--radius-card);
padding: 28px 32px;
}
.customize-list h3 { font-size: 18px; margin: 0 0 16px; }
.feature-list {
margin: 0;
padding: 0;
list-style: none;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px 32px;
color: var(--fg2);
font-size: 15px;
}
.feature-list li { display: flex; align-items: center; gap: 10px; }
.feature-list li::before {
content: "";
width: 6px; height: 6px;
border-radius: 50%;
background: var(--accent);
flex: none;
}
@media (max-width: 760px) {
.customize-grid { grid-template-columns: 1fr; }
.feature-list { grid-template-columns: 1fr; }
}
/* ---------- privacy ---------- */
.privacy-grid {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
gap: clamp(32px, 6vw, 64px);
align-items: center;
}
.privacy-points {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 14px;
}
.privacy-points li {
display: flex;
align-items: center;
gap: 14px;
background: var(--surface);
border-radius: var(--radius-input);
padding: 16px 18px;
font-weight: 500;
}
@media (max-width: 820px) {
.privacy-grid { grid-template-columns: 1fr; }
}
/* ---------- cta ---------- */
.section-cta { text-align: center; background: var(--surface); }
.cta-inner { display: flex; flex-direction: column; align-items: center; }
.cta-inner .section-sub { margin-bottom: 32px; }
.cta-actions { justify-content: center; }
.cta-fine { margin-top: 20px; font-size: 14px; color: var(--fg3); }
.cta-fine a { color: var(--accent-text); font-weight: 600; text-decoration: none; }
/* ---------- footer ---------- */
.site-footer { border-top: 1px solid var(--line); padding: 32px 0; }
.footer-row {
display: flex;
align-items: center;
gap: 24px;
flex-wrap: wrap;
}
.footer-note { color: var(--fg3); font-size: 13px; margin: 0; flex: 1; min-width: 200px; }
.footer-links { display: flex; gap: 20px; }
.footer-links a { font-size: 14px; text-decoration: none; color: var(--fg2); }
.footer-links a:hover { color: var(--fg1); }
/* ---------- motion ---------- */
@media (prefers-reduced-motion: reduce) {
html { scroll-behavior: auto; }
.phone, .chip, .btn { transition: none !important; }
}
.reveal {
opacity: 0;
transform: translateY(16px);
transition: opacity 600ms var(--ease), transform 600ms var(--ease);
}
.reveal.is-visible { opacity: 1; transform: none; }