lecodes-sdk 0.19.2 → 0.20.0

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.
@@ -62,9 +62,7 @@ The platform runs the conversation in one of three modes the user switches betwe
62
62
 
63
63
  <mode>build</mode> (or <mode>concept</mode>)
64
64
 
65
- Separately — the handoff. When your reply completes what the user asked for (the screens exist, compile, and are registered on the map), you have no question pending, and the app itself hasn't been built yet, end with `<mode>build</mode>`: the platform shows it as a "Build the app" button that starts the build from this design. Offer it at that moment — not after every routine edit to an already-offered design.
66
-
67
- The platform renders the directive as a button — the user decides; nothing switches by itself. At most one <mode> directive per reply, and never for work that belongs right here.
65
+ The platform renders it as a "Switch to …" button — the user decides; nothing switches by itself. At most one <mode> directive per reply, and never for normal design work.
68
66
 
69
67
  ## Important
70
68
 
@@ -84,24 +82,21 @@ The platform renders the directive as a button — the user decides; nothing swi
84
82
 
85
83
  // ===== UI COMPONENTS =====
86
84
  // Every element is created by a global factory function (never `new`) that takes only the element's
87
- // CONTENT — children as plain arguments (or text/src/...). Everything else — styles — is configured
88
- // by chaining: every configuring method returns the element itself, so construction reads as one chain.
89
- UIColumn(UIText("Title"), UIButton(UIText("Go")))
90
- // An ARRAY argument is flattened into the children — pass items.map(Row) directly, no spread:
91
- UIColumn(header, items.map(Row), footer)
85
+ // CONTENT (children array, text, src, ...). Everything else — styles — is configured by chaining:
86
+ // every configuring method returns the element itself, so construction reads as one chain.
92
87
 
93
88
  // UIRow, UIColumn — containers (UIColumn stacks vertically, UIRow horizontally)
94
- UIRow(...children) / UIColumn(...children)
89
+ UIRow(children) / UIColumn(children)
95
90
 
96
91
  // UIScreen — root screen, always fills the device. Behaves as a UIColumn.
97
- UIScreen(...children)
92
+ UIScreen(children)
98
93
  // Screens NEVER scroll — one vertical flow (article, long form, feed) = fixed chrome + ONE UIScrollable body
99
- // with flexGrow: 1: UIScreen(Header(), UIScrollable(content).style({ flexGrow: 1 }))
94
+ // with flexGrow: 1: UIScreen([ Header(), UIScrollable([...content]).style({ flexGrow: 1 }) ])
100
95
  // Note: a screen always fills the device — sizing styles on it (width, height, flexGrow, position) are no-ops
101
96
  // A design screen is returned from the file's default export — never call .open()
102
97
 
103
98
  // UIWidget — floating overlay above the screen, position: fixed in device coordinates
104
- UIWidget(...children)
99
+ UIWidget(children)
105
100
  // .show(), .hide(), .onOverlayTap(cb)
106
101
  // extra style: overlayColor — full-screen scrim BEHIND the widget that blocks taps underneath, turning it
107
102
  // into a modal (dialog / bottom sheet). Size the widget box to the CONTENT only (bottom-anchor a sheet
@@ -113,7 +108,7 @@ UIWidget(...children)
113
108
 
114
109
  // UIScrollable — THE scroll container: a screen's scrolling body, a list under a pinned header, a
115
110
  // horizontal chip row / carousel
116
- UIScrollable(...children)
111
+ UIScrollable(children)
117
112
  // extra styles: scrollDirection ("horizontal" | "vertical", default vertical), showScrollbar: boolean
118
113
  // Note: defaults flexShrink: 1 (scrolls instead of overflowing) — wrapping ancestors still need flexShrink: 1
119
114
 
@@ -132,7 +127,7 @@ UIImage(src) // src: string url | SvgSource | imported asset
132
127
 
133
128
  // UIButton — the only TAPPABLE container: a UIRow with children centered on both axes by default.
134
129
  // Anything clickable in the prototype (a card, a list row, an icon) — wrap it in a UIButton.
135
- UIButton(...children)
130
+ UIButton(children?)
136
131
  // extra styles: onPressed: { bgColor, opacity, ... } — style while the finger is down (press feedback is
137
132
  // OPT-IN; set it so the live prototype feels real)
138
133
  // Note: buttons render no chrome of their own — style bgColor/borderRadius/padding yourself, and give a
@@ -145,7 +140,7 @@ UIButton(...children)
145
140
 
146
141
  // UISpacer — flexible empty space (defaults flexGrow: 1), eats free space along the main axis.
