scaffold Flutter app — kernel, extensions, widgets, Tier 0 built-ins

First real content under app/. Lays the whole Tier 0 foundation in one
commit because the pieces depend on each other circularly (kernel →
extension → widgets → built-ins all reference types from the layer
below); splitting would leave intermediate commits that don't compile.

Key shapes:

  * bare WidgetsApp root — no Material, no Cupertino, no Scaffold.
    ClideTheme InheritedWidget is the only source of tokens.
  * ClideKernel InheritedWidget aggregates 18 services (settings,
    project, extensions, theme, panels, events, ipc, commands +
    palette + keybindings, clipboard, files, notify, dialog, tray,
    secrets, os, net, focus, log, i18n). ExtensionContext exposes
    them through a stable interface.
  * ClideExtension + sealed ContributionPoint hierarchy (Tab,
    StatusItem, Toolbar, Command, TrayItem, LayoutPreset). One
    manifest ships N contributions into kernel slots.
  * Three-tier theme pipeline: palette (named colors) → semantic
    roles → ~60 surface tokens. Defaults at each layer so legacy
    palette-only themes produce a complete SurfaceTokens.
  * A11y baked in from day one — every interactive primitive wraps
    in Semantics(label:, hint:, button:); ensureSemantics() at boot;
    theme/contrast.dart helper exposes token pairs for the WCAG
    gate in the a11y test suite.
  * i18n ported from fframe's L10n pattern (text-driven, namespaced
    JSON catalogs, caller-supplied placeholders) with a proper
    locale fallback chain (exact → language → default → placeholder)
    fframe lacks.
  * Four Tier-0 built-ins live (default-layout, welcome, ipc-status,
    theme-picker) plus 17 id-reserving stubs so later tiers fill in
    without rename churn.

