From b4cc3d50e03bf16da567a1253a337e35966d81c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 18:59:23 +0000 Subject: [PATCH 1/7] Add ${= ...} formulas and runtime style overrides Formulas compute values in text, bound properties, conditions and action values. Fill, stroke, opacity, width and height follow conditions, bound variables and set value actions. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YaHo5D7kD3eWcagvzh3o3K --- CHANGELOG.md | 14 + lib/codelessly_sdk.dart | 1 + .../passive_transformer_manager.dart | 88 ++- .../utils/condition_evaluator.dart | 37 +- .../utils/condition_visitors.dart | 16 +- .../utils/property_value_delegate.dart | 35 + lib/src/utils/formula.dart | 675 ++++++++++++++++++ test/formula_test.dart | 197 +++++ test/style_overrides_test.dart | 88 +++ 9 files changed, 1127 insertions(+), 24 deletions(-) create mode 100644 lib/src/utils/formula.dart create mode 100644 test/formula_test.dart create mode 100644 test/style_overrides_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 7aff3e76..fb2eda04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ +## Unreleased + +- Formulas: `${= expression}` in any text, bound property, condition or + action value computes a result, e.g. `${= money(qty * price)}`, + `${= sum(storage.cart, 'total')}` or `${= count > 1 ? 'drinks' : 'drink'}`. + They support arithmetic, comparisons, `&& || !`, `a ? b : c`, lists, and + the functions money, fixed, round, floor, ceil, abs, min, max, sum, count, + length, if, plural, join, upper, lower, contains, number, text, isEmpty and + isNotEmpty. A value that is exactly one formula keeps its type. Nodes + rebuild when the variables a formula reads change. +- Style properties at runtime: `fill`, `stroke`, `opacity`, `width` and + `height` follow conditions, variables or formulas bound to them, and set + value actions. + ## 7.2.0 - Require codelessly_api 5.2.0. diff --git a/lib/codelessly_sdk.dart b/lib/codelessly_sdk.dart index 0fb7c904..2d4c9f00 100644 --- a/lib/codelessly_sdk.dart +++ b/lib/codelessly_sdk.dart @@ -37,6 +37,7 @@ export 'src/utils/extensions.dart'; export 'src/utils/flutter_icons_data_map.dart'; export 'src/utils/flutter_icons_names_map.dart'; export 'src/utils/font_parser.dart'; +export 'src/utils/formula.dart'; export 'src/utils/image_builder.dart'; export 'src/utils/observable.dart'; export 'src/utils/rgb_to_color.dart'; diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index e62cd531..0ffbf17f 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -1,3 +1,5 @@ +import 'dart:math' as math; + import 'package:codelessly_api/codelessly_api.dart'; import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; @@ -78,29 +80,30 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { context, node: node, builder: (context) { + final BaseNode styled = applyStyleOverrides(context, node); Widget widget = getTransformerByNode(node) - .buildWidget(node, context, settings); + .buildWidget(styled, context, settings); if (settings.withOpacity) { - widget = applyWidgetOpacity(node, widget); + widget = applyWidgetOpacity(styled, widget); } if (settings.withReactions) { - widget = wrapWithReaction(context, node, widget); + widget = wrapWithReaction(context, styled, widget); } if (settings.withRotation) { - widget = applyWidgetRotation(context, node, widget); + widget = applyWidgetRotation(context, styled, widget); } if (settings.withConstraints) { - widget = applyWidgetConstraints(node, widget); + widget = applyWidgetConstraints(styled, widget); } if (settings.withMargins) { - widget = applyWidgetMargins(node, widget); + widget = applyWidgetMargins(styled, widget); } if (settings.withVisibility) { widget = applyWidgetVisibility( context, - node, + styled, widget, maintainState: true, ); @@ -111,6 +114,65 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ); } + /// Returns [node] with the style properties that conditions, bound + /// variables or formulas, or set value actions give it at runtime: `fill`, + /// `stroke`, `opacity`, `width` and `height`. Returns [node] itself when + /// nothing changes it. + BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { + final ScopedValues scopedValues = ScopedValues.of(context); + T? value(String property) => + PropertyValueDelegate.getPropertyValue( + node, + property, + scopedValues: scopedValues, + ); + + final Map patch = {}; + if (node is GeometryMixin) { + final GeometryMixin geometry = node; + final PaintModel? fill = value('fill'); + if (fill != null && fill != geometry.fills.firstOrNull) { + patch['fills'] = [ + fill.copyWith(visible: true).toJson(), + for (final paint in geometry.fills.skip(1)) paint.toJson(), + ]; + } + final PaintModel? stroke = value('stroke'); + if (stroke != null && stroke != geometry.strokes.firstOrNull) { + patch['strokes'] = [ + stroke.copyWith(visible: true).toJson(), + for (final paint in geometry.strokes.skip(1)) paint.toJson(), + ]; + } + } + if (node is BlendMixin) { + final double? opacity = value('opacity'); + if (opacity != null && opacity != node.opacity) { + patch['opacity'] = opacity.clamp(0, 1).toDouble(); + } + } + final NodeBox box = node.basicBoxLocal; + final double? width = value('width'); + final double? height = value('height'); + if ((width != null && width != box.width) || + (height != null && height != box.height)) { + patch['basicBoxLocal'] = NodeBox( + box.x, + box.y, + math.max(0, width ?? box.width), + math.max(0, height ?? box.height), + ).toJson(); + } + + if (patch.isEmpty) return node; + final BaseNode? styled = NodeJsonConverter().fromJson({ + ...node.toJson(), + ...patch, + }); + if (styled == null) return node; + return styled..parentID = node.parentID; + } + @override Widget buildWidgetByID( String id, @@ -180,7 +242,15 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { // Traverse through all the variables that the node properties are attached // to. - for (final String variablePath in node.variables.values.toSet()) { + // A property bound to a formula listens to the paths the formula reads. + final Set boundPaths = { + for (final String path in node.variables.values) + if (FormulaEvaluator.exactExpression(path) case final String expression) + ...FormulaEvaluator.referencedPaths(expression) + else + path, + }; + for (final String variablePath in boundPaths) { final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax()); if (match == null) continue; @@ -218,6 +288,8 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { .allMatches(node.characters) .map((match) => match.namedGroup('value')) .nonNulls, + if (node is TextNode) + ...FormulaEvaluator.referencedPathsInText(node.characters), }; for (final String variablePath in multiplePaths) { final match = VariableMatch.parse(variablePath.wrapWithVariableSyntax()); diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index cd579546..3f29a042 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -62,6 +62,14 @@ class ConditionEvaluator // A part that is exactly one path compares by its value as is, so lists // and maps keep their type for operators like contains and isEmpty. final String path = part.valueString.trim(); + // A part that is exactly one formula keeps its value's type too. + final String? expression = FormulaEvaluator.exactExpression(path); + if (expression != null) { + return PropertyValueDelegate.evaluateFormula( + expression, + scopedValues: scopedValues, + ); + } if (RegExp(r'^\$\{[^{}]+\}$').hasMatch(path)) { final Object? raw = PropertyValueDelegate.getVariableValueFromPath( @@ -70,17 +78,26 @@ class ConditionEvaluator ); if (raw is List || raw is Map) return raw; } - final value = part.valueString.splitMapJoinRegex( - variablePathRegex, - onMatch: (match) { - final Object? value = PropertyValueDelegate.getVariableValueFromPath( - match[0]!, - scopedValues: scopedValues, + final value = + FormulaEvaluator.substitute( + part.valueString, + (path) => PropertyValueDelegate.getVariableValueFromPath( + path, + scopedValues: scopedValues, + ), + nullText: 'null', + ).splitMapJoinRegex( + variablePathRegex, + onMatch: (match) { + final Object? value = + PropertyValueDelegate.getVariableValueFromPath( + match[0]!, + scopedValues: scopedValues, + ); + + return value?.typedValue() ?? 'null'; + }, ); - - return value?.typedValue() ?? 'null'; - }, - ); return _visitRawValue(value); } diff --git a/lib/src/transformers/utils/condition_visitors.dart b/lib/src/transformers/utils/condition_visitors.dart index 1564dd06..a8689ff1 100644 --- a/lib/src/transformers/utils/condition_visitors.dart +++ b/lib/src/transformers/utils/condition_visitors.dart @@ -1,5 +1,7 @@ import 'package:codelessly_api/codelessly_api.dart'; +import '../../utils/formula.dart'; + /// A visitor that returns the list of variable names used in a condition. class ConditionVariablesVisitor implements @@ -62,12 +64,14 @@ class ConditionVariablesVisitor @override Set visitVariablePart(VariablePart part) { - return variablePathRegex - .allMatches(part.valueString) - .map((match) => match.namedGroup('name')) - .nonNulls - .where((element) => !excludedVariableNames.contains(element)) - .toSet(); + return { + ...variablePathRegex + .allMatches(part.valueString) + .map((match) => match.namedGroup('name')) + .nonNulls, + ...FormulaEvaluator.referencedPathsInText(part.valueString) + .map((path) => path.split(RegExp(r'[.\[]')).first), + }.where((element) => !excludedVariableNames.contains(element)).toSet(); } @override diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 3be42119..2bd82e03 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -179,6 +179,11 @@ class PropertyValueDelegate { ScopedValues scopedValues, ) { return switch (value) { + String() when FormulaEvaluator.exactExpression(value) != null => + evaluateFormula( + FormulaEvaluator.exactExpression(value)!, + scopedValues: scopedValues, + ), String() => RegExp(r'^\$\{[^{}]+\}$').hasMatch(value.trim()) ? _retrieveVariableValue(value.trim(), scopedValues: scopedValues) @@ -211,6 +216,16 @@ class PropertyValueDelegate { required NullSubstitutionMode nullSubstitutionMode, }) { if (text.trim().isEmpty) return text; + // Formulas first, so `${= ...}` isn't read as a path. + text = FormulaEvaluator.substitute( + text, + (path) => + getVariableValueFromPath(path, scopedValues: scopedValues), + nullText: switch (nullSubstitutionMode) { + NullSubstitutionMode.nullValue => 'null', + _ => '', + }, + ); return text.splitMapJoinRegex( variablePathRegex, onNonMatch: (text) => text, @@ -232,6 +247,17 @@ class PropertyValueDelegate { ); } + /// Evaluates a formula [expression] (the part after `${=`), reading its + /// variable paths from [scopedValues]. See [FormulaEvaluator]. + static Object? evaluateFormula( + String expression, { + required ScopedValues scopedValues, + }) => FormulaEvaluator.evaluate( + expression, + (path) => + getVariableValueFromPath(path, scopedValues: scopedValues), + ); + /// Retrieves the value of a variable [path]. This also supports predefined /// variables such as data, index, and item. /// @@ -246,6 +272,15 @@ class PropertyValueDelegate { String path, { required ScopedValues scopedValues, }) { + final String? expression = FormulaEvaluator.exactExpression(path); + if (expression != null) { + final Object? value = evaluateFormula( + expression, + scopedValues: scopedValues, + ); + if (R == String) return FormulaEvaluator.stringify(value) as R?; + return value?.typedValue(); + } final match = VariableMatch.parse(path.wrapWithVariableSyntax()); if (match == null) return null; diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart new file mode 100644 index 00000000..cafc1b49 --- /dev/null +++ b/lib/src/utils/formula.dart @@ -0,0 +1,675 @@ +import 'dart:math' as math; + +/// Matches a formula written as `${= expression}` in a text. +/// +/// The expression can't contain braces, except inside quoted strings. +final RegExp formulaRegex = RegExp( + r'''(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}''', +); + +/// Matches a text that is exactly one formula, e.g. `${= qty * price}`. +final RegExp exactFormulaRegex = RegExp( + r'''^\s*(?(?:[^{}'"]|'[^']*'|"[^"]*")*)\}\s*$''', +); + +/// Resolves a variable path such as `storage.cart` or `item.price` to its +/// value. +typedef FormulaPathResolver = Object? Function(String path); + +/// Evaluates small expressions used in `${= ...}` formulas. +/// +/// Supports: +/// - numbers, 'single' or "double" quoted strings, true, false, null and +/// `[a, b]` lists. +/// - variable paths like `storage.cart.length` or `item.price`, resolved with +/// the same rules as `${path}`, followed by `[index]` or `.key`. +/// - `+ - * / %`, comparisons `== != < <= > >=`, `&& || !` and +/// `condition ? a : b`. `+` joins text when either side is text. +/// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, +/// length, if, plural, join, upper, lower, contains, number, text, isEmpty +/// and isNotEmpty. See [FormulaEvaluator.functions]. +/// +/// Null counts as 0 in arithmetic and as empty text when joining text. An +/// invalid expression evaluates to null. +class FormulaEvaluator { + const FormulaEvaluator._(); + + static final Map _cache = {}; + + /// Evaluates [expression], resolving variable paths with [resolve]. + static Object? evaluate(String expression, FormulaPathResolver resolve) { + final _Node? node = _parse(expression); + if (node == null) return null; + try { + return node.eval(resolve); + } catch (_) { + return null; + } + } + + /// The variable paths [expression] reads, e.g. `storage.cart` and `qty`. + static Set referencedPaths(String expression) { + final _Node? node = _parse(expression); + if (node == null) return {}; + final Set paths = {}; + node.collectPaths(paths); + return paths; + } + + /// The variable paths read by every formula in [text]. + static Set referencedPathsInText(String text) => { + for (final match in formulaRegex.allMatches(text)) + ...referencedPaths(match.namedGroup('expr')!), + }; + + /// Replaces every `${= ...}` formula in [text] with its result as text. + /// A formula that evaluates to null becomes [nullText]. + static String substitute( + String text, + FormulaPathResolver resolve, { + String nullText = '', + }) { + if (!text.contains(r'${=')) return text; + return text.splitMapJoin( + formulaRegex, + onMatch: (match) { + final Object? value = evaluate( + (match as RegExpMatch).namedGroup('expr')!, + resolve, + ); + return value == null ? nullText : stringify(value); + }, + onNonMatch: (text) => text, + ); + } + + /// The expression of [text] when it is exactly one formula, else null. + static String? exactExpression(String text) => + exactFormulaRegex.firstMatch(text)?.namedGroup('expr'); + + /// Formats [value] as text: whole numbers drop their decimals and other + /// numbers lose floating point noise, e.g. 17.200000000000003 is "17.2". + static String stringify(Object? value) { + if (value == null) return ''; + if (value is num) { + if (value.isNaN || value.isInfinite) return ''; + if (value == value.roundToDouble()) return value.toInt().toString(); + String text = value.toStringAsFixed(10); + text = text.replaceFirst(RegExp(r'0+$'), ''); + return text.endsWith('.') ? text.substring(0, text.length - 1) : text; + } + if (value is List) return value.map(stringify).join(', '); + return value.toString(); + } + + /// Parses [expression], caching the result. Returns null when it's invalid. + static _Node? _parse(String expression) { + final String key = expression.trim(); + if (_cache.containsKey(key)) return _cache[key]; + _Node? node; + try { + node = _Parser(key).parseAll(); + } catch (_) { + node = null; + } + if (_cache.length > 2000) _cache.clear(); + return _cache[key] = node; + } + + /// Names of the functions formulas can call. + static Iterable get functions => _functions.keys; +} + +// --------------------------------------------------------------------------- +// Values +// --------------------------------------------------------------------------- + +bool _truthy(Object? value) => switch (value) { + null => false, + bool() => value, + num() => value != 0, + String() => value.isNotEmpty && value.toLowerCase() != 'false', + Iterable() => value.isNotEmpty, + Map() => value.isNotEmpty, + _ => true, +}; + +/// Reads [value] as a number: numbers as is, numeric text parsed, null as 0. +num? _toNum(Object? value) => switch (value) { + null => 0, + num() => value, + bool() => value ? 1 : 0, + String() => value.trim().isEmpty ? 0 : num.tryParse(value.trim()), + _ => null, +}; + +num _num(Object? value) => _toNum(value) ?? double.nan; + +/// Keeps whole results as ints so `2 * 3` prints "6". +num _tidy(num value) => + value is double && value.isFinite && value == value.roundToDouble() + ? value.toInt() + : value; + +bool _looseEquals(Object? a, Object? b) { + if (a == null || b == null) return a == b; + final num? na = a is String ? num.tryParse(a) : _toNum(a); + final num? nb = b is String ? num.tryParse(b) : _toNum(b); + if ((a is num || b is num) && na != null && nb != null) return na == nb; + if (a is bool || b is bool) return _truthy(a) == _truthy(b); + return a.toString() == b.toString(); +} + +int _compare(Object? a, Object? b) { + final num? na = a is String ? num.tryParse(a) : _toNum(a); + final num? nb = b is String ? num.tryParse(b) : _toNum(b); + if (na != null && nb != null) return na.compareTo(nb); + return (a?.toString() ?? '').compareTo(b?.toString() ?? ''); +} + +Object? _member(Object? target, Object? key) { + if (target == null) return null; + if (target is List) { + if (key == 'length') return target.length; + if (key == 'first') return target.isEmpty ? null : target.first; + if (key == 'last') return target.isEmpty ? null : target.last; + final num? index = _toNum(key); + if (index == null) return null; + final int i = index.toInt(); + return i >= 0 && i < target.length ? target[i] : null; + } + if (target is Map) { + if (target.containsKey(key)) return target[key]; + if (key == 'length') return target.length; + return target[key.toString()]; + } + if (target is String) { + if (key == 'length') return target.length; + final num? index = _toNum(key); + if (index == null) return null; + final int i = index.toInt(); + return i >= 0 && i < target.length ? target[i] : null; + } + return null; +} + +String _fixed(Object? value, Object? digits) => + _num(value).toStringAsFixed(_num(digits ?? 2).toInt().clamp(0, 20)); + +String _money(Object? value, Object? symbol) { + final num amount = _num(value); + if (amount.isNaN) return ''; + final String sign = amount < 0 ? '-' : ''; + final String text = amount.abs().toStringAsFixed(2); + final int dot = text.indexOf('.'); + final String whole = text + .substring(0, dot) + .replaceAllMapped(RegExp(r'\B(?=(\d{3})+(?!\d))'), (_) => ','); + return '$sign${symbol ?? r'$'}$whole${text.substring(dot)}'; +} + +num _round(Object? value, Object? digits) { + final num v = _num(value); + final int d = _num(digits ?? 0).toInt(); + if (d <= 0) return v.round(); + final num factor = math.pow(10, d); + return _tidy((v * factor).round() / factor); +} + +/// Values of [list], or of each item's [field] when given. +Iterable _values(Object? list, Object? field) { + if (list is! Iterable) return const []; + if (field == null) return list; + return list.map((item) => _member(item, field)); +} + +typedef _Function = Object? Function(List args); + +Object? _arg(List args, int index) => + index < args.length ? args[index] : null; + +final Map _functions = { + 'money': (a) => _money(_arg(a, 0), _arg(a, 1)), + 'fixed': (a) => _fixed(_arg(a, 0), _arg(a, 1)), + 'round': (a) => _round(_arg(a, 0), _arg(a, 1)), + 'floor': (a) => _num(_arg(a, 0)).floor(), + 'ceil': (a) => _num(_arg(a, 0)).ceil(), + 'abs': (a) => _num(_arg(a, 0)).abs(), + 'min': (a) => (a.length == 1 && a.first is Iterable ? a.first as Iterable : a) + .map(_num) + .reduce(math.min), + 'max': (a) => (a.length == 1 && a.first is Iterable ? a.first as Iterable : a) + .map(_num) + .reduce(math.max), + 'sum': (a) => _tidy( + _values(_arg(a, 0), _arg(a, 1)).fold(0, (s, v) => s + _num(v)), + ), + 'count': (a) { + if (a.length < 3) return _values(_arg(a, 0), null).length; + return _values( + _arg(a, 0), + _arg(a, 1), + ).where((v) => _looseEquals(v, _arg(a, 2))).length; + }, + 'length': (a) => switch (_arg(a, 0)) { + final String s => s.length, + final Iterable l => l.length, + final Map m => m.length, + _ => 0, + }, + 'if': (a) => _truthy(_arg(a, 0)) ? _arg(a, 1) : _arg(a, 2), + 'plural': (a) => + _num(_arg(a, 0)) == 1 ? _arg(a, 1) : (_arg(a, 2) ?? '${_arg(a, 1)}s'), + 'join': (a) => _values(_arg(a, 0), _arg(a, 2)) + .map(FormulaEvaluator.stringify) + .where((s) => s.isNotEmpty) + .join((_arg(a, 1) ?? ', ').toString()), + 'upper': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toUpperCase(), + 'lower': (a) => FormulaEvaluator.stringify(_arg(a, 0)).toLowerCase(), + 'contains': (a) => switch (_arg(a, 0)) { + final String s => s.contains(FormulaEvaluator.stringify(_arg(a, 1))), + final Iterable l => l.any((v) => _looseEquals(v, _arg(a, 1))), + final Map m => m.containsKey(_arg(a, 1)), + _ => false, + }, + 'number': (a) => _toNum(_arg(a, 0)), + 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), + 'isEmpty': (a) => !_truthy(_functions['length']!(a)), + 'isNotEmpty': (a) => _truthy(_functions['length']!(a)), +}; + +// --------------------------------------------------------------------------- +// Syntax tree +// --------------------------------------------------------------------------- + +sealed class _Node { + const _Node(); + + Object? eval(FormulaPathResolver resolve); + + void collectPaths(Set paths) {} +} + +class _Literal extends _Node { + final Object? value; + + const _Literal(this.value); + + @override + Object? eval(FormulaPathResolver resolve) => value; +} + +class _Path extends _Node { + final String path; + + const _Path(this.path); + + @override + Object? eval(FormulaPathResolver resolve) => resolve(path); + + @override + void collectPaths(Set paths) => paths.add(path); +} + +class _ListLiteral extends _Node { + final List<_Node> items; + + const _ListLiteral(this.items); + + @override + Object? eval(FormulaPathResolver resolve) => [ + for (final item in items) item.eval(resolve), + ]; + + @override + void collectPaths(Set paths) { + for (final item in items) { + item.collectPaths(paths); + } + } +} + +class _Member extends _Node { + final _Node target; + final _Node key; + + const _Member(this.target, this.key); + + @override + Object? eval(FormulaPathResolver resolve) => + _member(target.eval(resolve), key.eval(resolve)); + + @override + void collectPaths(Set paths) { + target.collectPaths(paths); + key.collectPaths(paths); + } +} + +class _Call extends _Node { + final String name; + final List<_Node> args; + + const _Call(this.name, this.args); + + @override + Object? eval(FormulaPathResolver resolve) { + // `if` only evaluates the branch it returns. + if (name == 'if') { + _Node? arg(int i) => i < args.length ? args[i] : null; + return _truthy(arg(0)?.eval(resolve)) + ? arg(1)?.eval(resolve) + : arg(2)?.eval(resolve); + } + return _functions[name]!([for (final arg in args) arg.eval(resolve)]); + } + + @override + void collectPaths(Set paths) { + for (final arg in args) { + arg.collectPaths(paths); + } + } +} + +class _Unary extends _Node { + final String op; + final _Node operand; + + const _Unary(this.op, this.operand); + + @override + Object? eval(FormulaPathResolver resolve) { + final Object? value = operand.eval(resolve); + return op == '!' ? !_truthy(value) : _tidy(-_num(value)); + } + + @override + void collectPaths(Set paths) => operand.collectPaths(paths); +} + +class _Binary extends _Node { + final String op; + final _Node left; + final _Node right; + + const _Binary(this.op, this.left, this.right); + + @override + Object? eval(FormulaPathResolver resolve) { + final Object? a = left.eval(resolve); + if (op == '&&') return _truthy(a) ? right.eval(resolve) : a; + if (op == '||') return _truthy(a) ? a : right.eval(resolve); + final Object? b = right.eval(resolve); + switch (op) { + case '+': + final num? na = a is String ? null : _toNum(a); + final num? nb = b is String ? null : _toNum(b); + if (na != null && nb != null) return _tidy(na + nb); + return FormulaEvaluator.stringify(a) + FormulaEvaluator.stringify(b); + case '-': + return _tidy(_num(a) - _num(b)); + case '*': + return _tidy(_num(a) * _num(b)); + case '/': + final num divisor = _num(b); + return divisor == 0 ? null : _tidy(_num(a) / divisor); + case '%': + final num divisor = _num(b); + return divisor == 0 ? null : _tidy(_num(a) % divisor); + case '==': + return _looseEquals(a, b); + case '!=': + return !_looseEquals(a, b); + case '<': + return _compare(a, b) < 0; + case '<=': + return _compare(a, b) <= 0; + case '>': + return _compare(a, b) > 0; + case '>=': + return _compare(a, b) >= 0; + } + return null; + } + + @override + void collectPaths(Set paths) { + left.collectPaths(paths); + right.collectPaths(paths); + } +} + +class _Ternary extends _Node { + final _Node condition; + final _Node then; + final _Node otherwise; + + const _Ternary(this.condition, this.then, this.otherwise); + + @override + Object? eval(FormulaPathResolver resolve) => _truthy(condition.eval(resolve)) + ? then.eval(resolve) + : otherwise.eval(resolve); + + @override + void collectPaths(Set paths) { + condition.collectPaths(paths); + then.collectPaths(paths); + otherwise.collectPaths(paths); + } +} + +// --------------------------------------------------------------------------- +// Parser +// --------------------------------------------------------------------------- + +class _Parser { + final String source; + int pos = 0; + + _Parser(this.source); + + static final RegExp _number = RegExp(r'\d+(\.\d+)?|\.\d+'); + static final RegExp _identifier = RegExp(r'[a-zA-Z_][a-zA-Z0-9_]*'); + + _Node parseAll() { + final _Node node = _ternary(); + _skip(); + if (pos != source.length) { + throw FormatException('Unexpected "${source[pos]}"', source, pos); + } + return node; + } + + void _skip() { + while (pos < source.length && source[pos].trim().isEmpty) { + pos++; + } + } + + bool _eat(String token) { + _skip(); + if (!source.startsWith(token, pos)) return false; + // Don't read `=` of `==` or `<=` as a separate token. + if ((token == '<' || token == '>' || token == '!') && + source.startsWith('=', pos + 1)) { + return false; + } + pos += token.length; + return true; + } + + void _expect(String token) { + if (!_eat(token)) throw FormatException('Expected "$token"', source, pos); + } + + _Node _ternary() { + final _Node condition = _or(); + if (!_eat('?')) return condition; + final _Node then = _ternary(); + _expect(':'); + return _Ternary(condition, then, _ternary()); + } + + _Node _or() { + _Node node = _and(); + while (_eat('||')) { + node = _Binary('||', node, _and()); + } + return node; + } + + _Node _and() { + _Node node = _equality(); + while (_eat('&&')) { + node = _Binary('&&', node, _equality()); + } + return node; + } + + _Node _equality() { + _Node node = _comparison(); + while (true) { + if (_eat('==')) { + node = _Binary('==', node, _comparison()); + } else if (_eat('!=')) { + node = _Binary('!=', node, _comparison()); + } else { + return node; + } + } + } + + _Node _comparison() { + _Node node = _additive(); + while (true) { + final String? op = ['<=', '>=', '<', '>'].where(_eat).firstOrNull; + if (op == null) return node; + node = _Binary(op, node, _additive()); + } + } + + _Node _additive() { + _Node node = _multiplicative(); + while (true) { + final String? op = ['+', '-'].where(_eat).firstOrNull; + if (op == null) return node; + node = _Binary(op, node, _multiplicative()); + } + } + + _Node _multiplicative() { + _Node node = _unary(); + while (true) { + final String? op = ['*', '/', '%'].where(_eat).firstOrNull; + if (op == null) return node; + node = _Binary(op, node, _unary()); + } + } + + _Node _unary() { + if (_eat('!')) return _Unary('!', _unary()); + if (_eat('-')) return _Unary('-', _unary()); + return _postfix(_primary()); + } + + _Node _postfix(_Node node) { + while (true) { + if (_eat('[')) { + node = _Member(node, _ternary()); + _expect(']'); + } else if (_eat('.')) { + final Match? name = _identifier.matchAsPrefix(source, pos); + if (name == null) throw FormatException('Expected a name', source, pos); + pos = name.end; + node = _Member(node, _Literal(name[0])); + } else { + return node; + } + } + } + + _Node _primary() { + _skip(); + if (pos >= source.length) { + throw FormatException('Unexpected end', source, pos); + } + final String char = source[pos]; + + if (char == '(') { + pos++; + final _Node node = _ternary(); + _expect(')'); + return node; + } + + if (char == '[') { + pos++; + final List<_Node> items = []; + if (!_eat(']')) { + do { + items.add(_ternary()); + } while (_eat(',')); + _expect(']'); + } + return _ListLiteral(items); + } + + if (char == "'" || char == '"') { + final int end = source.indexOf(char, pos + 1); + if (end < 0) throw FormatException('Unclosed text', source, pos); + final String text = source.substring(pos + 1, end); + pos = end + 1; + return _Literal(text); + } + + final Match? number = _number.matchAsPrefix(source, pos); + if (number != null) { + pos = number.end; + return _Literal(num.parse(number[0]!)); + } + + final Match? identifier = _identifier.matchAsPrefix(source, pos); + if (identifier == null) { + throw FormatException('Unexpected "$char"', source, pos); + } + pos = identifier.end; + final String name = identifier[0]!; + + switch (name) { + case 'true': + return const _Literal(true); + case 'false': + return const _Literal(false); + case 'null': + return const _Literal(null); + } + + if (_eat('(')) { + if (!_functions.containsKey(name)) { + throw FormatException('Unknown function "$name"', source, pos); + } + final List<_Node> args = []; + if (!_eat(')')) { + do { + args.add(_ternary()); + } while (_eat(',')); + _expect(')'); + } + return _Call(name, args); + } + + // A variable path: name followed by `.key` and `[digits]` parts, read as + // one path so accessors like `.length` and `.initial` work as in `${}`. + final StringBuffer path = StringBuffer(name); + while (true) { + final Match? part = RegExp(r'\.[a-zA-Z][a-zA-Z0-9_]*|\[\d+\]') + .matchAsPrefix(source, pos); + if (part == null) break; + path.write(part[0]); + pos = part.end; + } + return _Path(path.toString()); + } +} diff --git a/test/formula_test.dart b/test/formula_test.dart new file mode 100644 index 00000000..bed0b4ee --- /dev/null +++ b/test/formula_test.dart @@ -0,0 +1,197 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + final Map vars = { + 'qty': 2, + 'price': 5.25, + 'name': 'Ava', + 'shots': '3', + 'cart': [ + {'name': 'Cortado', 'qty': 1, 'total': 4.75}, + {'name': 'Oat latte', 'qty': 2, 'total': 12.9}, + ], + 'syrups': ['vanilla', 'honey'], + }; + Object? resolve(String path) { + final parts = RegExp(r'[a-zA-Z_]\w*|\d+').allMatches(path).toList(); + Object? value = vars[parts.first[0]]; + for (final part in parts.skip(1).map((m) => m[0]!)) { + value = switch (value) { + final List list when part == 'length' => list.length, + final List list => list[int.parse(part)], + final Map map => map[part], + _ => null, + }; + } + return value; + } + + Object? eval(String expression) => + FormulaEvaluator.evaluate(expression, resolve); + + group('FormulaEvaluator', () { + test('arithmetic and precedence', () { + expect(eval('1 + 2 * 3'), 7); + expect(eval('(1 + 2) * 3'), 9); + expect(eval('qty * price'), 10.5); + expect(eval('10 / 4'), 2.5); + expect(eval('10 / 0'), isNull); + expect(eval('7 % 3'), 1); + expect(eval('-qty + 1'), -1); + expect(eval('shots * 0.9'), closeTo(2.7, 1e-9)); + expect(eval('missing + 1'), 1); + }); + + test('text', () { + expect(eval("'Hi ' + name"), 'Hi Ava'); + expect(eval('"x" + 2'), 'x2'); + expect(eval("upper(name) + lower('B')"), 'AVAb'); + expect(eval("plural(qty, 'drink')"), 'drinks'); + expect(eval("plural(1, 'drink')"), 'drink'); + expect(eval("join(syrups, ' · ')"), 'vanilla · honey'); + expect(eval("join(cart, ', ', 'name')"), 'Cortado, Oat latte'); + }); + + test('comparisons, logic and ternary', () { + expect(eval('qty > 1 && price < 6'), true); + expect(eval('qty == 2'), true); + expect(eval("shots == 3"), true); + expect(eval("name != 'Ava'"), false); + expect(eval('!qty'), false); + expect(eval("qty > 1 ? 'many' : 'one'"), 'many'); + expect(eval("qty > 5 ? 'a' : qty > 1 ? 'b' : 'c'"), 'b'); + expect(eval('missing || 4'), 4); + }); + + test('functions', () { + expect(eval('money(qty * price)'), r'$10.50'); + expect(eval('money(1234567.891)'), r'$1,234,567.89'); + expect(eval("money(-3, '€')"), '-€3.00'); + expect(eval('fixed(price, 1)'), '5.3'); + expect(eval('round(2.345, 2)'), 2.35); + expect(eval('round(2.5)'), 3); + expect(eval('floor(2.9) + ceil(2.1)'), 5); + expect(eval('min(3, 1, 2)'), 1); + expect(eval('max([3, 9, 2])'), 9); + expect(eval("sum(cart, 'total')"), 17.65); + expect(eval("sum(cart, 'qty')"), 3); + expect(eval('count(cart)'), 2); + expect(eval("count(cart, 'qty', 2)"), 1); + expect(eval('length(syrups)'), 2); + expect(eval("contains(syrups, 'honey')"), true); + expect(eval("if(qty > 1, 'yes', 'no')"), 'yes'); + expect(eval('isEmpty(cart)'), false); + expect(eval("number('4.5') + 1"), 5.5); + }); + + test('member access', () { + expect(eval('cart[1].name'), 'Oat latte'); + expect(eval('cart[qty - 1].total'), 12.9); + expect(eval('cart.length'), 2); + expect(eval('syrups[5]'), isNull); + }); + + test('invalid expressions evaluate to null', () { + expect(eval('1 +'), isNull); + expect(eval('nope(1)'), isNull); + expect(eval("'open"), isNull); + }); + + test('substitute and stringify', () { + expect( + FormulaEvaluator.substitute(r'Total ${= money(qty * price)}!', resolve), + r'Total $10.50!', + ); + expect(FormulaEvaluator.substitute(r'${= 0.1 + 0.2}', resolve), '0.3'); + expect(FormulaEvaluator.substitute(r'${= 3 * 2.0}', resolve), '6'); + expect( + FormulaEvaluator.substitute(r"${= qty > 1 ? '}' : '{'}", resolve), + '}', + ); + expect(FormulaEvaluator.substitute(r'\${= 1}', resolve), r'\${= 1}'); + expect(FormulaEvaluator.exactExpression(r' ${= a + b} '), ' a + b'); + expect(FormulaEvaluator.exactExpression(r'${= a} and ${b}'), isNull); + }); + + test('referenced paths', () { + expect( + FormulaEvaluator.referencedPaths( + "money(sum(storage.cart, 'total')) + item.name + cart[index].qty", + ), + {'storage.cart', 'item.name', 'cart', 'index'}, + ); + expect( + FormulaEvaluator.referencedPathsInText(r'${= a + 1} ${b} ${= c}'), + {'a', 'c'}, + ); + }); + }); + + testWidgets('formulas read variables through PropertyValueDelegate', ( + tester, + ) async { + final List variables = [ + VariableData( + id: 'qty', + name: 'qty', + type: VariableType.integer, + value: 2, + ), + VariableData( + id: 'lines', + name: 'lines', + type: VariableType.list, + value: [ + {'total': 8.6}, + {'total': 11.85}, + ], + ), + ]; + late ScopedValues values; + await tester.pumpWidget( + IndexedItemProvider( + item: IndexedItem(0, {'price': 4.25, 'name': 'Cortado'}), + child: Builder( + builder: (context) { + values = ScopedValues.of(context, variablesOverrides: variables); + return const SizedBox(); + }, + ), + ), + ); + + String sub(String text) => PropertyValueDelegate.substituteVariables( + text, + scopedValues: values, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + + expect(sub(r'${qty} × ${= money(item.price * qty)}'), r'2 × $8.50'); + expect(sub(r"${= sum(lines, 'total')}"), '20.45'); + expect(sub(r'${= lines.length} ${item.name}'), '2 Cortado'); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= qty * 3}', + scopedValues: values, + ), + 6, + ); + expect( + PropertyValueDelegate.getVariableValueFromPath( + r'${= qty > 1}', + scopedValues: values, + ), + true, + ); + expect( + PropertyValueDelegate.substituteVariablesInJson( + r'{"total": "${= item.price * qty}", "label": "${= item.name} x${qty}"}', + scopedValues: values, + ), + {'total': 8.5, 'label': 'Cortado x2'}, + ); + }); +} diff --git a/test/style_overrides_test.dart b/test/style_overrides_test.dart new file mode 100644 index 00000000..67fe0c8e --- /dev/null +++ b/test/style_overrides_test.dart @@ -0,0 +1,88 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('fill, opacity and size follow bound formulas', (tester) async { + final RectangleNode node = RectangleNode.fromJson({ + ...RectangleNode( + id: 'cup', + name: 'Cup layer', + basicBoxLocal: NodeBox(0, 0, 40, 40), + fills: [ + PaintModel.solid(id: 'fill', color: ColorRGB.fromHex('#EDE3D6')), + ], + ).toJson(), + 'variables': { + 'fill': r"${= qty > 1 ? '#C8643B' : '#EDE3D6'}", + 'height': r'${= qty * 20}', + 'opacity': r'${= qty > 2 ? 0.5 : 1}', + }, + }); + node.parentID = kRootNode; + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + final Observable qty = Observable( + VariableData( + id: 'qty', + name: 'qty', + type: VariableType.integer, + value: 1, + ), + ); + final CodelesslyContext codelesslyContext = CodelesslyContext.empty() + ..variables['qty'] = qty; + + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: codelesslyContext, + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); + + Color? color() => tester.allWidgets + .map( + (w) => switch (w) { + final Ink ink => ink.decoration, + final DecoratedBox box => box.decoration, + _ => null, + }, + ) + .whereType() + .map((decoration) => decoration.color) + .nonNulls + .firstOrNull; + final Finder box = find.byWidgetPredicate( + (w) => w is Container && w.decoration is BoxDecoration, + ); + + expect(tester.getSize(box).height, 20); + expect(color(), const Color(0xFFEDE3D6)); + expect(find.byType(Opacity), findsNothing); + + qty.set(qty.value.copyWith(value: 3)); + await tester.pump(); + + expect(tester.getSize(box).height, 60); + expect(color(), const Color(0xFFC8643B)); + expect(tester.widget(find.byType(Opacity)).opacity, 0.5); + // The node itself is unchanged. + expect(node.basicBoxLocal.height, 40); + }); +} From 4e7cf234ea936ff4b88555c0c6aafddfa306b0b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:18:11 +0000 Subject: [PATCH 2/7] Formulas: where and without; set list and map values from JSON templates safely A list or map value written as JSON is substituted value by value, so text with quotes can't break it and a value that is exactly one formula or path keeps its type. A text node's fill sets its color at runtime. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YaHo5D7kD3eWcagvzh3o3K --- lib/src/functions/functions_repository.dart | 39 ++++++++++++++++--- .../passive_transformer_manager.dart | 20 +++++++++- .../utils/property_value_delegate.dart | 7 ++++ lib/src/utils/formula.dart | 16 +++++++- test/formula_test.dart | 13 +++++++ 5 files changed, 85 insertions(+), 10 deletions(-) diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index 4537e3e2..c573ed13 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -942,6 +942,33 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. payload.nodeValues[action.nodeID]!.set(updateValues, notify: notify); } + /// Substitutes variables in the [template] of a value to set. For list and + /// map values written as JSON, each JSON value is substituted on its own + /// (see [PropertyValueDelegate.substituteJsonValue]), so text holding + /// quotes or newlines can't break the JSON. Everything else is substituted + /// as text. + static String substituteNewValue( + String template, + VariableType type, + ScopedValues scopedValues, + ) { + if (type.isList || type.isMap) { + final Object? parsed = tryJsonDecode(template.trim()); + if (parsed is List || parsed is Map) { + return jsonEncode( + PropertyValueDelegate.substituteJsonValue(parsed, scopedValues), + toEncodable: (value) => + value is DateTime ? value.toIso8601String() : value.toString(), + ); + } + } + return PropertyValueDelegate.substituteVariables( + template, + nullSubstitutionMode: NullSubstitutionMode.emptyString, + scopedValues: scopedValues, + ); + } + /// Sets given [action.newValue] for given [action.variable] to a variable /// from [CodelesslyContext.variables]. /// Returns `true` if variable was found and updated, `false` otherwise. @@ -958,10 +985,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. ); if (variableNotifier == null) return false; - String newValue = PropertyValueDelegate.substituteVariables( + String newValue = substituteNewValue( action.newValue, - nullSubstitutionMode: NullSubstitutionMode.emptyString, - scopedValues: scopedValues, + variableNotifier.value.type, + scopedValues, ); Object? currentValue = variableNotifier.value.getValue(); @@ -1275,10 +1302,10 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. scopedValues: scopedValues, ); - final newValue = PropertyValueDelegate.substituteVariables( + final newValue = substituteNewValue( action.newValue, - nullSubstitutionMode: NullSubstitutionMode.emptyString, - scopedValues: scopedValues, + action.variableType, + scopedValues, ); final match = VariableMatch.parse(storageKey.wrapWithVariableSyntax()); diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 0ffbf17f..7a4fd314 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -115,8 +115,9 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { } /// Returns [node] with the style properties that conditions, bound - /// variables or formulas, or set value actions give it at runtime: `fill`, - /// `stroke`, `opacity`, `width` and `height`. Returns [node] itself when + /// variables or formulas, or set value actions give it at runtime: `fill` + /// (a text's color for text nodes), `stroke`, `opacity`, `width` and + /// `height`. Returns [node] itself when /// nothing changes it. BaseNode applyStyleOverrides(BuildContext context, BaseNode node) { final ScopedValues scopedValues = ScopedValues.of(context); @@ -145,6 +146,21 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { ]; } } + if (node is TextNode) { + // A text's fill is its color: it replaces the color of every span. + final PaintModel? fill = value('fill'); + final PaintModel? current = + node.textMixedProps.firstOrNull?.fills.firstOrNull; + if (fill != null && fill != current) { + patch['textMixedProps'] = [ + for (final prop in node.textMixedProps) + { + ...prop.toJson(), + 'fills': [fill.copyWith(visible: true).toJson()], + }, + ]; + } + } if (node is BlendMixin) { final double? opacity = value('opacity'); if (opacity != null && opacity != node.opacity) { diff --git a/lib/src/transformers/utils/property_value_delegate.dart b/lib/src/transformers/utils/property_value_delegate.dart index 2bd82e03..6bee63c4 100644 --- a/lib/src/transformers/utils/property_value_delegate.dart +++ b/lib/src/transformers/utils/property_value_delegate.dart @@ -174,6 +174,13 @@ class PropertyValueDelegate { ); } + /// Substitutes variables in a decoded JSON [value]: see + /// [substituteVariablesInJson]. + static Object? substituteJsonValue( + Object? value, + ScopedValues scopedValues, + ) => _substituteJsonValue(value, scopedValues); + static Object? _substituteJsonValue( Object? value, ScopedValues scopedValues, diff --git a/lib/src/utils/formula.dart b/lib/src/utils/formula.dart index cafc1b49..9425995b 100644 --- a/lib/src/utils/formula.dart +++ b/lib/src/utils/formula.dart @@ -26,8 +26,8 @@ typedef FormulaPathResolver = Object? Function(String path); /// - `+ - * / %`, comparisons `== != < <= > >=`, `&& || !` and /// `condition ? a : b`. `+` joins text when either side is text. /// - functions: money, fixed, round, floor, ceil, abs, min, max, sum, count, -/// length, if, plural, join, upper, lower, contains, number, text, isEmpty -/// and isNotEmpty. See [FormulaEvaluator.functions]. +/// length, if, plural, join, upper, lower, contains, where, without, +/// number, text, isEmpty and isNotEmpty. See [FormulaEvaluator.functions]. /// /// Null counts as 0 in arithmetic and as empty text when joining text. An /// invalid expression evaluates to null. @@ -272,6 +272,18 @@ final Map _functions = { final Map m => m.containsKey(_arg(a, 1)), _ => false, }, + 'where': (a) => [ + for (final item in _values(_arg(a, 0), null)) + if (a.length < 3 + ? _truthy(_member(item, _arg(a, 1))) + : _looseEquals(_member(item, _arg(a, 1)), _arg(a, 2))) + item, + ], + 'without': (a) => [ + for (final item in _values(_arg(a, 0), null)) + if (!_values(_arg(a, 1), null).any((other) => _looseEquals(item, other))) + item, + ], 'number': (a) => _toNum(_arg(a, 0)), 'text': (a) => FormulaEvaluator.stringify(_arg(a, 0)), 'isEmpty': (a) => !_truthy(_functions['length']!(a)), diff --git a/test/formula_test.dart b/test/formula_test.dart index bed0b4ee..21d9886b 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -85,6 +85,11 @@ void main() { expect(eval("if(qty > 1, 'yes', 'no')"), 'yes'); expect(eval('isEmpty(cart)'), false); expect(eval("number('4.5') + 1"), 5.5); + expect(eval("where(cart, 'qty', 2)"), [ + {'name': 'Oat latte', 'qty': 2, 'total': 12.9}, + ]); + expect(eval("length(where(cart, 'qty'))"), 2); + expect(eval("without(syrups, ['honey'])"), ['vanilla']); }); test('member access', () { @@ -186,6 +191,14 @@ void main() { ), true, ); + expect( + FunctionsRepository.substituteNewValue( + r'[{"name": "${item.name}", "unit": "${= item.price * qty}"}]', + VariableType.list, + values, + ), + '[{"name":"Cortado","unit":8.5}]', + ); expect( PropertyValueDelegate.substituteVariablesInJson( r'{"total": "${= item.price * qty}", "label": "${= item.name} x${qty}"}', From 2a382aed36ecc9fdc53f2e1904ffce86212083f4 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 19:58:29 +0000 Subject: [PATCH 3/7] Keep lists and maps when a storage or variable value is a whole formula A setStorage or setVariable value of `${= item}` now stores the item's map (or list) instead of its text. Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YaHo5D7kD3eWcagvzh3o3K --- lib/src/functions/functions_repository.dart | 15 +++++++++++++++ test/formula_test.dart | 8 ++++++++ 2 files changed, 23 insertions(+) diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index c573ed13..b8d19bcc 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -953,6 +953,21 @@ ${response.body.contains('{') ? const JsonEncoder.withIndent(' ').convert(json. ScopedValues scopedValues, ) { if (type.isList || type.isMap) { + // A whole-value formula such as `${= item}` keeps its list or map. + final String? expression = FormulaEvaluator.exactExpression(template); + if (expression != null) { + final Object? value = PropertyValueDelegate.evaluateFormula( + expression, + scopedValues: scopedValues, + ); + if (value is List || value is Map) { + return jsonEncode( + value, + toEncodable: (value) => + value is DateTime ? value.toIso8601String() : value.toString(), + ); + } + } final Object? parsed = tryJsonDecode(template.trim()); if (parsed is List || parsed is Map) { return jsonEncode( diff --git a/test/formula_test.dart b/test/formula_test.dart index 21d9886b..6754aff4 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -199,6 +199,14 @@ void main() { ), '[{"name":"Cortado","unit":8.5}]', ); + expect( + FunctionsRepository.substituteNewValue( + r'${= item}', + VariableType.map, + values, + ), + '{"price":4.25,"name":"Cortado"}', + ); expect( PropertyValueDelegate.substituteVariablesInJson( r'{"total": "${= item.price * qty}", "label": "${= item.name} x${qty}"}', From 753d8e6f7cbe4cfa474cfb18924dddc4acfcef71 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:10:53 +0000 Subject: [PATCH 4/7] Run show toast actions as an overlay message Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YaHo5D7kD3eWcagvzh3o3K --- CHANGELOG.md | 3 + lib/src/functions/app_toast.dart | 156 ++++++++++++++++++ lib/src/functions/functions_repository.dart | 3 + .../utils/condition_evaluator.dart | 6 + .../utils/condition_visitors.dart | 6 + lib/src/utils/extensions.dart | 3 + test/toast_test.dart | 68 ++++++++ 7 files changed, 245 insertions(+) create mode 100644 lib/src/functions/app_toast.dart create mode 100644 test/toast_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index fb2eda04..67ec7766 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,8 @@ ## Unreleased +- Show toast action: shows a message near the bottom of the app over every + route, with variables and formulas in the text. A new toast replaces the + one on screen. - Formulas: `${= expression}` in any text, bound property, condition or action value computes a result, e.g. `${= money(qty * price)}`, `${= sum(storage.cart, 'total')}` or `${= count > 1 ? 'drinks' : 'drink'}`. diff --git a/lib/src/functions/app_toast.dart b/lib/src/functions/app_toast.dart new file mode 100644 index 00000000..021e2bde --- /dev/null +++ b/lib/src/functions/app_toast.dart @@ -0,0 +1,156 @@ +import 'dart:async'; + +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:flutter/material.dart'; + +import '../../codelessly_sdk.dart'; + +/// Shows [ShowToastAction] messages: a pill near the bottom of the screen +/// that fades in, stays for the action's duration and fades out. A new toast +/// replaces the one on screen. +class AppToast { + AppToast._(); + + static OverlayEntry? _current; + + /// Shows [action]'s message. Returns false when there is no overlay to + /// show it in. + static bool show(BuildContext context, ShowToastAction action) { + final OverlayState? overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return false; + + // Read variables now: the action that follows may navigate away. + final String message = PropertyValueDelegate.substituteVariables( + action.message, + scopedValues: ScopedValues.of(context), + nullSubstitutionMode: NullSubstitutionMode.emptyString, + ); + + hide(); + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => _ToastView( + action: action, + message: message, + onDone: () { + if (_current == entry) _current = null; + if (entry.mounted) entry.remove(); + }, + ), + ); + _current = entry; + overlay.insert(entry); + return true; + } + + /// Removes the toast on screen, if any. + static void hide() { + final OverlayEntry? entry = _current; + _current = null; + if (entry != null && entry.mounted) entry.remove(); + } +} + +class _ToastView extends StatefulWidget { + const _ToastView({ + required this.action, + required this.message, + required this.onDone, + }); + + final ShowToastAction action; + final String message; + final VoidCallback onDone; + + @override + State<_ToastView> createState() => _ToastViewState(); +} + +class _ToastViewState extends State<_ToastView> { + static const Duration _fade = Duration(milliseconds: 220); + + bool _visible = false; + Timer? _timer; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (mounted) setState(() => _visible = true); + }); + _timer = Timer(Duration(milliseconds: widget.action.duration), () { + if (!mounted) return; + setState(() => _visible = false); + _timer = Timer(_fade, widget.onDone); + }); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final ShowToastAction action = widget.action; + final Color textColor = action.textColor.toFlutterColor(); + final Widget? icon = action.icon == null + ? null + : retrieveIconWidget(action.icon!, 18); + return Positioned( + left: 16, + right: 16, + bottom: action.bottomOffset, + child: IgnorePointer( + child: Semantics( + liveRegion: true, + label: widget.message, + child: AnimatedOpacity( + opacity: _visible ? 1 : 0, + duration: _fade, + child: Center( + child: Material( + type: MaterialType.transparency, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 18, + vertical: 12, + ), + decoration: BoxDecoration( + color: action.backgroundColor.toFlutterColor(), + borderRadius: BorderRadius.circular(999), + boxShadow: const [ + BoxShadow( + color: Color(0x33000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ], + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (icon != null) ...[icon, const SizedBox(width: 8)], + Flexible( + child: Text( + widget.message, + style: DefaultTextStyle.of(context).style.copyWith( + color: textColor, + fontSize: 14, + fontWeight: FontWeight.w600, + decoration: TextDecoration.none, + ), + ), + ), + ], + ), + ), + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/src/functions/functions_repository.dart b/lib/src/functions/functions_repository.dart index b8d19bcc..8ca0a6d2 100644 --- a/lib/src/functions/functions_repository.dart +++ b/lib/src/functions/functions_repository.dart @@ -15,6 +15,7 @@ import '../logging/stat_tracker.dart'; import '../ui/codelessly_dialog_widget.dart'; import 'action_chain.dart'; import 'app_auth.dart'; +import 'app_toast.dart'; import 'cloud_files.dart'; /// Enum representing the types of API requests. @@ -134,6 +135,8 @@ class FunctionsRepository { action as CloudFilesAction, runActions: (actions) => runActions(context, actions), ); + case ActionType.showToast: + return AppToast.show(context, action as ShowToastAction); } } diff --git a/lib/src/transformers/utils/condition_evaluator.dart b/lib/src/transformers/utils/condition_evaluator.dart index 3f29a042..ce0cf1d5 100644 --- a/lib/src/transformers/utils/condition_evaluator.dart +++ b/lib/src/transformers/utils/condition_evaluator.dart @@ -268,6 +268,9 @@ class ConditionEvaluator @override R? visitLinkAction(LinkAction action) => null; + @override + R? visitShowToastAction(ShowToastAction action) => null; + @override R? visitNavigationAction(NavigationAction action) => null; @@ -379,6 +382,9 @@ class ConditionPrinter @override void visitLinkAction(LinkAction action) {} + @override + void visitShowToastAction(ShowToastAction action) {} + @override void visitNavigationAction(NavigationAction action) {} diff --git a/lib/src/transformers/utils/condition_visitors.dart b/lib/src/transformers/utils/condition_visitors.dart index a8689ff1..dadcbba3 100644 --- a/lib/src/transformers/utils/condition_visitors.dart +++ b/lib/src/transformers/utils/condition_visitors.dart @@ -83,6 +83,9 @@ class ConditionVariablesVisitor @override Set? visitLinkAction(LinkAction action) => {}; + @override + Set? visitShowToastAction(ShowToastAction action) => {}; + @override Set? visitNavigationAction(NavigationAction action) => {}; @@ -173,6 +176,9 @@ class ConditionNodesVisitor @override String? visitLinkAction(LinkAction action) => null; + @override + String? visitShowToastAction(ShowToastAction action) => null; + @override String? visitNavigationAction(NavigationAction action) => null; diff --git a/lib/src/utils/extensions.dart b/lib/src/utils/extensions.dart index cb4e2d53..08ab9da9 100644 --- a/lib/src/utils/extensions.dart +++ b/lib/src/utils/extensions.dart @@ -1679,6 +1679,9 @@ extension ActionModelExt on ActionModel { case ActionType.link: final LinkAction action = this as LinkAction; strings.addAll([action.url]); + case ActionType.showToast: + final ShowToastAction action = this as ShowToastAction; + strings.addAll([action.message]); case ActionType.submit: final SubmitAction action = this as SubmitAction; strings.addAll([action.apiKey, action.primaryTextField]); diff --git a/test/toast_test.dart b/test/toast_test.dart new file mode 100644 index 00000000..940850e8 --- /dev/null +++ b/test/toast_test.dart @@ -0,0 +1,68 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +void main() { + testWidgets('showToast shows the message with variables, then hides it', ( + tester, + ) async { + late BuildContext context; + final ctx = CodelesslyContext.empty() + ..variables['drink'] = Observable( + VariableData(id: 'drink', name: 'drink', value: 'Cortado'), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: ctx, + child: MaterialApp( + home: Builder( + builder: (c) { + context = c; + return const SizedBox(); + }, + ), + ), + ), + ); + + final reactions = [ + Reaction( + action: ShowToastAction( + message: r'Added ${drink} to your order', + duration: 1000, + ), + trigger: TriggerModel(type: TriggerType.click), + ), + ]; + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: reactions, + ); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Added Cortado to your order'), findsOneWidget); + + // A second toast replaces the first. + FunctionsRepository.triggerAction( + context, + TriggerType.click, + reactions: [ + Reaction( + action: ShowToastAction(message: 'Second', duration: 500), + trigger: TriggerModel(type: TriggerType.click), + ), + ], + ); + await tester.pump(); + expect(find.text('Added Cortado to your order'), findsNothing); + expect(find.text('Second'), findsOneWidget); + + await tester.pump(const Duration(milliseconds: 800)); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + expect(find.text('Second'), findsNothing); + }); +} From 70a230b29778ed3aad34764afa85d41d16d81a59 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:17:07 +0000 Subject: [PATCH 5/7] Name tappable nodes for screen readers Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YaHo5D7kD3eWcagvzh3o3K --- CHANGELOG.md | 4 + .../passive_transformer_manager.dart | 1 + .../widget_node_transformer_manager.dart | 91 +++++++++++++++++-- test/formula_test.dart | 2 +- test/semantics_test.dart | 78 ++++++++++++++++ 5 files changed, 165 insertions(+), 11 deletions(-) create mode 100644 test/semantics_test.dart diff --git a/CHANGELOG.md b/CHANGELOG.md index 67ec7766..9ff5117f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +- Screen readers: a tappable frame or shape reads as a button. One with no + text inside (an icon button) is named after its node, or its icon when the + node keeps a default name like "Frame 3". Nodes no longer offer a long + press that does nothing. - Show toast action: shows a message near the bottom of the app over every route, with variables and formulas in the text. A new toast replaces the one on screen. diff --git a/lib/src/transformers/passive_transformer_manager.dart b/lib/src/transformers/passive_transformer_manager.dart index 7a4fd314..7c0108c1 100644 --- a/lib/src/transformers/passive_transformer_manager.dart +++ b/lib/src/transformers/passive_transformer_manager.dart @@ -89,6 +89,7 @@ class PassiveNodeTransformerManager extends WidgetNodeTransformerManager { } if (settings.withReactions) { widget = wrapWithReaction(context, styled, widget); + widget = wrapWithSemantics(styled, widget); } if (settings.withRotation) { widget = applyWidgetRotation(context, styled, widget); diff --git a/lib/src/transformers/widget_node_transformer_manager.dart b/lib/src/transformers/widget_node_transformer_manager.dart index 93c04537..99ada0f5 100644 --- a/lib/src/transformers/widget_node_transformer_manager.dart +++ b/lib/src/transformers/widget_node_transformer_manager.dart @@ -131,6 +131,73 @@ abstract class WidgetNodeTransformerManager ); } + static final RegExp _defaultNodeName = RegExp( + r'^(frame|rectangle|row|column|stack|group|icon|container|ellipse|image|' + r'auto layout|layout|node)\s*\d*$', + caseSensitive: false, + ); + + /// Tells screen readers that a tappable node is a button. A node with text + /// inside reads as that text; one without (an icon button) reads as its + /// node name, or its icon's name when the node still has a default name. + Widget wrapWithSemantics(BaseNode node, Widget widget) { + if (node is! ReactionMixin) return widget; + if (node case CanvasNode() || SpacerNode()) return widget; + if (node is CustomPropertiesMixin && + node.handlesDefaultReactionsInternally) { + return widget; + } + final bool tappable = (node as ReactionMixin).reactions.any( + (reaction) => + reaction.trigger.type == TriggerType.click && reaction.action.enabled, + ); + if (!tappable) return widget; + + final List descendants = []; + void collect(BaseNode parent) { + for (final String id in parent.childrenOrEmpty) { + final BaseNode? child = _tryGetNode(id); + if (child == null) continue; + descendants.add(child); + collect(child); + } + } + + collect(node); + + final bool hasText = descendants.any((child) => child is TextNode); + final String label = hasText ? '' : semanticLabelFor(node, descendants); + return MergeSemantics( + child: Semantics( + button: true, + label: label.isEmpty ? null : label, + child: widget, + ), + ); + } + + /// The name a screen reader announces for [node] when it has no text. + static String semanticLabelFor(BaseNode node, List descendants) { + final String name = node.name.trim(); + if (name.isNotEmpty && !_defaultNodeName.hasMatch(name)) return name; + for (final BaseNode child in descendants) { + if (child is! IconNode) continue; + final String? iconName = child.properties.icon.icon?.name; + if (iconName != null && iconName.isNotEmpty) { + return iconName.replaceAll('_', ' '); + } + } + return ''; + } + + BaseNode? _tryGetNode(String id) { + try { + return getNode(id); + } catch (_) { + return null; + } + } + /// Convenience method to handle widget reactions. Widget wrapWithReaction(BuildContext context, BaseNode node, Widget widget) { if (node is! ReactionMixin) return widget; @@ -175,11 +242,13 @@ abstract class WidgetNodeTransformerManager TriggerType.click, reactions: onClickReactions, ), - onLongPress: () => FunctionsRepository.triggerAction( - context, - TriggerType.longPress, - reactions: onLongPressReactions, - ), + onLongPress: onLongPressReactions.isEmpty + ? null + : () => FunctionsRepository.triggerAction( + context, + TriggerType.longPress, + reactions: onLongPressReactions, + ), borderRadius: getBorderRadius(node), overlayColor: inkWell.overlayColor != null ? WidgetStatePropertyAll( @@ -208,11 +277,13 @@ abstract class WidgetNodeTransformerManager TriggerType.click, reactions: onClickReactions, ), - onLongPress: () => FunctionsRepository.triggerAction( - context, - TriggerType.longPress, - reactions: onLongPressReactions, - ), + onLongPress: onLongPressReactions.isEmpty + ? null + : () => FunctionsRepository.triggerAction( + context, + TriggerType.longPress, + reactions: onLongPressReactions, + ), child: widget, ), ); diff --git a/test/formula_test.dart b/test/formula_test.dart index 6754aff4..07426f83 100644 --- a/test/formula_test.dart +++ b/test/formula_test.dart @@ -58,7 +58,7 @@ void main() { test('comparisons, logic and ternary', () { expect(eval('qty > 1 && price < 6'), true); expect(eval('qty == 2'), true); - expect(eval("shots == 3"), true); + expect(eval('shots == 3'), true); expect(eval("name != 'Ava'"), false); expect(eval('!qty'), false); expect(eval("qty > 1 ? 'many' : 'one'"), 'many'); diff --git a/test/semantics_test.dart b/test/semantics_test.dart new file mode 100644 index 00000000..e9432117 --- /dev/null +++ b/test/semantics_test.dart @@ -0,0 +1,78 @@ +import 'package:codelessly_api/codelessly_api.dart'; +import 'package:codelessly_sdk/codelessly_sdk.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +RectangleNode tappable(String id, String name) => RectangleNode( + id: id, + name: name, + basicBoxLocal: NodeBox(0, 0, 40, 40), + reactions: [ + Reaction( + action: LinkAction(url: ''), + trigger: TriggerModel(type: TriggerType.click), + ), + ], +)..parentID = kRootNode; + +Future pumpNode(WidgetTester tester, BaseNode node) async { + final registry = NodeRegistry()..setNodes({node.id: node}); + final manager = PassiveNodeTransformerManager( + registry.getNodeByID, + (context, bounds, pageID, layoutID, canvasID) => const SizedBox(), + ); + await tester.pumpWidget( + ChangeNotifierProvider.value( + value: CodelesslyContext.empty(), + child: MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Builder( + builder: (context) => manager.buildWidgetFromNode( + node, + context, + settings: const WidgetBuildSettings(debugLabel: 'test'), + ), + ), + ), + ), + ), + ); +} + +void main() { + testWidgets('a tappable node without text reads as a named button', ( + tester, + ) async { + final SemanticsHandle handle = tester.ensureSemantics(); + await pumpNode(tester, tappable('back', 'Back to menu')); + + expect( + tester.getSemantics(find.bySemanticsLabel('Back to menu')), + matchesSemantics( + label: 'Back to menu', + isButton: true, + hasTapAction: true, + ), + ); + handle.dispose(); + }); + + test('a default node name is not used as a label', () { + expect( + WidgetNodeTransformerManager.semanticLabelFor( + tappable('r', 'Rectangle 2'), + const [], + ), + '', + ); + expect( + WidgetNodeTransformerManager.semanticLabelFor( + tappable('r', 'Add to bag'), + const [], + ), + 'Add to bag', + ); + }); +} From c6421bb50c49599768b88806f379414fcdafee5b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:38:43 +0000 Subject: [PATCH 6/7] Build against codelessly_api from its show toast branch for now Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YaHo5D7kD3eWcagvzh3o3K --- example/pubspec.yaml | 8 ++++++++ pubspec.yaml | 8 ++++++++ 2 files changed, 16 insertions(+) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e0640bdc..e020d640 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -23,3 +23,11 @@ dev_dependencies: flutter: uses-material-design: true + +# Temporary: the show toast action needs codelessly_api from +# Codelessly/CodelesslyAPI#37. Remove once that is published. +dependency_overrides: + codelessly_api: + git: + url: https://github.com/Codelessly/CodelesslyAPI.git + ref: claude/project-thread-1mdfl3 diff --git a/pubspec.yaml b/pubspec.yaml index 219f375a..6ff4094e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -92,3 +92,11 @@ false_secrets: - /lib/src/firebase_options_prod.dart scripts: scripts.yaml + +# Temporary: the show toast action needs codelessly_api from +# Codelessly/CodelesslyAPI#37. Remove once that is published. +dependency_overrides: + codelessly_api: + git: + url: https://github.com/Codelessly/CodelesslyAPI.git + ref: claude/project-thread-1mdfl3 From f5b26c76a8a0781ed905592632e603d59aed948f Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 19 Oct 2026 20:59:48 +0000 Subject: [PATCH 7/7] Build against codelessly_api master until it is released Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_01YaHo5D7kD3eWcagvzh3o3K --- example/pubspec.yaml | 5 +++-- pubspec.yaml | 5 +++-- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/example/pubspec.yaml b/example/pubspec.yaml index e020d640..dd3aa05b 100644 --- a/example/pubspec.yaml +++ b/example/pubspec.yaml @@ -25,9 +25,10 @@ flutter: uses-material-design: true # Temporary: the show toast action needs codelessly_api from -# Codelessly/CodelesslyAPI#37. Remove once that is published. +# Codelessly/CodelesslyAPI#37, merged but not yet published. Remove once +# codelessly_api is released. dependency_overrides: codelessly_api: git: url: https://github.com/Codelessly/CodelesslyAPI.git - ref: claude/project-thread-1mdfl3 + ref: master diff --git a/pubspec.yaml b/pubspec.yaml index 6ff4094e..8429ee72 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -94,9 +94,10 @@ false_secrets: scripts: scripts.yaml # Temporary: the show toast action needs codelessly_api from -# Codelessly/CodelesslyAPI#37. Remove once that is published. +# Codelessly/CodelesslyAPI#37, merged but not yet published. Remove once +# codelessly_api is released. dependency_overrides: codelessly_api: git: url: https://github.com/Codelessly/CodelesslyAPI.git - ref: claude/project-thread-1mdfl3 + ref: master