147
142
  // Only when plain alignment can't express it (one item pushed to the far end while the rest stay put):
148
- UIRow(title, UISpacer(), closeButton)
143
+ UIRow([ title, UISpacer(), closeButton ])
149
144
  // If ALL children move together, justifyContent ("space-between", "flex-end", ...) does it with no extra element.
150
145
 
151
146
  // ===== STYLING =====
@@ -202,7 +197,7 @@ const label: Style<UIText> = { fontSize: 12, fontWeight: 600, letterSpacing: 2 }
202
197
  const T = theme({ primaryColor: "#0A84FF" })
203
198
  label.style({ color: T.primaryColor }) // or "var(--primaryColor)"
204
199
  // theme({ color, fontFamily }) drive the DEFAULT text color/font app-wide. "var(--name, fallback)"
205
- // applies the fallback while the key is unset — how the SDK tab bar (defineTabs) stays themeable.
200
+ // applies the fallback while the key is unset — how shared/tabs.ts stays themeable.
206
201
 
207
202
  // --- Defaults that surprise ---
208
203
  // flexShrink: 0 — elements don't shrink to fit (exception: UIScrollable defaults flexShrink: 1 so it scrolls
@@ -223,14 +218,14 @@ label.style({ color: T.primaryColor }) // or "var(--primaryColor)"
223
218
  // ===== REUSABLE COMPONENTS =====
224
219
  // Extract repeated UI into factory functions — they return elements you can chain on:
225
220
  const StatusPill = (label: string, color: string, filled = false) =>
226
- UIColumn(UIText(label).style({ fontSize: 12, fontWeight: 600, color: filled ? "#05201f" : color }))
221
+ UIColumn([ UIText(label).style({ fontSize: 12, fontWeight: 600, color: filled ? "#05201f" : color }) ])
227
222
  .style({ height: 26, px: 10, borderRadius: 999, justifyContent: "center", alignItems: "center",
228
223
  bgColor: filled ? color : "transparent", border: filled ? undefined : `1px solid ${color}`,
229
224
  alignSelf: "flex-start" })
230
225
 
231
226
  // ===== CONDITIONAL CHILDREN =====
232
- // A null / undefined / false child is skipped — no element, no layout slot.
233
- UIColumn(header, item.unread ? dot : null, items.map(Card))
227
+ // null / undefined / false in a children array is skipped — no element, no layout slot.
228
+ UIColumn([ header, item.unread ? dot : null, ...items.map(Card) ])
234
229
 
235
230
  // ===== SIZING: the two axes behave differently =====
236
231
  // MAIN axis (row → width, column → height): elements stay as small as their content — nothing grows
@@ -258,14 +253,14 @@ lineHeight: 1.5 // WRONG — number is px (=1.5px); for a m
258
253
  // A wrapping container between the scrollable and the screen is missing flexShrink: 1
259
254
 
260
255
  // ❌ empty containers as spacers to align children (web habit)
261
- UIRow(UIColumn().style({ flexGrow: 1 }), label) // WRONG
256
+ UIRow([ UIColumn([]).style({ flexGrow: 1 }), label ]) // WRONG
262
257
  // ✅ alignment is a CONTAINER property, not an extra element
263
- UIRow(label).style({ justifyContent: "flex-end" })
258
+ UIRow([ label ]).style({ justifyContent: "flex-end" })
264
259
 
265
260
  // ❌ empty element as a placeholder for a conditional child
266
- UIRow(isSelected ? check : UIColumn()) // WRONG
261
+ UIRow([ isSelected ? check : UIColumn([]) ]) // WRONG
267
262
  // ✅ null is skipped in children — no phantom element
268
- UIRow(isSelected ? check : null)
263
+ UIRow([ isSelected ? check : null ])
269
264
 
270
265
  // ❌ pointing UIImage / bgImage at a project file by bare path — it won't resolve to the bundled asset
271
266
  UIImage("./photo.jpg") // WRONG (a plain string works only for remote http(s) URLs)
@@ -298,27 +293,16 @@ design/
298
293
  A design that looks designed starts from a kit, not from screens. On a new design, before the first
299
294
  screen: `shared/tokens.ts` (a real palette — surfaces, text tiers, one or two accents, status
300
295
  colors; a spacing/radius/type scale), then `shared/ui.ts` with the components the app will clearly
301
- need. The kit outlives the design: when the app is built, it IMPORTS `shared/tokens.ts` (and the
302
- presentational components of `shared/ui.ts`) unchanged — so build tokens on `theme()`, one live
303
- table for design and app alike, and shape components as things worth keeping. Screens then read as
304
- composition:
296
+ need. Screens then read as composition:
305
297
 