.gitignore extended to cover app/ sub-package artefacts and the
Playwright harness scratch dirs.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-04-21 15:39:41 +02:00
co-authored by Claude
parent 235cbcc046
commit b13483e2d5
167 changed files with 8270 additions and 0 deletions
+105
View File
@@ -0,0 +1,105 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart';
enum ClideButtonVariant { normal, primary, subtle }
class ClideButton extends StatefulWidget {
const ClideButton({
super.key,
required this.label,
required this.onPressed,
this.variant = ClideButtonVariant.normal,
this.padding = const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
this.semanticLabel,
this.semanticHint,
});
final String label;
final VoidCallback? onPressed;
final ClideButtonVariant variant;
final EdgeInsetsGeometry padding;
/// Overrides [label] for screen readers (use when the visible label is
/// an icon-only glyph or a noun that reads oddly when announced).
final String? semanticLabel;
/// Screen-reader hint describing the button's effect. Optional.
final String? semanticHint;
@override
State<ClideButton> createState() => _ClideButtonState();
}
class _ClideButtonState extends State<ClideButton> {
bool _hovered = false;
bool _pressed = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final enabled = widget.onPressed != null;
Color bg;
Color fg;
switch (widget.variant) {
case ClideButtonVariant.normal:
bg = _pressed
? tokens.buttonActiveBackground
: _hovered
? tokens.buttonHoverBackground
: tokens.buttonBackground;
fg = tokens.buttonForeground;
case ClideButtonVariant.primary:
bg = _pressed
? tokens.panelActiveBorder
: _hovered
? tokens.buttonActiveBackground
: tokens.buttonActiveBackground;
fg = tokens.globalBackground;
case ClideButtonVariant.subtle:
bg = _hovered
? tokens.listItemHoverBackground
: tokens.listItemBackground;
fg = tokens.listItemForeground;
}
return Semantics(
button: true,
enabled: enabled,
label: widget.semanticLabel ?? widget.label,
hint: widget.semanticHint,
onTap: enabled ? widget.onPressed : null,
excludeSemantics: true,
child: MouseRegion(
cursor:
enabled ? SystemMouseCursors.click : SystemMouseCursors.forbidden,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTapDown: (_) => setState(() => _pressed = true),
onTapCancel: () => setState(() => _pressed = false),
onTapUp: (_) {
setState(() => _pressed = false);
widget.onPressed?.call();
},
child: Container(
padding: widget.padding,
decoration: BoxDecoration(
color: bg,
border: Border.all(color: tokens.buttonBorder, width: 1),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(
widget.label,
color: fg,
fontWeight: widget.variant == ClideButtonVariant.primary
? FontWeight.w600
: FontWeight.w500,
),
),
),
),
);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
class ClideDivider extends StatelessWidget {
const ClideDivider({
super.key,
this.axis = Axis.horizontal,
this.thickness = 1,
});
final Axis axis;
final double thickness;
@override
Widget build(BuildContext context) {
final color = ClideTheme.of(context).surface.dividerColor;
return Container(
width: axis == Axis.vertical ? thickness : null,
height: axis == Axis.horizontal ? thickness : null,
color: color,
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// Stateless painter producing a single-color icon. Every icon in
/// [icons/] subclasses this; widgets wrap with [ClideIcon] for size +
/// color-from-theme.
abstract class ClideIconPainter {
const ClideIconPainter();
/// Paint the icon into a unit square (0,0 .. 1,1).
void paint(Canvas canvas, Color color);
}
class ClideIcon extends StatelessWidget {
const ClideIcon(
this.painter, {
super.key,
this.size = 14,
this.color,
});
final ClideIconPainter painter;
final double size;
final Color? color;
@override
Widget build(BuildContext context) {
final resolved = color ?? ClideTheme.of(context).surface.globalForeground;
return SizedBox(
width: size,
height: size,
child: CustomPaint(
size: Size(size, size),
painter: _IconPainterAdapter(painter: painter, color: resolved),
),
);
}
}
class _IconPainterAdapter extends CustomPainter {
_IconPainterAdapter({required this.painter, required this.color});
final ClideIconPainter painter;
final Color color;
@override
void paint(Canvas canvas, Size size) {
canvas.save();
canvas.scale(size.width, size.height);
painter.paint(canvas, color);
canvas.restore();
}
@override
bool shouldRepaint(covariant _IconPainterAdapter old) =>
old.painter != painter || old.color != color;
}
+54
View File
@@ -0,0 +1,54 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// Thin themed scrollbar. Tier-0 shell; more refined scrolling (velocity
/// multiplier, keyboard nav) lands with the editor.
class ClideScrollbar extends StatelessWidget {
const ClideScrollbar({
super.key,
required this.controller,
required this.child,
this.axis = Axis.vertical,
});
final ScrollController controller;
final Widget child;
final Axis axis;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return ScrollbarTheme(
slider: tokens.scrollbarSlider,
sliderHover: tokens.scrollbarSliderHover,
track: tokens.scrollbarTrack,
child: RawScrollbar(
controller: controller,
thumbColor: tokens.scrollbarSlider,
thickness: 8,
radius: const Radius.circular(4),
child: child,
),
);
}
}
class ScrollbarTheme extends InheritedWidget {
const ScrollbarTheme({
super.key,
required this.slider,
required this.sliderHover,
required this.track,
required super.child,
});
final Color slider;
final Color sliderHover;
final Color track;
@override
bool updateShouldNotify(ScrollbarTheme old) =>
slider != old.slider ||
sliderHover != old.sliderHover ||
track != old.track;
}
+42
View File
@@ -0,0 +1,42 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// Themed container. Replaces Material's Card / Scaffold body surfaces
/// for clide widgets — pulls color, border, and padding from the
/// current theme's surface tokens.
class ClideSurface extends StatelessWidget {
const ClideSurface({
super.key,
this.child,
this.color,
this.border,
this.padding = EdgeInsets.zero,
this.width,
this.height,
this.borderRadius,
});
final Widget? child;
final Color? color;
final Color? border;
final EdgeInsetsGeometry padding;
final double? width;
final double? height;
final BorderRadius? borderRadius;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Container(
width: width,
height: height,
padding: padding,
decoration: BoxDecoration(
color: color ?? tokens.panelBackground,
border: border == null ? null : Border.all(color: border!, width: 1),
borderRadius: borderRadius,
),
child: child,
);
}
}
+126
View File
@@ -0,0 +1,126 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:clide_app/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart';
@immutable
class ClideTabItem {
const ClideTabItem({
required this.id,
required this.title,
this.icon,
});
final String id;
final String title;
final ClideIconPainter? icon;
}
class ClideTabBar extends StatelessWidget {
const ClideTabBar({
super.key,
required this.items,
required this.activeId,
required this.onSelect,
this.height = 28,
this.semanticContainerLabel,
});
final List<ClideTabItem> items;
final String? activeId;
final ValueChanged<String> onSelect;
final double height;
/// Optional container-level label for screen readers (e.g. "Sidebar tabs").
final String? semanticContainerLabel;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
return Semantics(
container: true,
label: semanticContainerLabel,
explicitChildNodes: true,
child: Container(
height: height,
color: tokens.tabBarBackground,
child: Row(
children: [
for (final item in items)
_Tab(
item: item,
active: item.id == activeId,
onTap: () => onSelect(item.id),
),
],
),
),
);
}
}
class _Tab extends StatefulWidget {
const _Tab({required this.item, required this.active, required this.onTap});
final ClideTabItem item;
final bool active;
final VoidCallback onTap;
@override
State<_Tab> createState() => _TabState();
}
class _TabState extends State<_Tab> {
bool _hovered = false;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final bg = widget.active
? tokens.tabActive
: (_hovered ? tokens.tabInactive : tokens.tabInactive);
final fg = widget.active
? tokens.tabActiveForeground
: tokens.tabInactiveForeground;
return Semantics(
button: true,
selected: widget.active,
label: widget.item.title,
onTap: widget.onTap,
excludeSemantics: true,
child: MouseRegion(
cursor: SystemMouseCursors.click,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
onTap: widget.onTap,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: bg,
border: Border(
bottom: BorderSide(
color: widget.active
? tokens.tabActiveBorder
: const Color(0x00000000),
width: 2,
),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (widget.item.icon != null) ...[
ClideIcon(widget.item.icon!, size: 12, color: fg),
const SizedBox(width: 6),
],
ClideText(widget.item.title, color: fg),
],
),
),
),
),
);
}
}
+45
View File
@@ -0,0 +1,45 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:flutter/widgets.dart';
/// Theme-aware Text. Defaults pull from the global foreground token.
class ClideText extends StatelessWidget {
const ClideText(
this.data, {
super.key,
this.color,
this.fontSize = 13,
this.fontWeight = FontWeight.w400,
this.muted = false,
this.maxLines,
this.overflow,
this.textAlign,
});
final String data;
final Color? color;
final double fontSize;
final FontWeight fontWeight;
final bool muted;
final int? maxLines;
final TextOverflow? overflow;
final TextAlign? textAlign;
@override
Widget build(BuildContext context) {
final tokens = ClideTheme.of(context).surface;
final resolved =
color ?? (muted ? tokens.globalTextMuted : tokens.globalForeground);
return Text(
data,
maxLines: maxLines,
overflow: overflow,
textAlign: textAlign,
style: TextStyle(
color: resolved,
fontSize: fontSize,
fontWeight: fontWeight,
fontFamilyFallback: const ['Inter', 'Helvetica', 'Arial', 'sans-serif'],
),
);
}
}
+79
View File
@@ -0,0 +1,79 @@
import 'package:clide_app/kernel/src/theme/controller.dart';
import 'package:clide_app/widgets/src/clide_text.dart';
import 'package:flutter/widgets.dart';
/// Very small tooltip — hover a child to reveal a label. Uses an
/// OverlayEntry to draw above everything else without Material.
class ClideTooltip extends StatefulWidget {
const ClideTooltip({
super.key,
required this.message,
required this.child,
this.showDelay = const Duration(milliseconds: 500),
});
final String message;
final Widget child;
final Duration showDelay;
@override
State<ClideTooltip> createState() => _ClideTooltipState();
}
class _ClideTooltipState extends State<ClideTooltip> {
OverlayEntry? _entry;
void _show() {
final overlay = Overlay.maybeOf(context);
if (overlay == null) return;
_entry?.remove();
final box = context.findRenderObject() as RenderBox?;
if (box == null) return;
final offset = box.localToGlobal(Offset(0, box.size.height + 4));
_entry = OverlayEntry(
builder: (ctx) {
final tokens = ClideTheme.of(ctx).surface;
return Positioned(
left: offset.dx,
top: offset.dy,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: tokens.tooltipBackground,
border: Border.all(color: tokens.tooltipBorder),
borderRadius: BorderRadius.circular(3),
),
child: ClideText(widget.message, color: tokens.tooltipForeground),
),
);
},
);
overlay.insert(_entry!);
}
void _hide() {
_entry?.remove();
_entry = null;
}
@override
void dispose() {
_hide();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Semantics(
tooltip: widget.message,
child: MouseRegion(
onEnter: (_) async {
await Future<void>.delayed(widget.showDelay);
if (mounted) _show();
},
onExit: (_) => _hide(),
child: widget.child,
),
);
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class CheckIcon extends ClideIconPainter {
const CheckIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..strokeWidth = 0.14
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path()
..moveTo(0.18, 0.52)
..lineTo(0.42, 0.74)
..lineTo(0.82, 0.30);
canvas.drawPath(path, p);
}
}
+40
View File
@@ -0,0 +1,40 @@
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class ChevronRightIcon extends ClideIconPainter {
const ChevronRightIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.12
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path()
..moveTo(0.36, 0.22)
..lineTo(0.66, 0.50)
..lineTo(0.36, 0.78);
canvas.drawPath(path, p);
}
}
class ChevronDownIcon extends ClideIconPainter {
const ChevronDownIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.12
..strokeCap = StrokeCap.round
..strokeJoin = StrokeJoin.round;
final path = Path()
..moveTo(0.22, 0.36)
..lineTo(0.50, 0.66)
..lineTo(0.78, 0.36);
canvas.drawPath(path, p);
}
}
+11
View File
@@ -0,0 +1,11 @@
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class DotIcon extends ClideIconPainter {
const DotIcon();
@override
void paint(Canvas canvas, Color color) {
canvas.drawCircle(const Offset(0.5, 0.5), 0.18, Paint()..color = color);
}
}
+23
View File
@@ -0,0 +1,23 @@
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class FolderIcon extends ClideIconPainter {
const FolderIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.08;
final path = Path()
..moveTo(0.08, 0.30)
..lineTo(0.40, 0.30)
..lineTo(0.48, 0.22)
..lineTo(0.92, 0.22)
..lineTo(0.92, 0.80)
..lineTo(0.08, 0.80)
..close();
canvas.drawPath(path, p);
}
}
+38
View File
@@ -0,0 +1,38 @@
import 'dart:math' as math;
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class GearIcon extends ClideIconPainter {
const GearIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.08
..strokeCap = StrokeCap.round;
const center = Offset(0.5, 0.5);
const teeth = 8;
const innerR = 0.25;
const outerR = 0.40;
final path = Path();
for (var i = 0; i < teeth; i++) {
final a1 = (i / teeth) * 2 * math.pi;
final a2 = ((i + 0.5) / teeth) * 2 * math.pi;
final p1 = center + Offset(math.cos(a1) * innerR, math.sin(a1) * innerR);
final p2 = center + Offset(math.cos(a1) * outerR, math.sin(a1) * outerR);
final p3 = center + Offset(math.cos(a2) * outerR, math.sin(a2) * outerR);
final p4 = center + Offset(math.cos(a2) * innerR, math.sin(a2) * innerR);
path
..moveTo(p1.dx, p1.dy)
..lineTo(p2.dx, p2.dy)
..lineTo(p3.dx, p3.dy)
..lineTo(p4.dx, p4.dy);
}
canvas.drawPath(path, p);
canvas.drawCircle(center, 0.12, p);
}
}
+31
View File
@@ -0,0 +1,31 @@
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
/// Plug-shaped connection icon. Used by the ipc-status statusbar item
/// and by future connection-state affordances.
class PlugIcon extends ClideIconPainter {
const PlugIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 0.10
..strokeCap = StrokeCap.round;
// body
final body = Path()
..moveTo(0.30, 0.30)
..lineTo(0.60, 0.30)
..lineTo(0.60, 0.55)
..arcToPoint(const Offset(0.30, 0.55),
radius: const Radius.circular(0.15), clockwise: false)
..close();
canvas.drawPath(body, p);
// cord
canvas.drawLine(const Offset(0.45, 0.60), const Offset(0.45, 0.88), p);
// prongs
canvas.drawLine(const Offset(0.38, 0.18), const Offset(0.38, 0.30), p);
canvas.drawLine(const Offset(0.52, 0.18), const Offset(0.52, 0.30), p);
}
}
+17
View File
@@ -0,0 +1,17 @@
import 'package:clide_app/widgets/src/clide_icon.dart';
import 'package:flutter/widgets.dart';
class CloseIcon extends ClideIconPainter {
const CloseIcon();
@override
void paint(Canvas canvas, Color color) {
final p = Paint()
..color = color
..strokeWidth = 0.10
..strokeCap = StrokeCap.round;
canvas
..drawLine(const Offset(0.22, 0.22), const Offset(0.78, 0.78), p)
..drawLine(const Offset(0.78, 0.22), const Offset(0.22, 0.78), p);
}
}