Noo design system rebuild #1

Merged
ayushya merged 31 commits from noo-design-system-rebuild into main 2026-10-01 09:23:58 -04:00
5 changed files with 310 additions and 81 deletions
Showing only changes of commit c0f702b00e - Show all commits
+10 -6
View File
@@ -340,12 +340,16 @@ Sidebar items are 38px tall with radius 12, an 18px icon and a 14/500 label. The
Location, Modified, Created, ...). Versions and Activity reuse their own
row/feed treatment. Same on every platform - this sheet has no iOS/Android
split.
- **Media viewer** (the full-screen photo/video viewer): a black stage
regardless of theme, like a native photo/video viewer - not `bg`. A
translucent, blurred top bar (back, filename, meta) and bottom bar float
over the media; this is the one deliberate exception to "no blur" in
product UI, since it's chrome over photo/video content, not over the app's
own surfaces. Back is a plain arrow (`arrow-left`), not the iOS
- **Media viewer** (the full-screen photo/video viewer): the stage is `bg`,
following the app's own theme rather than a fixed black - a black stage
in light mode read as jarringly out of place. A translucent, blurred top
bar (back, filename, meta) and bottom bar float over the media in a fixed
dark panel regardless of theme (it needs to read over arbitrary photo/
video brightness, not over `bg`); this is the one deliberate exception to
"no blur" in product UI, since it's chrome over photo/video content, not
over the app's own surfaces. Both bars' background extends edge-to-edge
behind the status bar/gesture area, with only their content padded clear
of it. Back is a plain arrow (`arrow-left`), not the iOS
chevron+label pushed-screen pattern - platform split still to do. The
bottom bar holds every action in one row (share, favorite, open
externally, download, delete, details) on every platform; don't add a
+17 -15
View File
@@ -376,14 +376,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
final session = context.read<SessionController>();
final ops = context.read<ItemOperations>();
final colors = context.nooColors;
// The stage behind the media itself is pure black rather than `bg` -
// the near-universal "letterbox" convention of photo/video viewers
// (matting an image/video in the app's warm neutral background reads
// as unfinished, and black also hides any letterboxing from
// `BoxFit.contain`/`AspectRatio` at the screen edges). PDFs/text/
// unsupported-file previews aren't "viewed" the same way - they're
// read, so they stay on the normal `bg` like any other screen.
final stageColor = _isSwipeable ? Colors.black : colors.bg;
// The stage follows the app's own theme (`bg`) rather than being pinned
// to black - a black stage in light mode read as jarringly out of place
// next to the rest of the light-themed app. The floating top/action
// bars stay a fixed dark frosted panel regardless (see
// FrostedGlassContainer's doc comment) since they need to read over
// arbitrary photo/video brightness, not over this background.
final stageColor = colors.bg;
return Scaffold(
backgroundColor: stageColor,
@@ -437,9 +436,13 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
offset: _controlsVisible ? Offset.zero : const Offset(0, -1.4),
child: IgnorePointer(
ignoring: !_controlsVisible,
child: SafeArea(
bottom: false,
child: FrostedGlassContainer(
// The blurred/tinted background lives outside the SafeArea
// (not inside it) so it extends up behind the status bar
// instead of stopping short beneath it - see the matching
// fix/comment on MediaActionBar's own SafeArea.
child: FrostedGlassContainer(
child: SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 6,
@@ -447,10 +450,9 @@ class _FileViewerScreenState extends State<FileViewerScreen> {
),
child: IconTheme.merge(
// Fixed white, not `colors.fg1`: this bar always
// sits on the dark translucent panel over a black
// media stage (see `FrostedGlassContainer`'s doc
// comment), regardless of the app's light/dark
// theme.
// sits on the dark translucent panel over the media
// stage (see `FrostedGlassContainer`'s doc comment),
// regardless of the app's light/dark theme.
data: const IconThemeData(
color: Colors.white,
size: 24,
+118 -28
View File
@@ -1,13 +1,23 @@
import 'package:flutter/material.dart';
import '../../../theme/design_tokens.dart';
/// Fraction of the available height a sheet can reach before it switches
/// from sizing to its content to a draggable peek (see [_NooSheetBody]).
const double _kNooSheetPeekFraction = 0.6;
const double _kNooSheetMinFraction = 0.3;
const double _kNooSheetMaxFraction = 0.95;
/// Mobile bottom sheet on a scrim: radius-28 top, grabber, 22px gap between
/// sections (Noo Design System project, `components/overlays/Sheet.jsx`).
/// `showModalBottomSheet` already supplies the scrim/backdrop-dismiss
/// machinery, so this only standardizes the shape/padding - use it instead
/// of a bare `showModalBottomSheet` for any new sheet. Scroll-controlled so a
/// tall sheet (e.g. share) can grow past half the screen; content scrolls
/// once it hits the top.
/// of a bare `showModalBottomSheet` for any new sheet. Sizes to its content
/// like a plain scroll view for anything that fits comfortably on screen
/// (most sheets: a menu, a handful of settings rows); content long enough to
/// need scrolling (Details' Versions/Activity tabs with many rows, a long
/// accounts list, ...) instead opens at a native-style peek height the user
/// can drag up to reveal more of, rather than snapping straight to nearly
/// the full screen with nothing left to adjust - see [_NooSheetBody].
Future<T?> showNooSheet<T>(
BuildContext context, {
required List<Widget> children,
@@ -20,31 +30,111 @@ Future<T?> showNooSheet<T>(
isScrollControlled: true,
useSafeArea: true,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(NooRadii.sheetTop)),
borderRadius: BorderRadius.vertical(
top: Radius.circular(NooRadii.sheetTop),
),
),
builder: (context) {
return SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 5,
decoration: BoxDecoration(
color: colors.surface3,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
),
for (final child in children) ...[
const SizedBox(height: 22),
child,
],
],
),
),
);
},
builder: (context) => _NooSheetBody(children: children),
);
}
/// Renders [children] once, unconstrained, to measure their natural height
/// against the sheet's available height. Content that fits within
/// [_kNooSheetPeekFraction] just stays as that first (plain, content-sized)
/// render - the common case for a short menu/option list. Content taller
/// than that switches to a [DraggableScrollableSheet] opened at the peek
/// fraction instead, so it never jumps straight to (near) full height with
/// no room left to adjust. The one extra layout pass for tall content
/// happens within the sheet's own entrance animation, so it isn't visible
/// as a jump.
class _NooSheetBody extends StatefulWidget {
final List<Widget> children;
const _NooSheetBody({required this.children});
@override
State<_NooSheetBody> createState() => _NooSheetBodyState();
}
class _NooSheetBodyState extends State<_NooSheetBody> {
final _contentKey = GlobalKey();
bool _measured = false;
bool _needsPeek = false;
void _measureAfterFrame(double availableHeight) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || _measured) return;
final box = _contentKey.currentContext?.findRenderObject() as RenderBox?;
final contentHeight = box?.size.height ?? 0;
setState(() {
_measured = true;
_needsPeek = contentHeight > availableHeight * _kNooSheetPeekFraction;
});
});
}
Widget _buildContent(ScrollController? scrollController) {
final colors = context.nooColors;
return SingleChildScrollView(
controller: scrollController,
padding: const EdgeInsets.fromLTRB(20, 10, 20, 20),
child: Column(
key: _measured ? null : _contentKey,
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: 36,
height: 5,
decoration: BoxDecoration(
color: colors.surface3,
borderRadius: BorderRadius.circular(NooRadii.pill),
),
),
for (final child in widget.children) ...[
const SizedBox(height: 22),
child,
],
],
),
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: LayoutBuilder(
builder: (context, constraints) {
if (!_measured) {
_measureAfterFrame(constraints.maxHeight);
return _buildContent(null);
}
if (!_needsPeek) {
return _buildContent(null);
}
return DraggableScrollableSheet(
initialChildSize: _kNooSheetPeekFraction,
minChildSize: _kNooSheetMinFraction,
maxChildSize: _kNooSheetMaxFraction,
expand: false,
builder: (context, scrollController) {
// Plain `DraggableScrollableSheet` doesn't dismiss the modal
// route on its own when dragged down to its floor - it just
// stops resizing there - so pop explicitly once it's been
// dragged (near) all the way down, matching a native
// peek sheet's swipe-to-dismiss.
return NotificationListener<DraggableScrollableNotification>(
onNotification: (notification) {
if (notification.extent <= notification.minExtent + 0.01) {
Navigator.of(context).maybePop();
}
return false;
},
child: _buildContent(scrollController),
);
},
);
},
),
);
}
}
+8 -3
View File
@@ -46,9 +46,14 @@ class MediaActionBar extends StatelessWidget {
// favorite/delete stay legible even when the ambient theme is light.
const dark = NooColors.dark;
return SafeArea(
top: false,
child: FrostedGlassContainer(
// The blurred/tinted background lives outside the SafeArea (not inside
// it) so it extends all the way to the physical bottom edge, behind the
// gesture bar/home indicator, instead of the panel itself stopping
// short and leaving that strip unstyled - only the actual row content
// needs padding up and away from the gesture area.
return FrostedGlassContainer(
child: SafeArea(
top: false,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
+157 -29
View File
@@ -14,18 +14,26 @@ import 'noo_test_utils.dart';
/// A button that opens an overlay from a context below MaterialApp.
Widget _launcher(void Function(BuildContext) open) => Builder(
builder: (context) => TextButton(onPressed: () => open(context), child: const Text('open')),
builder: (context) =>
TextButton(onPressed: () => open(context), child: const Text('open')),
);
Finder _dialogCard() => find.byWidgetPredicate(
(w) => w is Container && w.decoration is BoxDecoration && (w.decoration as BoxDecoration).boxShadow != null,
(w) =>
w is Container &&
w.decoration is BoxDecoration &&
(w.decoration as BoxDecoration).boxShadow != null,
);
void main() {
setUpNooTests();
group('NooOverlayHeader', () {
testNooWidgets('title, subtitle, leading and close', (tester, theme, c) async {
testNooWidgets('title, subtitle, leading and close', (
tester,
theme,
c,
) async {
var closed = 0;
await pumpNoo(
tester,
@@ -41,23 +49,41 @@ void main() {
theme: theme,
);
expect(find.text('Report.pdf'), findsOneWidget);
expect(tester.widget<Text>(find.text('2 MB · Documents')).style!.color, c.fg3);
expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2);
expect(
tester.widget<Text>(find.text('2 MB · Documents')).style!.color,
c.fg3,
);
expect(
decorationOf(tester, find.byIcon(LucideIcons.x)).color,
c.surface2,
);
await tester.tap(find.byIcon(LucideIcons.x));
expect(closed, 1);
});
testWidgets('no close button without onClose', (tester) async {
await pumpNoo(tester, const SizedBox(width: 360, child: NooOverlayHeader(title: 'T')));
await pumpNoo(
tester,
const SizedBox(width: 360, child: NooOverlayHeader(title: 'T')),
);
expect(find.byIcon(LucideIcons.x), findsNothing);
});
});
group('showNooSheet', () {
testNooWidgets('shows children on a surface sheet and dismisses', (tester, theme, c) async {
testNooWidgets('shows children on a surface sheet and dismisses', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
_launcher((ctx) => showNooSheet<void>(ctx, children: const [Text('Section A'), Text('Section B')])),
_launcher(
(ctx) => showNooSheet<void>(
ctx,
children: const [Text('Section A'), Text('Section B')],
),
),
theme: theme,
);
await tester.tap(find.text('open'));
@@ -66,7 +92,8 @@ void main() {
expect(find.text('Section B'), findsOneWidget);
final sheet = tester.widget<BottomSheet>(find.byType(BottomSheet));
expect(sheet.backgroundColor, c.surface);
final gap = tester.getTopLeft(find.text('Section B')).dy -
final gap =
tester.getTopLeft(find.text('Section B')).dy -
tester.getBottomLeft(find.text('Section A')).dy;
expect(gap, 22);
@@ -74,13 +101,56 @@ void main() {
await tester.pumpAndSettle();
expect(find.text('Section A'), findsNothing);
});
testNooWidgets('long content opens as a draggable peek, not full height', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
_launcher(
(ctx) => showNooSheet<void>(
ctx,
children: [
for (var i = 0; i < 30; i++)
SizedBox(height: 40, child: Text('Row $i')),
],
),
),
theme: theme,
surfaceSize: const Size(400, 700),
);
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
// Content (30 * (40 + 22) =~ 1860px) far exceeds the peek fraction of
// the 700px-tall surface, so it should switch to a draggable sheet
// instead of the plain content-sized scroll view, opened at a peek
// (well under half the content height) rather than snapping to
// (near) the full 700px available.
expect(find.text('Row 0'), findsOneWidget);
final sheetHeight = tester
.getSize(find.byType(DraggableScrollableSheet))
.height;
expect(sheetHeight, lessThan(500));
expect(sheetHeight, greaterThan(150));
});
});
group('NooDialog', () {
testNooWidgets('card is 540 wide, r24, single dialog shadow', (tester, theme, c) async {
testNooWidgets('card is 540 wide, r24, single dialog shadow', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
NooDialog(title: 'Share "Report.pdf"', onClose: () {}, children: const [Text('body')]),
NooDialog(
title: 'Share "Report.pdf"',
onClose: () {},
children: const [Text('body')],
),
theme: theme,
surfaceSize: const Size(1200, 900),
);
@@ -92,7 +162,9 @@ void main() {
expect(tester.getSize(card).width, 540);
});
testWidgets('narrow screens shrink the card to fit with a 24px margin', (tester) async {
testWidgets('narrow screens shrink the card to fit with a 24px margin', (
tester,
) async {
await tester.binding.setSurfaceSize(const Size(400, 800));
addTearDown(() => tester.binding.setSurfaceSize(null));
await tester.pumpWidget(
@@ -105,7 +177,11 @@ void main() {
expect(tester.getSize(card).width, 400 - 48);
});
testNooWidgets('header close is a 32px surface-2 circle', (tester, theme, c) async {
testNooWidgets('header close is a 32px surface-2 circle', (
tester,
theme,
c,
) async {
var closed = 0;
await pumpNoo(
tester,
@@ -118,12 +194,19 @@ void main() {
theme: theme,
);
expect(tester.getSize(find.byType(NooCloseButton)), const Size(32, 32));
expect(decorationOf(tester, find.byIcon(LucideIcons.x)).color, c.surface2);
expect(
decorationOf(tester, find.byIcon(LucideIcons.x)).color,
c.surface2,
);
await tester.tap(find.byType(NooCloseButton));
expect(closed, 1);
});
testNooWidgets('showNooDialog uses the scrim and closes via the button', (tester, theme, c) async {
testNooWidgets('showNooDialog uses the scrim and closes via the button', (
tester,
theme,
c,
) async {
Future<String?>? result;
await pumpNoo(
tester,
@@ -140,7 +223,9 @@ void main() {
await tester.tap(find.text('open'));
await tester.pumpAndSettle();
expect(find.text('dialog body'), findsOneWidget);
final barrier = tester.widgetList<ModalBarrier>(find.byType(ModalBarrier)).last;
final barrier = tester
.widgetList<ModalBarrier>(find.byType(ModalBarrier))
.last;
expect(barrier.color, c.scrim);
await tester.tap(find.byType(NooCloseButton));
@@ -176,7 +261,10 @@ void main() {
(ctx) => showNooDialog<void>(
ctx,
title: 'T',
children: [for (var i = 0; i < 40; i++) SizedBox(height: 60, child: Text('row $i'))],
children: [
for (var i = 0; i < 40; i++)
SizedBox(height: 60, child: Text('row $i')),
],
),
),
surfaceSize: const Size(1200, 600),
@@ -189,13 +277,20 @@ void main() {
});
group('NooTextField', () {
testNooWidgets('surface-2, r14, fg-3 placeholder, typing', (tester, theme, c) async {
testNooWidgets('surface-2, r14, fg-3 placeholder, typing', (
tester,
theme,
c,
) async {
String? typed;
await pumpNoo(
tester,
SizedBox(
width: 400,
child: NooTextField(placeholder: 'Name, email or group', onChanged: (v) => typed = v),
child: NooTextField(
placeholder: 'Name, email or group',
onChanged: (v) => typed = v,
),
),
theme: theme,
);
@@ -209,14 +304,20 @@ void main() {
expect(typed, 'ali');
});
testNooWidgets('mono URL field with inline Copy link button', (tester, theme, c) async {
testNooWidgets('mono URL field with inline Copy link button', (
tester,
theme,
c,
) async {
var copied = 0;
await pumpNoo(
tester,
SizedBox(
width: 480,
child: NooTextField(
controller: TextEditingController(text: 'https://cloud.example/s/abc'),
controller: TextEditingController(
text: 'https://cloud.example/s/abc',
),
mono: true,
readOnly: true,
onSurface: false,
@@ -233,7 +334,10 @@ void main() {
expect(field.style!.fontFamily, NooText.mono.fontFamily);
expect(field.readOnly, isTrue);
expect(decorationOf(tester, find.byType(TextField)).color, c.surface);
expect(tester.getSize(find.byType(NooButton)).height, NooSizes.buttonField);
expect(
tester.getSize(find.byType(NooButton)).height,
NooSizes.buttonField,
);
await tester.tap(find.text('Copy link'));
await tester.pumpAndSettle();
expect(copied, 1);
@@ -241,7 +345,11 @@ void main() {
});
group('Share parts', () {
testNooWidgets('NooShareSection title, trailing and caption', (tester, theme, c) async {
testNooWidgets('NooShareSection title, trailing and caption', (
tester,
theme,
c,
) async {
await pumpNoo(
tester,
const SizedBox(
@@ -255,13 +363,26 @@ void main() {
),
theme: theme,
);
expect(tester.widget<Text>(find.text('Send file directly')).style!.color, c.fg1);
expect(tester.widget<Text>(find.text("Link settings don't apply")).style!.color, c.fg3);
expect(
tester.widget<Text>(find.text('Send file directly')).style!.color,
c.fg1,
);
expect(
tester
.widget<Text>(find.text("Link settings don't apply"))
.style!
.color,
c.fg3,
);
expect(find.byIcon(LucideIcons.link), findsOneWidget);
expect(find.text('content'), findsOneWidget);
});
testNooWidgets('owner row is static, others get a tappable pill', (tester, theme, c) async {
testNooWidgets('owner row is static, others get a tappable pill', (
tester,
theme,
c,
) async {
var taps = 0;
await pumpNoo(
tester,
@@ -296,13 +417,19 @@ void main() {
expect(taps, 1);
});
testWidgets('same parts lay out inside the sheet and the dialog', (tester) async {
testWidgets('same parts lay out inside the sheet and the dialog', (
tester,
) async {
List<Widget> parts() => [
NooShareSection(
title: 'Share with people',
child: const NooTextField(placeholder: 'Name, email or group'),
),
const NooPersonAccessRow(avatar: NooAvatar(initials: 'JD'), name: 'Jane', permission: 'Can view'),
const NooPersonAccessRow(
avatar: NooAvatar(initials: 'JD'),
name: 'Jane',
permission: 'Can view',
),
];
await pumpNoo(
tester,
@@ -312,7 +439,8 @@ void main() {
_launcher((ctx) => showNooSheet<void>(ctx, children: parts())),
Builder(
builder: (ctx) => TextButton(
onPressed: () => showNooDialog<void>(ctx, title: 'Share', children: parts()),
onPressed: () =>
showNooDialog<void>(ctx, title: 'Share', children: parts()),
child: const Text('dialog'),
),
),