306
298
  ```ts
307
- // design/shared/tokens.ts — surfaces dark-to-light, text in tiers, accents as a family.
308
- // ONE theme() call registers the vars and returns the accessors ("var(--x)" strings); the system
309
- // key `color` in the same table sets the app-wide default text color. Re-calling theme() with new
310
- // values (dark mode, brand swap) restyles every screen — and later the built app — live.
311
- const palette = {
299
+ // design/shared/tokens.ts — surfaces dark-to-light, text in tiers, accents as a family
300
+ export const colors = {
312
301
  bg: "#080808", card: "#161618", cardAlt: "#1d1d20", field: "#101012",
313
302
  border: "#2a2a2e", text: "#ffffff", muted: "#8a8a93", faint: "#5a5b62",
314
303
  accent: "#00BFC2", accentPressed: "#00999b", accentDeep: "#0c4e4f", onAccent: "#05201f",
315
304
  success: "#30D158", danger: "#F83131", scrim: "#000000b3",
316
- // theme SYSTEM keys — SDK-rendered chrome reads these. `color` = default text color everywhere;
317
- // the other four brand the tab bar (defineTabs here, UITabs in the built app). Mirror the brand:
318
- color: "#ffffff",
319
- primaryColor: "#00BFC2", mutedColor: "#8a8a93", tabbarBg: "#161618", screenBg: "#080808",
320
305
  }
321
- export const colors: { [K in keyof typeof palette]: string } = theme(palette)
322
306
  export const spacing = { xs: 4, sm: 8, md: 12, lg: 16, xl: 20, xxl: 24 }
323
307
  export const radius = { sm: 8, md: 12, lg: 16, pill: 999 }
324
308
  ```
@@ -328,7 +312,7 @@ export const radius = { sm: 8, md: 12, lg: 16, pill: 999 }
328
312
  import { colors, spacing, radius } from "./tokens"
329
313
 
330
314
  export const PrimaryButton = (label: string, o: { name?: string, disabled?: boolean } = {}) =>
331
- UIButton(UIText(label).style({ fontSize: 16, fontWeight: 700, color: o.disabled ? colors.muted : colors.onAccent }))
315
+ UIButton([ UIText(label).style({ fontSize: 16, fontWeight: 700, color: o.disabled ? colors.muted : colors.onAccent }) ])
332
316
  .style({ height: 52, borderRadius: radius.md, bgColor: o.disabled ? colors.accentDeep : colors.accent,
333
317
  justifyContent: "center", alignItems: "center", name: o.name, onPressed: { bgColor: colors.accentPressed } })
334
318
 
@@ -337,15 +321,15 @@ export const SectionLabel = (t: string) =>
337
321
 
338
322
  // A field is a STATIC MOCK — the value is drawn as text (see ui rules); passwords as dots.
339
323
  export const Field = (o: { label?: string, value?: string, placeholder?: string, focused?: boolean }) =>
340
- UIColumn(
324
+ UIColumn([
341
325
  o.label ? SectionLabel(o.label) : null,
342
- UIRow(UIText(o.value ?? o.placeholder ?? "").style({ flexGrow: 1, fontSize: 16, color: o.value ? colors.text : colors.muted }))
326
+ UIRow([ UIText(o.value ?? o.placeholder ?? "").style({ flexGrow: 1, fontSize: 16, color: o.value ? colors.text : colors.muted }) ])
343
327
  .style({ height: 52, px: 16, alignItems: "center", bgColor: colors.field, borderRadius: radius.md,
344
328
  border: `1px solid ${o.focused ? colors.accent : colors.border}` }),
345
- ).style({ gap: spacing.sm })
329
+ ]).style({ gap: spacing.sm })
346
330
 
347
331
  // Bottom sheet shell — a real modal: the scrim is overlayColor, the box is just the bottom stack.
