view-contracts 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@
2
2
 
3
3
  Contract-first UI design toolchain. Compiles restricted view-contract TSX to language-neutral View IR and renders platform targets (React, SwiftUI, Compose).
4
4
 
5
- **Version:** 0.5.2
5
+ **Version:** 0.5.3
6
6
 
7
7
  ## Table of Contents
8
8
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "view-contracts",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
4
4
  "description": "Contract-first UI design toolchain — restricted TSX to View IR to platform renderers",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,7 @@ import {
24
24
  extractClipRadiusDp,
25
25
  extractPaddingModifiers,
26
26
  extractTextMaxLines,
27
+ composeRowArrangement,
27
28
  extractTextOverflow,
28
29
  extractTextStyleModifiers,
29
30
  hasFillMaxHeight,
@@ -429,11 +430,51 @@ function renderStyledBox(
429
430
  ].join('\n');
430
431
  }
431
432
 
433
+ function isInlineDisplayVariant(
434
+ elementName: string,
435
+ variantLit: string,
436
+ ctx: ComposeRenderContext,
437
+ ): boolean {
438
+ const merged = mergeVisualProps({
439
+ elementName,
440
+ theme: ctx.theme,
441
+ tokens: ctx.tokens,
442
+ variantLiteral: variantLit,
443
+ instanceLiterals: {},
444
+ });
445
+ const display = merged.display;
446
+ return typeof display === 'string' && display.includes('inline');
447
+ }
448
+
449
+ function isShrinkWrapStyledText(
450
+ props: Record<string, IrValue>,
451
+ ctx: ComposeRenderContext,
452
+ itemScope?: string,
453
+ ): boolean {
454
+ if (ctx.cellTextBehavior) return true;
455
+ const variantLit = propLiteral(props, 'variant');
456
+ if (typeof variantLit === 'string') {
457
+ return isInlineDisplayVariant('Text', variantLit, ctx);
458
+ }
459
+ if (isDynamicVariant(props)) {
460
+ const v = props.variant;
461
+ if (typeof v === 'object' && v !== null && 'kind' in v && v.kind === 'concat') {
462
+ const concat = v as IrConcat;
463
+ const first = concat.parts[0];
464
+ if (first === 'badge-') return true;
465
+ if (typeof first === 'string' && first.startsWith('badge-')) return true;
466
+ }
467
+ }
468
+ return false;
469
+ }
470
+
432
471
  function formatTextComposable(
433
472
  pad: string,
434
473
  content: string,
435
474
  modifiers: string[],
436
475
  ctx: ComposeRenderContext,
476
+ textProps?: Record<string, IrValue>,
477
+ itemScope?: string,
437
478
  ): string {
438
479
  const paddingMods = extractPaddingModifiers(modifiers);
439
480
  const runtimeStyleExpr = extractApplyVcStyleExpr(applyModifiers('Modifier', modifiers));
@@ -464,7 +505,8 @@ function formatTextComposable(
464
505
  const textLine = `${pad} Text(text = ${content}${maxLinesParam}${overflowParam}${styleParam})`;
465
506
 
466
507
  if (runtimeStyleExpr) {
467
- const styleBase = ctx.cellTextBehavior ? 'Modifier' : 'Modifier.fillMaxWidth()';
508
+ const shrinkWrap = textProps ? isShrinkWrapStyledText(textProps, ctx, itemScope) : ctx.cellTextBehavior === true;
509
+ const styleBase = shrinkWrap ? 'Modifier' : 'Modifier.fillMaxWidth()';
468
510
  const styleModifier = `${styleBase}.applyVcStyle(${runtimeStyleExpr})`;
469
511
  if (paddingMods.length > 0) {
470
512
  const outerModifier = applyModifiers(modifierBase, [...layoutMods, ...paddingMods]);
@@ -670,7 +712,7 @@ function renderLeaf(
670
712
 
671
713
  if (entry.leaf === 'text') {
672
714
  const content = renderTextContent(node.children, ctx, itemScope) ?? '""';
673
- return formatTextComposable(pad, content, modifiers, ctx);
715
+ return formatTextComposable(pad, content, modifiers, ctx, node.props, itemScope);
674
716
  }
675
717
  if (entry.leaf === 'button') {
676
718
  return renderVcButton(node, level, itemScope, ctx, modifiers);
@@ -763,7 +805,9 @@ function renderLeaf(
763
805
  return `${pad}Spacer(modifier = ${modifierExpr})`;
764
806
  }
765
807
  if (entry.leaf === 'divider') {
766
- return `${pad}HorizontalDivider(modifier = ${modifierExpr})`;
808
+ const axisLit = propLiteral(node.props, 'axis');
809
+ const divider = axisLit === 'vertical' ? 'VerticalDivider' : 'HorizontalDivider';
810
+ return `${pad}${divider}(modifier = ${modifierExpr})`;
767
811
  }
768
812
  if (entry.leaf === 'image') {
769
813
  const src = propLiteral(node.props, 'src') ?? propLiteral(node.props, 'name');
@@ -814,13 +858,7 @@ function renderContainer(
814
858
  const spacing = typeof spacingLit === 'number' ? `${spacingLit}.dp` : '0.dp';
815
859
  const justifyLit = propLiteral(node.props, 'justify');
816
860
  const wrapLit = propLiteral(node.props, 'wrap');
817
- const arrangement = justifyLit === 'between'
818
- ? 'Arrangement.SpaceBetween'
819
- : justifyLit === 'end'
820
- ? 'Arrangement.End'
821
- : justifyLit === 'center'
822
- ? 'Arrangement.Center'
823
- : `Arrangement.spacedBy(${spacing})`;
861
+ const arrangement = composeRowArrangement(justifyLit, spacing);
824
862
  const alignLit = propLiteral(node.props, 'align');
825
863
  const alignItemsLit = propLiteral(node.props, 'alignItems');
826
864
  const heightLit = propLiteral(node.props, 'height');
@@ -848,7 +886,7 @@ function renderContainer(
848
886
  const childContent = wrapWithInheritedStyles(renderChildren(node.children, level + 1, renderNested, ctx, itemScope), modifiers, level + 1);
849
887
  if (node.name === 'TableHeader') ctx.inTableHeader = prevTableHeader;
850
888
 
851
- if (wrapLit === true && justifyLit !== 'between') {
889
+ if (wrapLit === true && justifyLit !== 'between' && justifyLit !== 'spaceBetween') {
852
890
  return [
853
891
  `${indent(level)}FlowRow(modifier = ${rowModifier}, horizontalArrangement = Arrangement.spacedBy(${spacing}), verticalArrangement = Arrangement.spacedBy(${spacing})) {`,
854
892
  childContent,
@@ -987,6 +1025,8 @@ function renderContainer(
987
1025
  JSON.stringify((child as IrTextNode).value),
988
1026
  dedupeModifiers([...cellTextBehaviorMods(), `@textAlign(${textAlign})`, ...paddingMods, ...inheritedMods.map((m) => m)]),
989
1027
  ctx,
1028
+ undefined,
1029
+ itemScope,
990
1030
  );
991
1031
  }
992
1032
  if (child.kind === 'binding') {
@@ -995,6 +1035,8 @@ function renderContainer(
995
1035
  bindingExpr(child as IrBinding, ctx, itemScope),
996
1036
  dedupeModifiers([...cellTextBehaviorMods(), `@textAlign(${textAlign})`, ...paddingMods, ...inheritedMods.map((m) => m)]),
997
1037
  ctx,
1038
+ undefined,
1039
+ itemScope,
998
1040
  );
999
1041
  }
1000
1042
  return renderNested(child, level + 1, itemScope);
@@ -85,6 +85,16 @@ function composeSelfAlign(value: string | number | boolean): string | null {
85
85
  return map[value] ?? null;
86
86
  }
87
87
 
88
+ /** Map DSL justify enum to Compose Row horizontalArrangement (accepts schema + legacy aliases). */
89
+ export function composeRowArrangement(justifyLit: unknown, spacing: string): string {
90
+ if (justifyLit === 'between' || justifyLit === 'spaceBetween') return 'Arrangement.SpaceBetween';
91
+ if (justifyLit === 'around' || justifyLit === 'spaceAround') return 'Arrangement.SpaceAround';
92
+ if (justifyLit === 'spaceEvenly') return 'Arrangement.SpaceEvenly';
93
+ if (justifyLit === 'end') return 'Arrangement.End';
94
+ if (justifyLit === 'center') return 'Arrangement.Center';
95
+ return `Arrangement.spacedBy(${spacing})`;
96
+ }
97
+
88
98
  export function composeContentAlignment(value: string): string {
89
99
  const map: Record<string, string> = {
90
100
  topStart: 'Alignment.TopStart',
@@ -89,7 +89,10 @@ function resolveVariantEntries(
89
89
  case 'minHeight':
90
90
  case 'maxWidth': {
91
91
  const px = parsePx(value);
92
- if (px !== null) entries.push({ property, kotlinValue: `${px}.dp` });
92
+ if (px !== null) {
93
+ const kotlinValue = property === 'fontSize' ? `${px}.sp` : `${px}.dp`;
94
+ entries.push({ property, kotlinValue });
95
+ }
93
96
  break;
94
97
  }
95
98
  case 'width': {
@@ -127,6 +130,12 @@ function resolveVariantEntries(
127
130
  entries.push({ property, kotlinValue: value === 'none' ? 'false' : 'true' });
128
131
  break;
129
132
  }
133
+ case 'display': {
134
+ if (value.includes('inline')) {
135
+ entries.push({ property: 'shrinkWrap', kotlinValue: 'true' });
136
+ }
137
+ break;
138
+ }
130
139
  default:
131
140
  break;
132
141
  }
@@ -135,13 +144,21 @@ function resolveVariantEntries(
135
144
  }
136
145
 
137
146
  const STRUCT_FIELD_ORDER = [
138
- 'foreground', 'background', 'padding', 'paddingH', 'paddingV', 'widthFill', 'heightFill',
147
+ 'foreground', 'background', 'padding', 'paddingH', 'paddingV', 'widthFill', 'heightFill', 'shrinkWrap',
139
148
  'radius', 'fontSize', 'fontWeight', 'shadow', 'borderWidth', 'borderColor', 'minHeight', 'maxWidth',
140
149
  ];
141
150
 
151
+ function finalizeVariantEntries(entries: ResolvedEntry[]): ResolvedEntry[] {
152
+ if (entries.some((entry) => entry.property === 'shrinkWrap')) {
153
+ return entries.filter((entry) => entry.property !== 'widthFill');
154
+ }
155
+ return entries;
156
+ }
157
+
142
158
  function emitVariantStyleInit(entries: ResolvedEntry[]): string {
143
- if (entries.length === 0) return 'VcVariantStyle()';
144
- const sorted = [...entries].sort(
159
+ const finalized = finalizeVariantEntries(entries);
160
+ if (finalized.length === 0) return 'VcVariantStyle()';
161
+ const sorted = [...finalized].sort(
145
162
  (a, b) => STRUCT_FIELD_ORDER.indexOf(a.property) - STRUCT_FIELD_ORDER.indexOf(b.property),
146
163
  );
147
164
  return `VcVariantStyle(${sorted.map((e) => `${e.property} = ${e.kotlinValue}`).join(', ')})`;
@@ -183,6 +200,7 @@ export function renderComposeTheme(theme: ThemeIR, tokens: TokenIR): string {
183
200
  lines.push('import androidx.compose.ui.text.TextStyle');
184
201
  lines.push('import androidx.compose.ui.text.font.FontWeight');
185
202
  lines.push('import androidx.compose.ui.unit.Dp');
203
+ lines.push('import androidx.compose.ui.unit.TextUnit');
186
204
  lines.push('import androidx.compose.ui.unit.dp');
187
205
  lines.push('import androidx.compose.ui.unit.sp');
188
206
  lines.push('');
@@ -194,8 +212,9 @@ export function renderComposeTheme(theme: ThemeIR, tokens: TokenIR): string {
194
212
  lines.push(' val paddingV: Dp? = null,');
195
213
  lines.push(' val widthFill: Boolean? = null,');
196
214
  lines.push(' val heightFill: Boolean? = null,');
215
+ lines.push(' val shrinkWrap: Boolean? = null,');
197
216
  lines.push(' val radius: Dp? = null,');
198
- lines.push(' val fontSize: Dp? = null,');
217
+ lines.push(' val fontSize: TextUnit? = null,');
199
218
  lines.push(' val fontWeight: FontWeight? = null,');
200
219
  lines.push(' val shadow: Boolean? = null,');
201
220
  lines.push(' val borderWidth: Dp? = null,');
@@ -207,7 +226,7 @@ export function renderComposeTheme(theme: ThemeIR, tokens: TokenIR): string {
207
226
  lines.push('fun VcVariantStyle.asTextStyle(): TextStyle {');
208
227
  lines.push(' var style = TextStyle()');
209
228
  lines.push(' foreground?.let { style = style.copy(color = it) }');
210
- lines.push(' fontSize?.let { style = style.copy(fontSize = it.value.sp) }');
229
+ lines.push(' fontSize?.let { style = style.copy(fontSize = it) }');
211
230
  lines.push(' fontWeight?.let { style = style.copy(fontWeight = it) }');
212
231
  lines.push(' return style');
213
232
  lines.push('}');
@@ -223,7 +242,7 @@ export function renderComposeTheme(theme: ThemeIR, tokens: TokenIR): string {
223
242
  lines.push('');
224
243
  lines.push('fun Modifier.applyVcStyle(style: VcVariantStyle): Modifier {');
225
244
  lines.push(' var out = this');
226
- lines.push(' if (style.widthFill == true) out = out.fillMaxWidth()');
245
+ lines.push(' if (style.widthFill == true && style.shrinkWrap != true) out = out.fillMaxWidth()');
227
246
  lines.push(' if (style.heightFill == true) out = out.fillMaxHeight()');
228
247
  lines.push(' if (style.shadow == true) out = out.then(vcSoftShadowModifier(style.radius ?: 0.dp))');
229
248
  lines.push(' style.background?.let { out = out.background(it, RoundedCornerShape(style.radius ?: 0.dp)) }');
@@ -109,19 +109,19 @@ function actionHandler(props: Record<string, IrValue>, event: 'onClick' | 'onSub
109
109
  return `onClick={() => { void dispatchAction(${expr}); }}`;
110
110
  }
111
111
 
112
- function onChangeHandler(props: Record<string, IrValue>): string | undefined {
112
+ function onChangeHandler(props: Record<string, IrValue>, targetProp = 'e.target.value'): string | undefined {
113
113
  const onChange = props.onChange;
114
114
  if (!onChange || typeof onChange !== 'object' || !('kind' in onChange) || onChange.kind !== 'action') {
115
115
  return undefined;
116
116
  }
117
117
  const action = onChange as IrAction;
118
118
  if (!action.params || Object.keys(action.params).length === 0) {
119
- return `onChange={(e) => { void dispatchAction({ name: ${JSON.stringify(action.name)}, params: { value: e.target.value } } as ActionDescriptor); }}`;
119
+ return `onChange={(e) => { void dispatchAction({ name: ${JSON.stringify(action.name)}, params: { value: ${targetProp} } } as ActionDescriptor); }}`;
120
120
  }
121
121
  const existingParams = Object.entries(action.params)
122
122
  .map(([key, val]) => `${key}: ${renderValue(val, 0)}`)
123
123
  .join(', ');
124
- return `onChange={(e) => { void dispatchAction({ name: ${JSON.stringify(action.name)}, params: { ${existingParams}, value: e.target.value } } as ActionDescriptor); }}`;
124
+ return `onChange={(e) => { void dispatchAction({ name: ${JSON.stringify(action.name)}, params: { ${existingParams}, value: ${targetProp} } } as ActionDescriptor); }}`;
125
125
  }
126
126
 
127
127
  function componentToKebab(name: string): string {
@@ -197,10 +197,13 @@ function emitFieldWrap(inner: string, props: Record<string, IrValue>, level: num
197
197
  function emitCheckbox(props: Record<string, IrValue>, level: number): string {
198
198
  const name = propExpr(props, 'name') ?? '""';
199
199
  const label = propExpr(props, 'label') ?? '""';
200
- const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
201
200
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
201
+ const changeAttr = onChangeHandler(props, 'e.target.checked') ?? '';
202
+ const checkedAttr = props.checked
203
+ ? (changeAttr ? `checked={${propExpr(props, 'checked')}}` : `defaultChecked={${propExpr(props, 'checked')}}`)
204
+ : '';
202
205
  return `${indent(level)}<label className="vc-checkbox">
203
- ${indent(level + 1)}<input type="checkbox" name={${name}} ${checked} ${disabled} />
206
+ ${indent(level + 1)}<input type="checkbox" name={${name}} ${checkedAttr} ${disabled} ${changeAttr} />
204
207
  ${indent(level + 1)}<span>{${label}}</span>
205
208
  ${indent(level)}</label>`;
206
209
  }
@@ -209,10 +212,13 @@ function emitRadio(props: Record<string, IrValue>, level: number): string {
209
212
  const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
210
213
  const value = propExpr(props, 'value') ?? '""';
211
214
  const label = propExpr(props, 'label') ?? '""';
212
- const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
213
215
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
216
+ const changeAttr = onChangeHandler(props, 'e.target.value') ?? '';
217
+ const checkedAttr = props.checked
218
+ ? (changeAttr ? `checked={${propExpr(props, 'checked')}}` : `defaultChecked={${propExpr(props, 'checked')}}`)
219
+ : '';
214
220
  return `${indent(level)}<label className="vc-radio">
215
- ${indent(level + 1)}<input type="radio" name={${name}} value={${value}} ${checked} ${disabled} />
221
+ ${indent(level + 1)}<input type="radio" name={${name}} value={${value}} ${checkedAttr} ${disabled} ${changeAttr} />
216
222
  ${indent(level + 1)}<span>{${label}}</span>
217
223
  ${indent(level)}</label>`;
218
224
  }
@@ -220,10 +226,13 @@ ${indent(level)}</label>`;
220
226
  function emitSwitch(props: Record<string, IrValue>, level: number): string {
221
227
  const name = propExpr(props, 'binding') ?? propExpr(props, 'name') ?? '""';
222
228
  const label = propExpr(props, 'label') ?? '""';
223
- const checked = props.checked ? `defaultChecked={${propExpr(props, 'checked')}}` : '';
224
229
  const disabled = props.enabled ? `disabled={${propExpr(props, 'enabled')} === false}` : '';
230
+ const changeAttr = onChangeHandler(props, 'e.target.checked') ?? '';
231
+ const checkedAttr = props.checked
232
+ ? (changeAttr ? `checked={${propExpr(props, 'checked')}}` : `defaultChecked={${propExpr(props, 'checked')}}`)
233
+ : '';
225
234
  return `${indent(level)}<label className="vc-switch">
226
- ${indent(level + 1)}<input type="checkbox" role="switch" name={${name}} ${checked} ${disabled} />
235
+ ${indent(level + 1)}<input type="checkbox" role="switch" name={${name}} ${checkedAttr} ${disabled} ${changeAttr} />
227
236
  ${indent(level + 1)}<span>{${label}}</span>
228
237
  ${indent(level)}</label>`;
229
238
  }
@@ -282,7 +291,7 @@ function emitVoidInput(
282
291
  }
283
292
  if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
284
293
  if (props.placeholder) attrs.push(`placeholder={${propExpr(props, 'placeholder')}}`);
285
- if (entry.inputType) attrs.push(`type={${propExpr(props, 'type') ?? JSON.stringify(entry.inputType)}}`);
294
+ if (entry.inputType) attrs.push(`type={${propExpr(props, 'inputType') ?? JSON.stringify(entry.inputType)}}`);
286
295
  if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
287
296
  if (props.readonly) attrs.push(`readOnly={${propExpr(props, 'readonly')}}`);
288
297
  if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
@@ -320,7 +329,10 @@ function emitOpenTag(
320
329
  const handler = actionHandler(props, entry.actionEvent);
321
330
  if (handler) attrs.push(handler);
322
331
  }
332
+ if (props.name) attrs.push(`name={${propExpr(props, 'name')}}`);
323
333
  if (entry.tag === 'select' || entry.tag === 'textarea' || entry.tag === 'input') {
334
+ if (props.required) attrs.push(`required={${propExpr(props, 'required')}}`);
335
+ if (props.enabled) attrs.push(`disabled={${propExpr(props, 'enabled')} === false}`);
324
336
  const changeHandler = onChangeHandler(props);
325
337
  if (changeHandler) {
326
338
  if (props.value) attrs.push(`value={${propExpr(props, 'value')}}`);