@meistrari/tela-build 1.71.0 → 1.72.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  Latest updates and announcements.
4
4
 
5
+ ## September 16, 2026
6
+
7
+ ### Square Loader
8
+
9
+ - **New `TelaLoadersSquare`.** The animated square from the splash screen is now its own component, with `size="md"` (32×32) and `size="sm"` (12×12). `TelaLoadersSplashScreen` and `TelaChatWorkingIndicator` render it. See [Square Loader](/components/square).
10
+
11
+ ### Button
12
+
13
+ - **`size="sm"` icons are now 10px.** The icon size lookup compared against the size's class string instead of the size name, so every size rendered the 13px default. `md` and `lg` are unchanged. See [Button](/components/button).
14
+ - **`size="lg"` is slightly tighter.** Vertical padding goes from 10px to 8px and the radius from 13px to 12px.
15
+ - **Icon gap is now 4px.** Icons get a 2px padded wrapper, so the gap between icon and label drops from 6px to 4px.
16
+ - **Icons sit on the cap line.** Leading and trailing icons (and the loading spinner) are lifted half a pixel so they center on the capital letters instead of rendering a device pixel low on 2x screens.
17
+
18
+ ### Chat: Working indicator
19
+
20
+ - **New `TelaChatWorkingIndicator`.** Running-turn row for the conversation: `TelaLoadersSquare` (`sm`), a `label` (`Working` by default), and an elapsed counter driven by `startedAt` or `elapsed`. Render it whenever a turn is running; the host composes anything else (such as `TelaChatTextShimmer`) around it. See [Chat → Working Indicator](/templates/chat#working-indicator).
21
+ - **New `TelaChatStopRunButton`.** While a turn runs, render it in place of `TelaChatPromptInput`: a centered secondary pill with a stop icon that emits `stop`. Wrap it and `TelaChatPromptInput` in the new `TelaChatAnimateEnter` inside an `<AnimatePresence mode="popLayout">` to cross-fade them.
22
+ - **New `TelaChatAnimateEnter`.** Enter/exit preset (fade, slide, slight scale) for the prompt input and stop button; `TelaChatBody` docks the wrapper when it holds a docked surface. Props: `offset`, `scale`, `duration`. It reuses the prompt input's dock, so the conversation padding and scrollbar inset stay correct. See [Chat → Stop Run](/templates/chat#stop-run).
23
+ - **`TelaChatMessage` stacks its children with an 8px gap.** A running turn can hold the indicator plus a `TelaChatTextShimmer` or reasoning trace in one message; single-child messages are unaffected.
24
+
5
25
  ## September 15, 2026
6
26
 
7
27
  ### Chat: Floating window
@@ -67,6 +67,24 @@ A versatile button component with multiple variants, sizes, and states. Supports
67
67
  </TelaButton>
68
68
  ```
69
69
 
70
+ ### Sizes
71
+
72
+ Three sizes, `md` by default. Each one sets its own text, padding, radius and icon size, so a leading or trailing icon scales with the button.
73
+
74
+ ```vue
75
+ <div class="flex items-center gap-12px">
76
+ <TelaButton size="sm" icon="i-ph-plus" leading>Small</TelaButton>
77
+ <TelaButton size="md" icon="i-ph-plus" leading>Medium</TelaButton>
78
+ <TelaButton size="lg" icon="i-ph-plus" leading>Large</TelaButton>
79
+ </div>
80
+ ```
81
+
82
+ | size | text | padding | radius | icon |
83
+ |---|---|---|---|---|
84
+ | `sm` | 12px / 16px | 4px × 10px | 9px | 10px |
85
+ | `md` | 14px / 18px | 7px × 12px | 11px | 13px |
86
+ | `lg` | 16px / 20px | 8px × 16px | 12px | 13px |
87
+
70
88
  ### With Icon Code
71
89
 
72
90
  ```vue
@@ -37,7 +37,7 @@ const hasTrailingIcon = computed(() => !!((props.loading && !props.leading) || s
37
37
  const sizeStyle = computed(() => (({
38
38
  sm: '[--padding-x:10px] text-[12px] leading-[16px] tracking-normal py-[4px] rounded-[9px]',
39
39
  md: '[--padding-x:12px] text-[14px] leading-[18px] -tracking-[0.15px] py-[7px] rounded-[11px]',
40
- lg: '[--padding-x:16px] text-[16px] leading-[20px] -tracking-[0.15px] py-[10px] rounded-[13px]',
40
+ lg: '[--padding-x:16px] text-[16px] leading-[20px] -tracking-[0.15px] py-[8px] rounded-[12px]',
41
41
  }) as Record<ButtonSize, string>)[resolvedSize.value] ?? '')
42
42
 
43
43
  const paddingStyle = computed(() => [
@@ -64,20 +64,11 @@ const resolvedStyle = computed(() => [
64
64
 
65
65
  const tag = computed(() => props.to ? NuxtLink : 'button')
66
66
 
67
- const iconSize = computed(() => {
68
- switch (sizeStyle.value) {
69
- case 'xs':
70
- return '11px'
71
- case 'sm':
72
- return '11px'
73
- case 'md':
74
- return '13px'
75
- case 'lg':
76
- return '13px'
77
- default:
78
- return '13px'
79
- }
80
- })
67
+ const iconSize = computed(() => (({
68
+ sm: '10px',
69
+ md: '13px',
70
+ lg: '13px',
71
+ }) as Record<ButtonSize, string>)[resolvedSize.value] ?? '13px')
81
72
  </script>
82
73
 
83
74
  <template>
@@ -85,19 +76,42 @@ const iconSize = computed(() => {
85
76
  :is="tag"
86
77
  :to="to"
87
78
  :class="cn(
88
- 'box-border font-540 select-none flex items-center justify-center gap-6px ease-out duration-40',
79
+ 'box-border font-540 select-none flex items-center justify-center gap-4px ease-out duration-40',
89
80
  disabled ? 'cursor-not-allowed' : 'cursor-pointer',
90
81
  resolvedStyle,
91
82
  )"
92
83
  :disabled="disabled"
93
84
  :target="target"
94
85
  >
95
- <TelaIconSpinner v-if="loading && leading" class="animate-spin" />
86
+ <div v-if="loading && leading" data-button-icon class="p-2px flex items-center justify-center">
87
+ <TelaIconSpinner class="animate-spin" />
88
+ </div>
89
+
96
90
  <slot v-else-if="$slots.leading" name="leading" />
97
- <TelaIcon v-else-if="icon && leading" :name="icon" :size="iconSize" :color="iconColorStyle" class="min-h-14px min-w-14px" />
91
+
92
+ <div v-else-if="icon && leading" data-button-icon class="p-2px flex items-center justify-center">
93
+ <TelaIcon :name="icon" :size="iconSize" :color="iconColorStyle" />
94
+ </div>
95
+
98
96
  <slot />
99
- <TelaIconSpinner v-if="loading && !leading" class="animate-spin min-h-14px min-w-14px" />
97
+
98
+ <div v-if="loading && !leading" data-button-icon class="p-2px flex items-center justify-center">
99
+ <TelaIconSpinner class="animate-spin" />
100
+ </div>
101
+
100
102
  <slot v-else-if="$slots.trailing" name="trailing" />
101
- <TelaIcon v-else-if="icon && !leading" :name="icon" :size="iconSize" :color="iconColorStyle" class="min-h-14px min-w-14px" />
103
+
104
+ <div v-else-if="icon && !leading" data-button-icon class="p-2px flex items-center justify-center">
105
+ <TelaIcon :name="icon" :size="iconSize" :color="iconColorStyle" />
106
+ </div>
102
107
  </component>
103
108
  </template>
109
+
110
+ <style scoped>
111
+ /* Optical alignment */
112
+ @media (min-resolution: 1.5dppx) {
113
+ [data-button-icon] {
114
+ transform: translateY(-0.5px);
115
+ }
116
+ }
117
+ </style>
@@ -0,0 +1,24 @@
1
+ <script setup lang="ts">
2
+ withDefaults(defineProps<{
3
+ /** Slide distance on enter/exit, as a CSS length or percentage of the element. */
4
+ offset?: string | number
5
+ scale?: number
6
+ duration?: number
7
+ }>(), {
8
+ offset: '100%',
9
+ scale: 0.97,
10
+ duration: 0.16,
11
+ })
12
+ </script>
13
+
14
+ <template>
15
+ <Motion
16
+ data-chat-animate-enter
17
+ :initial="{ opacity: 0, y: offset, scale }"
18
+ :animate="{ opacity: 1, y: 0, scale: 1 }"
19
+ :exit="{ opacity: 0, y: offset, scale }"
20
+ :transition="{ duration, ease: 'easeOut' }"
21
+ >
22
+ <slot />
23
+ </Motion>
24
+ </template>
@@ -7,12 +7,16 @@ withDefaults(defineProps<{
7
7
  })
8
8
 
9
9
  const rootRef = ref<HTMLElement | null>(null)
10
+
11
+ // The docked element is the prompt-input surface, or the TelaChatAnimateEnter
12
+ // wrapping it (document order puts the wrapper first).
13
+ const DOCK_SELECTOR = '[data-chat-animate-enter]:has([data-chat-prompt-input]), [data-chat-prompt-input]'
10
14
  let inputObserver: ResizeObserver | null = null
11
15
  let slotObserver: MutationObserver | null = null
12
16
  let observedInput: HTMLElement | null = null
13
17
 
14
18
  function bindInputObserver() {
15
- const input = rootRef.value?.querySelector<HTMLElement>('[data-chat-prompt-input]') ?? null
19
+ const input = rootRef.value?.querySelector<HTMLElement>(DOCK_SELECTOR) ?? null
16
20
 
17
21
  if (input === observedInput)
18
22
  return
@@ -68,10 +72,24 @@ onBeforeUnmount(() => {
68
72
  padding-bottom: calc(var(--chat-input-height, 64px) + 24px);
69
73
  }
70
74
 
71
- [data-chat-body] [data-chat-prompt-input] {
75
+ /*
76
+ * The dock: the prompt-input surface itself, or the TelaChatAnimateEnter that
77
+ * wraps it. A surface inside the wrapper stays in flow so only one box is
78
+ * positioned, faded and (with popLayout) pinned.
79
+ */
80
+ [data-chat-body] :is([data-chat-prompt-input]:not([data-chat-animate-enter] *), [data-chat-animate-enter]:has([data-chat-prompt-input])) {
72
81
  --at-apply: 'absolute bottom-0 left-0 right-0 z-2 mx-12px mb-12px';
73
82
  }
74
83
 
84
+ /*
85
+ * While AnimatePresence (popLayout) holds an exiting dock element it pins it
86
+ * with `left`/`top` measured from the offsets, which already include the side
87
+ * margin; keeping the margin on top of that shifts it sideways mid-fade.
88
+ */
89
+ [data-chat-body] :is([data-chat-prompt-input], [data-chat-animate-enter])[data-motion-pop-id] {
90
+ margin: 0 !important;
91
+ }
92
+
75
93
  [data-chat-body]:has([data-chat-prompt-input]) .chat-conversation-scrollbar {
76
94
  top: 8px !important;
77
95
  bottom: calc(var(--chat-input-height, 64px) + 16px) !important;
@@ -86,7 +104,7 @@ onBeforeUnmount(() => {
86
104
  padding-bottom: calc(var(--chat-input-height, 64px) + 32px);
87
105
  }
88
106
 
89
- [data-chat-body][data-variant='page'] [data-chat-prompt-input] {
107
+ [data-chat-body][data-variant='page'] :is([data-chat-prompt-input]:not([data-chat-animate-enter] *), [data-chat-animate-enter]:has([data-chat-prompt-input])) {
90
108
  --at-apply: 'mx-24px mb-16px';
91
109
  }
92
110
  </style>
@@ -0,0 +1,31 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ class?: HTMLAttributes['class']
6
+ label?: string
7
+ disabled?: boolean
8
+ }>(), {
9
+ label: 'Stop Run',
10
+ })
11
+
12
+ const emit = defineEmits<{
13
+ (e: 'stop'): void
14
+ }>()
15
+ </script>
16
+
17
+ <template>
18
+ <div data-chat-prompt-input :class="cn('flex justify-center', props.class)">
19
+ <TelaButton
20
+ type="button"
21
+ variant="secondary"
22
+ size="sm"
23
+ leading
24
+ icon="i-ph-square-bold"
25
+ :disabled="props.disabled"
26
+ @click="emit('stop')"
27
+ >
28
+ {{ props.label }}
29
+ </TelaButton>
30
+ </div>
31
+ </template>
@@ -0,0 +1,84 @@
1
+ <script setup lang="ts">
2
+ import type { HTMLAttributes } from 'vue'
3
+
4
+ const props = withDefaults(defineProps<{
5
+ class?: HTMLAttributes['class']
6
+ /** Row label next to the loader. */
7
+ label?: string
8
+ /** Epoch ms of when work started; the elapsed counter ticks from it every second. */
9
+ startedAt?: number
10
+ /** Elapsed seconds controlled by the host. Takes precedence over `startedAt`. */
11
+ elapsed?: number
12
+ }>(), {
13
+ label: 'Working',
14
+ })
15
+
16
+ const now = ref(Date.now())
17
+ let timer: ReturnType<typeof setInterval> | null = null
18
+
19
+ function stopTicking() {
20
+ if (timer)
21
+ clearInterval(timer)
22
+ timer = null
23
+ }
24
+
25
+ function syncTicking() {
26
+ stopTicking()
27
+
28
+ if (props.elapsed !== undefined || props.startedAt === undefined)
29
+ return
30
+
31
+ now.value = Date.now()
32
+
33
+ timer = setInterval(() => {
34
+ now.value = Date.now()
35
+ }, 1000)
36
+ }
37
+
38
+ onMounted(() => {
39
+ syncTicking()
40
+ watch(() => [props.startedAt, props.elapsed], syncTicking)
41
+ })
42
+
43
+ onUnmounted(stopTicking)
44
+
45
+ const elapsedSeconds = computed(() => {
46
+ if (props.elapsed !== undefined)
47
+ return Math.max(0, Math.floor(props.elapsed))
48
+
49
+ if (props.startedAt === undefined)
50
+ return undefined
51
+
52
+ return Math.max(0, Math.floor((now.value - props.startedAt) / 1000))
53
+ })
54
+
55
+ const elapsedLabel = computed(() => {
56
+ const seconds = elapsedSeconds.value
57
+
58
+ if (seconds === undefined)
59
+ return undefined
60
+
61
+ if (seconds < 60)
62
+ return `${seconds}s`
63
+
64
+ const minutes = Math.floor(seconds / 60)
65
+
66
+ return `${minutes}m ${seconds % 60}s`
67
+ })
68
+ </script>
69
+
70
+ <template>
71
+ <div
72
+ :class="cn('flex items-center gap-10px text-start', props.class)"
73
+ role="status"
74
+ aria-live="polite"
75
+ >
76
+ <TelaLoadersSquare size="sm" class="shrink-0" />
77
+ <div class="flex items-center gap-6px">
78
+ <h5 class="heading-h5-semibold text-primary">
79
+ {{ props.label }}
80
+ </h5>
81
+ <span v-if="elapsedLabel" aria-hidden="true" class="body-14-regular text-tertiary tabular-nums">{{ elapsedLabel }}</span>
82
+ </div>
83
+ </div>
84
+ </template>
@@ -24,6 +24,7 @@ const messages = ref<ChatMessage[]>([
24
24
  { id: '2', role: 'assistant', text: 'The chat primitives landed: a conversation viewport, a prompt input, and three shells you can compose freely.' },
25
25
  ])
26
26
  const status = ref<'ready' | 'uploading' | 'streaming'>('ready')
27
+ const startedAt = ref(Date.now())
27
28
  const draft = ref('')
28
29
  const recentFiles = [
29
30
  { id: 'f1', label: 'release-notes.pdf', type: 'application/pdf' },
@@ -48,6 +49,8 @@ function onInput(event: Event) {
48
49
 
49
50
  function send() {
50
51
  // append the user message, call your backend, stream the reply...
52
+ draft.value = ''
53
+ startedAt.value = Date.now()
51
54
  conversationRef.value?.scrollToBottom()
52
55
  }
53
56
  </script>
@@ -63,70 +66,76 @@ function send() {
63
66
  </TelaChatMessageContent>
64
67
  </TelaChatMessage>
65
68
 
66
- <TelaChatTextShimmer v-if="status === 'streaming'">
67
- Thinking...
68
- </TelaChatTextShimmer>
69
+ <TelaChatWorkingIndicator v-if="status === 'streaming'" :started-at="startedAt" />
69
70
  </TelaChatConversationContent>
70
71
  </TelaChatConversation>
71
72
 
72
- <TelaChatPromptInput>
73
- <TelaChatPromptInputTextarea
74
- placeholder="How can I help?"
75
- :empty="!draft"
76
- @input="onInput"
77
- @keydown.enter.exact.prevent="send"
78
- />
79
- <TelaChatPromptInputToolbar>
80
- <TelaChatPromptInputTools>
81
- <TelaChatPromptInputActionMenu>
82
- <TelaChatPromptInputActionMenuTrigger />
83
- <TelaChatPromptInputActionMenuContent>
84
- <TelaChatPromptInputActionMenuItem icon="i-ph-paperclip">
85
- Add files or photos
86
- </TelaChatPromptInputActionMenuItem>
87
- <TelaChatPromptInputActionMenuSub>
88
- <TelaChatPromptInputActionMenuSubTrigger icon="i-ph-clock-counter-clockwise">
89
- Recent files
90
- </TelaChatPromptInputActionMenuSubTrigger>
91
- <TelaChatPromptInputActionMenuSubContent>
92
- <TelaChatPromptInputActionMenuItem
93
- v-for="file in recentFiles"
94
- :key="file.id"
95
- >
96
- <img :src="getMimeTypeIcon(file.type)" class="w-16px h-16px mr-8px shrink-0" alt="">
97
- <span class="min-w-0 max-w-200px truncate">{{ file.label }}</span>
98
- </TelaChatPromptInputActionMenuItem>
99
- </TelaChatPromptInputActionMenuSubContent>
100
- </TelaChatPromptInputActionMenuSub>
101
- <TelaMenubarSeparator />
102
- <TelaChatPromptInputActionMenuSub>
103
- <TelaChatPromptInputActionMenuSubTrigger icon="i-ph-notepad" icon-size="16px">
104
- Skills
105
- </TelaChatPromptInputActionMenuSubTrigger>
106
- <TelaChatPromptInputActionMenuSubContent>
107
- <TelaChatPromptInputActionMenuItem
108
- v-for="skill in skills"
109
- :key="skill.id"
110
- @click="draft = skill.label"
111
- >
112
- <span class="min-w-0 max-w-200px truncate">{{ skill.label }}</span>
113
- </TelaChatPromptInputActionMenuItem>
114
- </TelaChatPromptInputActionMenuSubContent>
115
- </TelaChatPromptInputActionMenuSub>
116
- </TelaChatPromptInputActionMenuContent>
117
- </TelaChatPromptInputActionMenu>
118
- </TelaChatPromptInputTools>
119
- <TelaChatPromptInputActions>
120
- <TelaChatModelSelector v-model="selectedModel" :options="modelOptions" />
121
- <TelaChatPromptSubmitButton
122
- :status="status"
123
- :disabled="!draft.trim()"
124
- @send="send"
125
- @stop="status = 'ready'"
73
+ <AnimatePresence mode="popLayout" :initial="false">
74
+ <TelaChatAnimateEnter v-if="status === 'streaming'" key="stop">
75
+ <TelaChatStopRunButton @stop="status = 'ready'" />
76
+ </TelaChatAnimateEnter>
77
+
78
+ <TelaChatAnimateEnter v-else key="input" :scale="0.9">
79
+ <TelaChatPromptInput>
80
+ <TelaChatPromptInputTextarea
81
+ placeholder="How can I help?"
82
+ :empty="!draft"
83
+ @input="onInput"
84
+ @keydown.enter.exact.prevent="send"
126
85
  />
127
- </TelaChatPromptInputActions>
128
- </TelaChatPromptInputToolbar>
129
- </TelaChatPromptInput>
86
+ <TelaChatPromptInputToolbar>
87
+ <TelaChatPromptInputTools>
88
+ <TelaChatPromptInputActionMenu>
89
+ <TelaChatPromptInputActionMenuTrigger />
90
+ <TelaChatPromptInputActionMenuContent>
91
+ <TelaChatPromptInputActionMenuItem icon="i-ph-paperclip">
92
+ Add files or photos
93
+ </TelaChatPromptInputActionMenuItem>
94
+ <TelaChatPromptInputActionMenuSub>
95
+ <TelaChatPromptInputActionMenuSubTrigger icon="i-ph-clock-counter-clockwise">
96
+ Recent files
97
+ </TelaChatPromptInputActionMenuSubTrigger>
98
+ <TelaChatPromptInputActionMenuSubContent>
99
+ <TelaChatPromptInputActionMenuItem
100
+ v-for="file in recentFiles"
101
+ :key="file.id"
102
+ >
103
+ <img :src="getMimeTypeIcon(file.type)" class="w-16px h-16px mr-8px shrink-0" alt="">
104
+ <span class="min-w-0 max-w-200px truncate">{{ file.label }}</span>
105
+ </TelaChatPromptInputActionMenuItem>
106
+ </TelaChatPromptInputActionMenuSubContent>
107
+ </TelaChatPromptInputActionMenuSub>
108
+ <TelaMenubarSeparator />
109
+ <TelaChatPromptInputActionMenuSub>
110
+ <TelaChatPromptInputActionMenuSubTrigger icon="i-ph-notepad" icon-size="16px">
111
+ Skills
112
+ </TelaChatPromptInputActionMenuSubTrigger>
113
+ <TelaChatPromptInputActionMenuSubContent>
114
+ <TelaChatPromptInputActionMenuItem
115
+ v-for="skill in skills"
116
+ :key="skill.id"
117
+ @click="draft = skill.label"
118
+ >
119
+ <span class="min-w-0 max-w-200px truncate">{{ skill.label }}</span>
120
+ </TelaChatPromptInputActionMenuItem>
121
+ </TelaChatPromptInputActionMenuSubContent>
122
+ </TelaChatPromptInputActionMenuSub>
123
+ </TelaChatPromptInputActionMenuContent>
124
+ </TelaChatPromptInputActionMenu>
125
+ </TelaChatPromptInputTools>
126
+ <TelaChatPromptInputActions>
127
+ <TelaChatModelSelector v-model="selectedModel" :options="modelOptions" />
128
+ <TelaChatPromptSubmitButton
129
+ :status="status"
130
+ :disabled="!draft.trim()"
131
+ @send="send"
132
+ @stop="status = 'ready'"
133
+ />
134
+ </TelaChatPromptInputActions>
135
+ </TelaChatPromptInputToolbar>
136
+ </TelaChatPromptInput>
137
+ </TelaChatAnimateEnter>
138
+ </AnimatePresence>
130
139
  </TelaChatBody>
131
140
  </div>
132
141
  </template>
@@ -972,6 +981,91 @@ const suggestions = [
972
981
  </template>
973
982
  ```
974
983
 
984
+ ### Working Indicator
985
+
986
+ The running state of an assistant turn: a small `TelaLoadersSquare`, a label (`Working` by default) and an elapsed counter. Show it whenever something is running. A running turn is one `TelaChatMessage`: put the indicator and anything else the wait needs (a shimmering activity line, a reasoning trace) inside it, and the message stacks them 8px apart while the conversation keeps its 16px between turns. Pass `started-at` (epoch ms) and it ticks by itself; pass `elapsed` (seconds) to drive it from the host, for example from a server-reported duration.
987
+
988
+ ```vue
989
+ <script setup lang="ts">
990
+ const startedAt = Date.now()
991
+ </script>
992
+
993
+ <template>
994
+ <TelaChatConversationContent>
995
+ <TelaChatMessage from="assistant">
996
+ <TelaChatWorkingIndicator :started-at="startedAt" />
997
+ <TelaChatTextShimmer>Processing information...</TelaChatTextShimmer>
998
+ </TelaChatMessage>
999
+ </TelaChatConversationContent>
1000
+ </template>
1001
+ ```
1002
+
1003
+ ### Stop Run
1004
+
1005
+ While a turn is running the prompt input goes away and this button takes its place, centered where the input was. Neither it nor `TelaChatPromptInput` animates on its own: wrap each in `TelaChatAnimateEnter` and swap them with `v-if` / `v-else` (keyed) inside an `<AnimatePresence mode="popLayout">` to cross-fade them in place. It carries the same `data-chat-prompt-input` marker, so `TelaChatBody` docks it at the bottom and keeps the conversation padded for it. Emits `stop`.
1006
+
1007
+ ```vue
1008
+ <script setup lang="ts">
1009
+ const running = ref(true)
1010
+ const startedAt = Date.now()
1011
+ const draft = ref('')
1012
+
1013
+ function onInput(event: Event) {
1014
+ draft.value = (event.target as HTMLElement).innerText
1015
+ }
1016
+
1017
+ function send() {
1018
+ // the editor unmounts with the input, so clear the draft it was showing
1019
+ draft.value = ''
1020
+ running.value = true
1021
+ }
1022
+ </script>
1023
+
1024
+ <template>
1025
+ <div class="flex flex-col w-full h-260px">
1026
+ <TelaChatBody variant="page">
1027
+ <TelaChatConversation>
1028
+ <TelaChatConversationContent>
1029
+ <TelaChatMessage from="user">
1030
+ <TelaChatMessageContent>Consolidate these exams into a table.</TelaChatMessageContent>
1031
+ </TelaChatMessage>
1032
+ <TelaChatMessage v-if="running" from="assistant">
1033
+ <TelaChatWorkingIndicator :started-at="startedAt" />
1034
+ <TelaChatTextShimmer>Processing information...</TelaChatTextShimmer>
1035
+ </TelaChatMessage>
1036
+ </TelaChatConversationContent>
1037
+ </TelaChatConversation>
1038
+
1039
+ <AnimatePresence mode="popLayout" :initial="false">
1040
+ <TelaChatAnimateEnter v-if="running" key="stop">
1041
+ <TelaChatStopRunButton @stop="running = false" />
1042
+ </TelaChatAnimateEnter>
1043
+ <TelaChatAnimateEnter v-else key="input" :scale="0.9">
1044
+ <TelaChatPromptInput>
1045
+ <TelaChatPromptInputTextarea placeholder="How can I help?" :empty="!draft" @input="onInput" @keydown.enter.exact.prevent="send" />
1046
+ <TelaChatPromptInputToolbar>
1047
+ <TelaChatPromptInputTools>
1048
+ <TelaChatPromptInputActionMenu>
1049
+ <TelaChatPromptInputActionMenuTrigger />
1050
+ <TelaChatPromptInputActionMenuContent>
1051
+ <TelaChatPromptInputActionMenuItem icon="i-ph-paperclip">
1052
+ Add files or photos
1053
+ </TelaChatPromptInputActionMenuItem>
1054
+ </TelaChatPromptInputActionMenuContent>
1055
+ </TelaChatPromptInputActionMenu>
1056
+ </TelaChatPromptInputTools>
1057
+ <TelaChatPromptInputActions>
1058
+ <TelaChatPromptSubmitButton status="ready" :disabled="!draft.trim()" @send="send" />
1059
+ </TelaChatPromptInputActions>
1060
+ </TelaChatPromptInputToolbar>
1061
+ </TelaChatPromptInput>
1062
+ </TelaChatAnimateEnter>
1063
+ </AnimatePresence>
1064
+ </TelaChatBody>
1065
+ </div>
1066
+ </template>
1067
+ ```
1068
+
975
1069
  ### Text Shimmer
976
1070
 
977
1071
  The shimmering thinking indicator. Render it while the model is generating: inside the conversation, under a reasoning trace, wherever the wait happens.
@@ -1040,6 +1134,9 @@ import { TelaChatIconsAnthropic, TelaChatIconsGemini, TelaChatIconsOpenai } from
1040
1134
  | `TelaChatFloatingWindowTrigger` | Pill button that opens the floating widget. |
1041
1135
  | `TelaChatFloatingWindow` | Anchored popover shell for the floating variant (`open` / `@update:open`). |
1042
1136
  | `TelaChatSidepanelWindow` | Always-open, in-flow docked column for the sidepanel variant. |
1137
+ | `TelaChatWorkingIndicator` | Running-turn row: square loader, label, elapsed time. |
1138
+ | `TelaChatStopRunButton` | Replaces the prompt input while a turn runs; centered pill that emits `stop`. |
1139
+ | `TelaChatAnimateEnter` | Enter/exit preset (fade, slide, slight scale) for a surface that comes and goes; when it wraps the prompt input or stop button the body docks the wrapper. Pair with `AnimatePresence`. |
1043
1140
  | `TelaChatTextShimmer` | Shimmering text, used for thinking/loading indicators. |
1044
1141
  | `TelaChatIconsAnthropic` / `TelaChatIconsOpenai` / `TelaChatIconsGemini` | Provider icons for model options. |
1045
1142
  | `TelaChatPoweredByTela` | "Powered by" + Tela wordmark footer. |
@@ -1055,8 +1152,8 @@ Shell (page container | TelaChatFloatingWindow | TelaChatSidepanelWindow)
1055
1152
  ├── TelaChatConversation
1056
1153
  │ └── TelaChatConversationContent
1057
1154
  │ ├── TelaChatMessage → TelaChatMessageContent
1058
- │ └── TelaChatTextShimmer (while generating)
1059
- └── TelaChatPromptInput
1155
+ │ └── TelaChatWorkingIndicator (while generating)
1156
+ └── TelaChatPromptInput (TelaChatStopRunButton while a turn runs)
1060
1157
  ├── TelaChatPromptInputAttachments
1061
1158
  ├── TelaChatPromptInputTextarea
1062
1159
  └── TelaChatPromptInputToolbar
@@ -1309,6 +1406,37 @@ type FloatingWindowProps = {
1309
1406
  // TelaChatSidepanelWindow takes no props.
1310
1407
  ```
1311
1408
 
1409
+ ### Working Indicator
1410
+
1411
+ ```typescript
1412
+ type WorkingIndicatorProps = {
1413
+ class?: HTMLAttributes['class']
1414
+ label?: string // default: 'Working'
1415
+ startedAt?: number // epoch ms; the counter ticks every second from it
1416
+ elapsed?: number // seconds; host-controlled, wins over startedAt
1417
+ }
1418
+ ```
1419
+
1420
+ ### Animate Enter
1421
+
1422
+ ```typescript
1423
+ type AnimateEnterProps = {
1424
+ offset?: string | number // default: '100%' (slide distance)
1425
+ scale?: number // default: 0.97; the input reads better at 0.9, the pill at the default
1426
+ duration?: number // default: 0.16 (seconds)
1427
+ }
1428
+ ```
1429
+
1430
+ ### Stop Run
1431
+
1432
+ ```typescript
1433
+ type StopRunButtonProps = {
1434
+ class?: HTMLAttributes['class']
1435
+ label?: string // default: 'Stop Run'
1436
+ disabled?: boolean
1437
+ }
1438
+ ```
1439
+
1312
1440
  ### Text Shimmer
1313
1441
 
1314
1442
  ```typescript
@@ -1325,7 +1453,7 @@ type TextShimmerProps = {
1325
1453
  - `TelaChatSidepanelWindow`: `default`.
1326
1454
  - `TelaChatWidgetHeader`: `history` (rendered only while the history panel is open).
1327
1455
  - `TelaChatWidgetCard`: `subtitle`, `default` (tool output).
1328
- - `TelaChatBody` / `TelaChatConversation` / `TelaChatConversationContent` / `TelaChatMessage` / `TelaChatMessageContent` / `TelaChatPromptInput` / `TelaChatPromptInputToolbar` / `TelaChatPromptInputTools` / `TelaChatPromptInputActions` / `TelaChatPromptInputAttachments` / `TelaChatReasoningStep` / `TelaChatWidgetSources`: `default`.
1456
+ - `TelaChatBody` / `TelaChatConversation` / `TelaChatConversationContent` / `TelaChatMessage` / `TelaChatMessageContent` / `TelaChatPromptInput` / `TelaChatPromptInputToolbar` / `TelaChatPromptInputTools` / `TelaChatPromptInputActions` / `TelaChatPromptInputAttachments` / `TelaChatReasoningStep` / `TelaChatWidgetSources` / `TelaChatAnimateEnter`: `default`.
1329
1457
 
1330
1458
  ```vue
1331
1459
  <TelaChatConversation scroll-area>
@@ -200,7 +200,7 @@ function focusEditor(event: Event) {
200
200
 
201
201
  <template>
202
202
  <TelaPopover :open="open" @update:open="emit('update:open', $event)">
203
- <div ref="launcher" :class="cn('bottom-20px', contained ? 'absolute right-16px' : 'fixed right-[calc(16px+var(--scrollbar-width,0px))]')">
203
+ <div ref="launcher" :class="cn('bottom-16px', contained ? 'absolute right-16px' : 'fixed right-[calc(16px+var(--scrollbar-width,0px))]')">
204
204
  <div :class="cn('transition-opacity', open ? 'opacity-0 duration-100' : 'duration-160 delay-160')">
205
205
  <TelaPopoverTrigger as-child>
206
206
  <slot name="launcher">
@@ -9,7 +9,7 @@ const resolvedAlign = computed(() => props.align ?? (props.from === 'user' ? 'en
9
9
  </script>
10
10
 
11
11
  <template>
12
- <div flex="~ col" :class="resolvedAlign === 'end' ? 'items-end' : 'items-start'">
12
+ <div flex="~ col" gap-8px :class="resolvedAlign === 'end' ? 'items-end' : 'items-start'">
13
13
  <slot />
14
14
  </div>
15
15
  </template>
@@ -41,15 +41,10 @@ function focusEditor(event: MouseEvent) {
41
41
  <div
42
42
  ref="rootRef"
43
43
  data-chat-prompt-input
44
- flex="~ col"
45
- gap-8px
46
- pl-16px pb-8px pr-8px
47
- rounded-12px
48
- bg border-0.5px border-strong
49
- overflow-hidden
50
- relative
51
- transition
52
- :class="locked ? 'bg-muted cursor-not-allowed' : 'cursor-text'"
44
+ :class="cn(
45
+ 'flex flex-col gap-8px pl-16px pb-8px pr-8px rounded-12px bg border-[0.5px] border-strong overflow-hidden relative transition',
46
+ locked ? 'bg-muted cursor-not-allowed' : 'cursor-text',
47
+ )"
53
48
  @mousedown="focusEditor"
54
49
  >
55
50
  <div class="contents" :inert="locked">
@@ -0,0 +1,45 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Loaders/Splash Screen" />
4
+
5
+ # TelaLoadersSplashScreen
6
+
7
+ Full-viewport white overlay shown while the app boots. It centers a `TelaLoadersSquare` and, when `modelValue` turns false, wipes the loader with a diagonal mask and fades the overlay out before emitting `hidden`. Mount it once at the app root and flip the flag when the first page is ready.
8
+
9
+ ## Props
10
+
11
+ ```typescript
12
+ type SplashScreenProps = {
13
+ modelValue: boolean // true while the app is loading
14
+ }
15
+ ```
16
+
17
+ ## Emits
18
+
19
+ | event | payload | when |
20
+ |---|---|---|
21
+ | `update:modelValue` | `false` | the exit animation has finished |
22
+ | `hidden` | — | same moment, for side effects such as starting a welcome screen |
23
+
24
+ ## Basic Usage
25
+
26
+ The overlay keeps rendering during the exit animation, so it is safe to set `modelValue` to `false` as soon as data is ready.
27
+
28
+ ```vue
29
+ <script setup lang="ts">
30
+ const isLoading = ref(true)
31
+
32
+ onMounted(() => {
33
+ isLoading.value = false
34
+ })
35
+ </script>
36
+
37
+ <template>
38
+ <TelaLoadersSplashScreen v-model="isLoading" />
39
+ </template>
40
+ ```
41
+
42
+ ## Rules
43
+
44
+ 1. **One per app.** The overlay is `fixed`, full-viewport and `z-9999`; render it from the root layout, not from pages.
45
+ 2. **Do not restyle the loader.** The wipe targets the loader's `#square` id; wrap or replace the whole component instead of overriding it.
@@ -48,27 +48,6 @@ watch(() => props.modelValue, (visible) => {
48
48
  id="square-screen"
49
49
  fixed w-100dvw h-100dvh inset-0 z-9999 flex items-center justify-center bg-white
50
50
  >
51
- <div id="square" relative border-4 border="#9CA1A9" bg-white w-8 h-8>
52
- <div
53
- absolute border-4 border-transparent inset="-1"
54
- class="[mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)]"
55
- >
56
- <Motion
57
- absolute aspect-square bg-black h-4
58
- :animate="{
59
- offsetDistance: ['0%', '100%'],
60
- }"
61
- :style="{
62
- width: 40,
63
- offsetPath: 'rect(0 auto auto 0)',
64
- }"
65
- :transition="{
66
- repeat: Number.POSITIVE_INFINITY,
67
- duration: 0.8,
68
- ease: 'easeInOut',
69
- }"
70
- />
71
- </div>
72
- </div>
51
+ <TelaLoadersSquare id="square" size="md" />
73
52
  </div>
74
53
  </template>
@@ -0,0 +1,47 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Loaders/Square" tags={['new']} />
4
+
5
+ # TelaLoadersSquare
6
+
7
+ The Tela square loader: a bar orbiting inside a bordered square. It is the animation behind `TelaLoadersSplashScreen` and the chat working indicator.
8
+
9
+ ## Props
10
+
11
+ ```typescript
12
+ type SquareLoaderProps = {
13
+ size?: 'sm' | 'md' // default: 'md'
14
+ }
15
+ ```
16
+
17
+ ## Basic Usage
18
+
19
+ Renders the 32×32 loader.
20
+
21
+ ```vue
22
+ <TelaLoadersSquare />
23
+ ```
24
+
25
+ ## Variants
26
+
27
+ ### Sizes
28
+
29
+ `md` is 32×32 with a 4px border; `sm` is 12×12 with a 2px border, for inline states next to text.
30
+
31
+ ```vue
32
+ <div class="flex items-center gap-4">
33
+ <TelaLoadersSquare size="sm" />
34
+ <TelaLoadersSquare size="md" />
35
+ </div>
36
+ ```
37
+
38
+ ### Section loading
39
+
40
+ Center it in the area that is loading, with an optional label below.
41
+
42
+ ```vue
43
+ <div class="flex flex-col items-center justify-center gap-4 py-12">
44
+ <TelaLoadersSquare />
45
+ <p class="body-14-regular text-secondary">Loading…</p>
46
+ </div>
47
+ ```
@@ -0,0 +1,55 @@
1
+ <script lang="ts" setup>
2
+ const props = withDefaults(defineProps<{
3
+ size?: 'sm' | 'md'
4
+ }>(), { size: 'md' })
5
+
6
+ const sizes = {
7
+ sm: { box: 'w-3 h-3 border-2', mask: 'border-2 -inset-0.5' },
8
+ md: { box: 'w-8 h-8 border-4', mask: 'border-4 -inset-1' },
9
+ } as const
10
+
11
+ const current = computed(() => sizes[props.size])
12
+ const barColor = DT.colors.icon.DEFAULT
13
+ </script>
14
+
15
+ <template>
16
+ <div class="relative border-icon-subtle bg-white" :class="current.box">
17
+ <div
18
+ :class="cn(
19
+ 'absolute border-transparent [mask-clip:padding-box,border-box] [mask-composite:intersect] [mask-image:linear-gradient(transparent,transparent),linear-gradient(#000,#000)]',
20
+ current.mask,
21
+ )"
22
+ >
23
+ <div
24
+ data-square-loader-bar
25
+ class="absolute -inset-1/2"
26
+ :style="{ '--bar-color': barColor }"
27
+ />
28
+ </div>
29
+ </div>
30
+ </template>
31
+
32
+ <style scoped>
33
+ [data-square-loader-bar] {
34
+ background: conic-gradient(from 0deg, var(--bar-color) 0 90deg, transparent 90deg 360deg);
35
+ animation: square-loader-bar 0.8s ease-in-out infinite;
36
+ will-change: transform;
37
+ }
38
+
39
+ @keyframes square-loader-bar {
40
+ from {
41
+ transform: rotate(0deg);
42
+ }
43
+
44
+ to {
45
+ transform: rotate(360deg);
46
+ }
47
+ }
48
+
49
+ @media (prefers-reduced-motion: reduce) {
50
+ [data-square-loader-bar] {
51
+ animation-duration: 2.4s;
52
+ animation-timing-function: linear;
53
+ }
54
+ }
55
+ </style>
@@ -0,0 +1,54 @@
1
+ import { Meta } from '@storybook/blocks';
2
+
3
+ <Meta title="Loaders/Welcome Screen" />
4
+
5
+ # TelaLoadersWelcomeScreen
6
+
7
+ Full-viewport greeting shown once after the splash screen. It waits `delayAfterTrigger` after `trigger` turns true, reveals `text` with `TelaTextMaskAnimation` (optionally highlighting one word), then wipes the text and fades out before emitting `hidden`.
8
+
9
+ ## Props
10
+
11
+ ```typescript
12
+ type WelcomeScreenProps = {
13
+ modelValue: boolean // true while the screen should be shown
14
+ text: string // greeting to animate
15
+ highlightWord?: string // word inside `text` to emphasize
16
+ trigger: boolean // starts the text animation when true
17
+ delayAfterTrigger?: number // ms to wait after `trigger`, default: 2700
18
+ }
19
+ ```
20
+
21
+ ## Emits
22
+
23
+ | event | payload | when |
24
+ |---|---|---|
25
+ | `update:modelValue` | `false` | the exit animation has finished |
26
+ | `hidden` | — | same moment |
27
+
28
+ ## Basic Usage
29
+
30
+ Chain it after the splash screen by using the inverse of the splash flag as `trigger`, so the greeting starts once the loader is gone.
31
+
32
+ ```vue
33
+ <script setup lang="ts">
34
+ const isLoading = ref(true)
35
+ const showWelcome = ref(true)
36
+ const firstName = 'Ana'
37
+ </script>
38
+
39
+ <template>
40
+ <TelaLoadersSplashScreen v-model="isLoading" />
41
+ <TelaLoadersWelcomeScreen
42
+ v-model="showWelcome"
43
+ :text="`Welcome to Tela, ${firstName}.`"
44
+ :highlight-word="firstName"
45
+ :trigger="!isLoading"
46
+ />
47
+ </template>
48
+ ```
49
+
50
+ ## Rules
51
+
52
+ 1. **Show it once.** Persist that the user has seen it; do not replay on every navigation.
53
+ 2. **Keep `text` short.** It renders as a single centered line; long greetings wrap awkwardly on narrow viewports.
54
+ 3. **`highlightWord` must appear in `text`.** Otherwise nothing is emphasized.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@meistrari/tela-build",
3
- "version": "1.71.0",
3
+ "version": "1.72.0",
4
4
  "type": "module",
5
5
  "files": [
6
6
  "app.config.ts",