348
- export const Sheet = (...children: UINodeChild[]) => {
332
+ export const Sheet = (children: UINodeChild[]) => {
349
333
  const sheet = UIWidget(children).style({
350
334
  left: 0, right: 0, bottom: 0, px: spacing.lg, pb: "calc(safe-bottom + 16px)", gap: spacing.sm,
351
335
  overlayColor: colors.scrim,
@@ -355,19 +339,19 @@ export const Sheet = (...children: UINodeChild[]) => {
355
339
 
356
340
  // Full-screen confirm — full-inset box, centered card, NO onOverlayTap (a confirm demands a choice).
357
341
  export const ConfirmDialog = (o: { title: string, body?: string, confirmLabel: string, confirmName: string }) =>
358
- UIWidget(
359
- UIColumn(
342
+ UIWidget([
343
+ UIColumn([
360
344
  UIText(o.title).style({ fontSize: 17, fontWeight: 700, color: colors.text, textAlign: "center" }),
361
345
  o.body ? UIText(o.body).style({ fontSize: 14, color: colors.muted, textAlign: "center", lineHeight: "1.4em" }) : null,
362
346
  PrimaryButton("Cancel", { name: "cancel" }),
363
- ).style({ bgColor: colors.card, borderRadius: radius.lg, p: spacing.xl, gap: spacing.lg, width: "86%", maxWidth: 360 }),
364
- ).style({ top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", overlayColor: colors.scrim })
347
+ ]).style({ bgColor: colors.card, borderRadius: radius.lg, p: spacing.xl, gap: spacing.lg, width: "86%", maxWidth: 360 }),
348
+ ]).style({ top: 0, left: 0, right: 0, bottom: 0, justifyContent: "center", alignItems: "center", overlayColor: colors.scrim })
365
349
  ```
366
350
 
367
351
  ## Screen rules
368
352
 
369
353
  - **One file = one screen.** `screens/<id>.ts` default-exports either a value
370
- (`export default UIScreen(...).style({...})`) or a function of state (below). Ids are
354
+ (`export default UIScreen([...]).style({...})`) or a function of state (below). Ids are
371
355
  lowercase `[a-z0-9-]`.
372
356
  - **Screens are islands.** A screen imports ONLY from `../shared/` — never another screen, no props.
373
357
  - **Mock data is the domain.** Realistic inline mock data at the top of the file — the entities and
@@ -395,20 +379,20 @@ const classes = [
395
379
  { start: "19:30", end: "21:00", title: "Acrobatics workshop", trainer: "A. Razgulin", price: 1000, seats: 2 },
396
380
  ]
397
381
 
398
- const FilterSheet = () => Sheet(
382
+ const FilterSheet = () => Sheet([
399
383
  /* chip groups… */
400
384
  PrimaryButton("Show 1 class", { name: "apply" }),
401
- )
385
+ ])
402
386
 
403
387
  export default (state: "default" | "empty" | "filters" = "default") => {
404
388
  if (state === "filters") FilterSheet().show()
405
- return UIScreen(
406
- UIScrollable(
389
+ return UIScreen([
390
+ UIScrollable([
407
391
  SectionLabel("Today"),
408
392
  state === "empty" ? EmptyDay() : UIColumn(classes.map((c) => ClassCard(c, { name: "class" }))).style({ gap: spacing.sm }),
409
- ).style({ flexGrow: 1, flexShrink: 1, px: spacing.xl, pt: spacing.md, gap: spacing.lg }),
393
+ ]).style({ flexGrow: 1, flexShrink: 1, px: spacing.xl, pt: spacing.md, gap: spacing.lg }),
410
394
  mainTabs.bar("schedule"),
411
- ).style({ bgColor: colors.bg, pt: "safe-top" })
395
+ ]).style({ bgColor: colors.bg, pt: "safe-top" })
412
396
  }
413
397
  ```
414
398
 
@@ -442,40 +426,60 @@ design_map ops: [
442
426
  ## The tab bar
443
427
 
444
428
  Declared ONCE in `shared/tabs.ts` — the board discovers it from source and renders it as a rail
445
- with lanes; tab switching needs NO edges. `defineTabs` is an SDK global: the standard themed
446
- bottom bar, the same look the built app's `UITabs` renders. The keys are screen ids (each tab's
447
- root, in tab order), literals only; icons are always `assetIcon(...)` calls:
429
+ with lanes; tab switching needs NO edges. The `defineTabs` keys are screen ids (each tab's root, in
430
+ tab order), literals only. A screen that shows the bar mounts it with a literal tab id — roots AND
431
+ pushed details that keep the bar; a screen that doesn't mount it is a full-screen push. Mount the
432
+ bar alone when the screen owns its layout (`mainTabs.bar("schedule")`, as above) or wrap content
433
+ with `mainTabs.screen("home", [...])`.
434
+
435
+ If `shared/tabs.ts` doesn't exist yet and the app needs a tab bar, create it with exactly this
436
+ content (then edit only the `defineTabs({...})` keys and the styling constants):
448
437
 
449
438
  ```ts
450
- // design/shared/tabs.ts — this declaration is the whole file
451
- export const mainTabs = defineTabs({
452
- schedule: { label: "Schedule", icon: assetIcon("lucide:calendar") },
453
- profile: { label: "Profile", icon: assetIcon("lucide:user") },
454
- })
439
+ type TabDef = { label: string, icon?: { svg: string, tintColor: string | null } }
440
+
441
+ const ACTIVE = "var(--primaryColor, #5b8cff)"
442
+ const INACTIVE = "var(--mutedColor, #8a919e)"
443
+ const BAR_BG = "var(--tabbarBg, #15171c)"
444
+ const SCREEN_BG = "var(--screenBg, #101114)"
445
+
446
+ export const defineTabs = <T extends Record<string, TabDef>>(tabs: T) => {
447
+ const ids = Object.keys(tabs) as (keyof T & string)[]
448
+
449
+ const bar = (active: keyof T & string) =>
450
+ UIRow(ids.map((id) => {
451
+ const color = id === active ? ACTIVE : INACTIVE
452
+ return UIButton([
453
+ ...(tabs[id].icon ? [UIImage(tabs[id].icon!).style({ width: 22, height: 22, tintColor: color })] : []),
454
+ UIText(tabs[id].label).style({ fontSize: 10, color }),
455
+ ]).style({ name: `tab-${id}`, flexDirection: "column", gap: 3, flexGrow: 1, flexBase: 0, pt: 8, pb: 6 })
456
+ })).style({ bgColor: BAR_BG, pb: "safe-bottom" })
457
+
458
+ const screen = (active: keyof T & string, children: UINodeChild[]) =>
459
+ UIScreen([
460
+ UIColumn(children).style({ flexGrow: 1, p: 16, pt: "safe-top" }),
461
+ bar(active),
462
+ ]).style({ bgColor: SCREEN_BG })
463
+
464
+ return { ids, bar, screen }
465
+ }
455
466
  ```
456
467
 
457
- A screen that shows the bar mounts it with a literal tab id — roots AND pushed details that keep
458
- the bar; a screen that doesn't mount it is a full-screen push. Mount the bar alone when the screen
459
- owns its layout (`mainTabs.bar("schedule")`, as above) or wrap content with
460
- `mainTabs.screen("home", [...])`. Restyle through the theme's system keys — `primaryColor`,
461
- `mutedColor`, `tabbarBg`, `screenBg`, set in `shared/tokens.ts` — never by reimplementing the
462
- bar. Keep the `defineTabs({...})` declaration and `.screen("<tab>", ...)` / `.bar("<tab>")` call
463
- shapes intact — the board parses them.
464
-
465
- ## Icons & fonts
466
-
467
- `assetIcon("pack:name")` inlines a registry icon at compile time — the ONLY way to make an icon
468
- (never hand-write its `{ svg }` result — that renders blank). Use real icons freely on every
469
- screen. Vendored copies (`design/assets/icons/`, listed under `[Assets]`) resolve first; anything
470
- else is fetched from the icon registry automatically. The main pack is `lucide` (kebab-case names:
471
- `"lucide:bell"`, `"lucide:chevron-right"`); an unknown name is a compile error with "did you mean"
472
- suggestions. Wrap each icon in a tiny factory in `shared/ui.ts`
473
- (`Icon.bell = (c, s = 20) => UIImage(assetIcon("lucide:bell")).style({ width: s, height: s, tintColor: c })`).
474
-
475
- `font("id")` works the same way — registry families load from the font CDN, no vendoring needed:
476
- `theme({ fontFamily: font("manrope") })`, or per-node `.style({ fontFamily: font("rubik") })`.
477
- An unknown id or weight is a compile error listing what exists. Only project font files
478
- (`font("./Brand.ttf")`) must already be pushed.
468
+ Keep the `defineTabs({...})` declaration and `.screen("<tab>", ...)` / `.bar("<tab>")` call shapes
469
+ intact — the board parses them.
470
+
471
+ ## Icons & fonts — vendored only
472
+
473
+ `assetIcon("pack:name")` and `font("id")` are compile-time macros resolved from files ALREADY in
474
+ the project: icons from `design/assets/icons/<pack>/<name>.svg`, font faces from
475
+ `design/assets/fonts/` (both appear under `[Assets]`). There is NO registry access at compile time
476
+ here — an id that isn't vendored is a compile error.
477
+
478
+ - Vendored icons listed in `[Assets]` → use them freely; wrap each in a tiny factory in `shared/ui.ts`
479
+ (`Icon.bell = (c, s = 20) => UIImage(assetIcon("lucide:bell")).style({ width: s, height: s, tintColor: c })`).
480
+ - Not vendored → text glyphs (`✓ ✕ + ← ★ ♥ ⚙`) or simple shapes (a `UIBox`-style circle/bar), and
481
+ stay on the default font. Mention once that running `lecodes design` locally can vendor real
482
+ icons/fonts.
479
483
 
480
484
  ## spec.md — the concept
481
485