@djangocfg/widget-chat 0.1.1
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/LICENSE +21 -0
- package/README.md +124 -0
- package/package.json +95 -0
- package/src/composer/composer.tsx +100 -0
- package/src/composer/context.ts +33 -0
- package/src/composer/controls/button.tsx +104 -0
- package/src/composer/controls/language-menu.tsx +123 -0
- package/src/composer/controls/send.tsx +83 -0
- package/src/composer/controls/think.tsx +50 -0
- package/src/composer/controls/tools.tsx +130 -0
- package/src/composer/controls/tooltip.tsx +10 -0
- package/src/composer/controls/voice.tsx +266 -0
- package/src/composer/field/field.tsx +140 -0
- package/src/composer/field/input.tsx +214 -0
- package/src/composer/field/keys.ts +44 -0
- package/src/composer/field/use-autosize.ts +134 -0
- package/src/composer/focus-field.ts +27 -0
- package/src/composer/index.ts +86 -0
- package/src/composer/stories/field.tsx +112 -0
- package/src/composer/stories/harness.tsx +127 -0
- package/src/composer/tools/menu.tsx +129 -0
- package/src/composer/tools/rows.tsx +180 -0
- package/src/composer/tools/use-menu-nav.ts +66 -0
- package/src/composer/tools/use-recent-tools.ts +30 -0
- package/src/composer/tray/banner.tsx +157 -0
- package/src/composer/tray/command-progress.tsx +116 -0
- package/src/composer/tray/picked-tools.tsx +104 -0
- package/src/composer/tray/queue.tsx +202 -0
- package/src/composer/tray/use-replace-text.ts +58 -0
- package/src/composer/use-draft-persistence.ts +79 -0
- package/src/content/index.ts +16 -0
- package/src/content/media-content.tsx +38 -0
- package/src/content/response.tsx +221 -0
- package/src/core/adapter.ts +29 -0
- package/src/core/capabilities.ts +286 -0
- package/src/core/chunks.ts +66 -0
- package/src/core/content/incomplete-math.ts +118 -0
- package/src/core/content/index.ts +9 -0
- package/src/core/draft-storage.ts +62 -0
- package/src/core/events.ts +88 -0
- package/src/core/focus/index.ts +10 -0
- package/src/core/focus/roving.ts +56 -0
- package/src/core/group-parts.ts +80 -0
- package/src/core/index.ts +34 -0
- package/src/core/links/index.ts +33 -0
- package/src/core/links/prose.ts +191 -0
- package/src/core/links/scheme.ts +98 -0
- package/src/core/links/tool-id.ts +26 -0
- package/src/core/media/allowed-src.ts +138 -0
- package/src/core/media/block.ts +254 -0
- package/src/core/media/index.ts +22 -0
- package/src/core/media/kinds.ts +85 -0
- package/src/core/outbound.ts +86 -0
- package/src/core/parts.ts +169 -0
- package/src/core/queue/index.ts +15 -0
- package/src/core/queue/queue.ts +100 -0
- package/src/core/recall/boundary.ts +77 -0
- package/src/core/recall/from-transcript.ts +47 -0
- package/src/core/recall/index.ts +23 -0
- package/src/core/recall/ladder.ts +171 -0
- package/src/core/reload-decision.ts +36 -0
- package/src/core/scroll/index.ts +17 -0
- package/src/core/scroll/missed.ts +106 -0
- package/src/core/scroll/sent.ts +54 -0
- package/src/core/text-replace.ts +20 -0
- package/src/core/tool-name.ts +31 -0
- package/src/core/tool-picks-set.ts +48 -0
- package/src/core/tool-picks.ts +142 -0
- package/src/core/tool-prompt.ts +47 -0
- package/src/core/tools/capability.ts +34 -0
- package/src/core/tools/catalogue.ts +109 -0
- package/src/core/tools/index.ts +17 -0
- package/src/core/tools/layout.ts +44 -0
- package/src/core/tools/nav.ts +32 -0
- package/src/core/tools/rank.ts +60 -0
- package/src/core/tools/recent.ts +39 -0
- package/src/core/transcript.ts +301 -0
- package/src/core/voice/earcons.ts +58 -0
- package/src/core/voice/index.ts +26 -0
- package/src/core/voice/language-search.ts +62 -0
- package/src/core/voice/languages.ts +267 -0
- package/src/core/voice/resolve-language.ts +93 -0
- package/src/core/voice/session.ts +196 -0
- package/src/core/voice/web-speech.ts +170 -0
- package/src/i18n/index.ts +87 -0
- package/src/i18n/locales/ar.ts +97 -0
- package/src/i18n/locales/da.ts +90 -0
- package/src/i18n/locales/de.ts +90 -0
- package/src/i18n/locales/en.ts +90 -0
- package/src/i18n/locales/es.ts +90 -0
- package/src/i18n/locales/fr.ts +90 -0
- package/src/i18n/locales/it.ts +90 -0
- package/src/i18n/locales/ja.ts +83 -0
- package/src/i18n/locales/ko.ts +83 -0
- package/src/i18n/locales/nl.ts +90 -0
- package/src/i18n/locales/no.ts +90 -0
- package/src/i18n/locales/pl.ts +96 -0
- package/src/i18n/locales/ptBR.ts +90 -0
- package/src/i18n/locales/ru.ts +96 -0
- package/src/i18n/locales/sv.ts +90 -0
- package/src/i18n/locales/tr.ts +89 -0
- package/src/i18n/locales/zh.ts +83 -0
- package/src/i18n/plural.ts +62 -0
- package/src/i18n/types.ts +257 -0
- package/src/index.ts +18 -0
- package/src/primitives/index.ts +43 -0
- package/src/primitives/message.tsx +85 -0
- package/src/primitives/part-registry.tsx +44 -0
- package/src/primitives/stories-harness.tsx +118 -0
- package/src/primitives/suggestions.tsx +112 -0
- package/src/primitives/tool-call.tsx +145 -0
- package/src/primitives/tooltip.tsx +48 -0
- package/src/primitives/transcript.tsx +285 -0
- package/src/primitives/use-dismiss.ts +100 -0
- package/src/primitives/use-missed-count.ts +30 -0
- package/src/primitives/use-overflows.ts +69 -0
- package/src/primitives/use-return-on-send.ts +29 -0
- package/src/primitives/use-roving-focus.ts +69 -0
- package/src/primitives/use-select-all-transcript.ts +111 -0
- package/src/primitives/use-stick-to-bottom.ts +83 -0
- package/src/primitives/use-turn-activity.ts +25 -0
- package/src/store/context.ts +28 -0
- package/src/store/hooks.ts +215 -0
- package/src/store/index.ts +31 -0
- package/src/store/provider.tsx +97 -0
- package/src/store/store.ts +394 -0
- package/src/store/subscribers.ts +55 -0
- package/src/store/surfaces.ts +174 -0
- package/src/store/use-draft.ts +168 -0
- package/src/store/use-queue.ts +61 -0
- package/src/store/use-recall.ts +264 -0
- package/src/store/use-reload-on-revision.ts +67 -0
- package/src/store/use-speech-language.ts +84 -0
- package/src/store/use-voice.ts +116 -0
- package/src/styles/block.css +128 -0
- package/src/styles/composer.css +449 -0
- package/src/styles/index.css +47 -0
- package/src/styles/markdown.css +368 -0
- package/src/styles/surface.css +102 -0
- package/src/styles/tool.css +128 -0
- package/src/styles/turn-mark.css +97 -0
- package/src/testing/index.ts +15 -0
- package/src/testing/mock-adapter.ts +69 -0
- package/src/testing/mock-tools.ts +101 -0
- package/src/testing/mock-voice.ts +81 -0
- package/src/ui/chat-composer.tsx +286 -0
- package/src/ui/chat-reply-chip.tsx +42 -0
- package/src/ui/chat-suggestions.tsx +87 -0
- package/src/ui/chat-transcript.tsx +157 -0
- package/src/ui/chat.tsx +197 -0
- package/src/ui/collapsible-body.tsx +180 -0
- package/src/ui/collapsible-row.tsx +81 -0
- package/src/ui/day-divider.tsx +49 -0
- package/src/ui/index.ts +54 -0
- package/src/ui/jump-to-latest.tsx +60 -0
- package/src/ui/link-chip.tsx +73 -0
- package/src/ui/message-actions.tsx +163 -0
- package/src/ui/message-quote.tsx +47 -0
- package/src/ui/message.tsx +295 -0
- package/src/ui/pending.tsx +85 -0
- package/src/ui/renderers/data.tsx +63 -0
- package/src/ui/renderers/reasoning.tsx +73 -0
- package/src/ui/renderers/tool.tsx +176 -0
- package/src/ui/turn-mark.tsx +31 -0
- package/src/utils/day-heading.ts +46 -0
- package/src/utils/error-message.ts +10 -0
- package/src/utils/index.ts +12 -0
- package/src/utils/markdown-text.ts +175 -0
- package/src/widgets/index.ts +14 -0
- package/src/widgets/media.tsx +239 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
VoiceSession,
|
|
5
|
+
createWebSpeechCapability,
|
|
6
|
+
type PlayEarcon,
|
|
7
|
+
type VoiceCapability,
|
|
8
|
+
type VoiceSnapshot,
|
|
9
|
+
} from "../core";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* React binding for `VoiceSession`.
|
|
13
|
+
*
|
|
14
|
+
* Deliberately thin, exactly like `useMentions`. Everything with a race in it —
|
|
15
|
+
* a result arriving after stop, an engine that ends on its own, the interim that
|
|
16
|
+
* must replace rather than append — lives in `core/voice`, tested without a DOM.
|
|
17
|
+
* This file only subscribes.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
export interface UseVoiceResult extends VoiceSnapshot {
|
|
21
|
+
/** Whether a microphone exists at all. `false` ⇒ render no control. */
|
|
22
|
+
readonly available: boolean;
|
|
23
|
+
readonly start: () => void;
|
|
24
|
+
readonly stop: () => void;
|
|
25
|
+
readonly cancel: () => void;
|
|
26
|
+
/** Take the dictated text and reset, for handing to the draft. */
|
|
27
|
+
readonly take: () => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const IDLE: VoiceSnapshot = {
|
|
31
|
+
recording: false,
|
|
32
|
+
committed: "",
|
|
33
|
+
interim: "",
|
|
34
|
+
text: "",
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export function useVoice(
|
|
38
|
+
capability?: VoiceCapability | false,
|
|
39
|
+
language = "en-US",
|
|
40
|
+
playEarcon?: PlayEarcon,
|
|
41
|
+
): UseVoiceResult {
|
|
42
|
+
const [snapshot, setSnapshot] = useState(IDLE);
|
|
43
|
+
|
|
44
|
+
// Read through a ref for the same reason the capability is: a host passes an
|
|
45
|
+
// inline arrow at every realistic call site, so keying the session on it would
|
|
46
|
+
// rebuild the session mid-sentence and drop the words.
|
|
47
|
+
const currentEarcon = useRef(playEarcon);
|
|
48
|
+
currentEarcon.current = playEarcon;
|
|
49
|
+
|
|
50
|
+
// The language, readable at START without being a dependency. The default
|
|
51
|
+
// engine reads it through this function, so changing the language applies to
|
|
52
|
+
// the NEXT dictation instead of rebuilding the session mid-sentence.
|
|
53
|
+
const currentLanguage = useRef(language);
|
|
54
|
+
currentLanguage.current = language;
|
|
55
|
+
|
|
56
|
+
// The package's own engine, built at most once. `undefined` on a browser with
|
|
57
|
+
// no Web Speech, which is what makes "no engine ⇒ no button" hold.
|
|
58
|
+
const fallback = useMemo(
|
|
59
|
+
() => createWebSpeechCapability({ language: () => currentLanguage.current }),
|
|
60
|
+
[],
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Absent capability falls back to the browser rather than to nothing — the
|
|
64
|
+
// one capability where absent does not mean absent. A host that supplies one
|
|
65
|
+
// overrides the default outright, and `false` refuses the fallback too, which
|
|
66
|
+
// is the only way to say "no dictation" when absence already means "default".
|
|
67
|
+
const effective = capability === false ? undefined : (capability ?? fallback);
|
|
68
|
+
|
|
69
|
+
// Same reason as `useMentions`: the capability is an object literal at every
|
|
70
|
+
// realistic call site, so it is a new reference on every parent render.
|
|
71
|
+
// Keying the session on it would rebuild it mid-sentence and drop the words.
|
|
72
|
+
const latest = useRef(effective);
|
|
73
|
+
latest.current = effective;
|
|
74
|
+
|
|
75
|
+
const session = useMemo(() => {
|
|
76
|
+
if (effective === undefined) return null;
|
|
77
|
+
return new VoiceSession({
|
|
78
|
+
// Read through the ref so a re-created capability object still routes to
|
|
79
|
+
// the host's CURRENT engine without rebuilding the session.
|
|
80
|
+
capability: {
|
|
81
|
+
start: (onText, signal) => latest.current!.start(onText, signal),
|
|
82
|
+
stop: () => latest.current!.stop(),
|
|
83
|
+
},
|
|
84
|
+
onChange: setSnapshot,
|
|
85
|
+
// Through the ref, so a host that rebuilds the callback each render still
|
|
86
|
+
// reaches its CURRENT player without the session being rebuilt.
|
|
87
|
+
playEarcon: (kind) => currentEarcon.current?.(kind),
|
|
88
|
+
});
|
|
89
|
+
// Presence alone rebuilds it. A `VoiceCapability` has no behavioural
|
|
90
|
+
// primitives to key on — both members are functions, and functions are read
|
|
91
|
+
// through the ref — so this is the whole dependency. Keyed on the EFFECTIVE
|
|
92
|
+
// engine: a host capability arriving late must replace the default, and
|
|
93
|
+
// keying on `capability` alone would leave the browser engine in place.
|
|
94
|
+
}, [effective === undefined]);
|
|
95
|
+
|
|
96
|
+
useEffect(() => () => session?.dispose(), [session]);
|
|
97
|
+
|
|
98
|
+
// The ACTIONS memo on `session` alone, never on `snapshot` — the same defect
|
|
99
|
+
// `use-mentions.ts` guards against. A caller may put `stop` in an effect's
|
|
100
|
+
// dependency array (stopping dictation when a turn is sent is exactly that
|
|
101
|
+
// shape); rebuilt on every snapshot, that effect would fire on each interim
|
|
102
|
+
// result and re-run forever. Nothing about an action depends on the snapshot:
|
|
103
|
+
// recording does not change what `cancel` does.
|
|
104
|
+
const actions = useMemo(
|
|
105
|
+
() => ({
|
|
106
|
+
available: session !== null,
|
|
107
|
+
start: () => session?.start(),
|
|
108
|
+
stop: () => session?.stop(),
|
|
109
|
+
cancel: () => session?.cancel(),
|
|
110
|
+
take: () => session?.take() ?? "",
|
|
111
|
+
}),
|
|
112
|
+
[session],
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
return useMemo(() => ({ ...snapshot, ...actions }), [snapshot, actions]);
|
|
116
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The rhythm between the block-level parts of one turn.
|
|
3
|
+
*
|
|
4
|
+
* A message stacks its parts at a flat 8px, which is right between two
|
|
5
|
+
* paragraphs and too tight around a BOX: a tool call, an enrichment card, a
|
|
6
|
+
* picture. At the same gap as prose those boxes touch each other and the
|
|
7
|
+
* sentence that introduced them, and several in a row read as one striped
|
|
8
|
+
* slab rather than as separate things.
|
|
9
|
+
*
|
|
10
|
+
* Here rather than in a class list because the condition is structural: the
|
|
11
|
+
* spacing depends on what the PREVIOUS sibling is, which no class on the
|
|
12
|
+
* element itself can express. The transcript's `gap` cannot express it either —
|
|
13
|
+
* a gap is uniform by definition, and these parts are not.
|
|
14
|
+
*
|
|
15
|
+
* `.chat-tool` and `.chat-block` share every rule. They stay two selectors
|
|
16
|
+
* because they are two markup contracts (a tool call is rendered by `tool.tsx`,
|
|
17
|
+
* a card and a picture by `data.tsx` and `media.tsx`), and collapsing them into
|
|
18
|
+
* one class would mean one renderer silently deciding the other's shape.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
/* A box sits closer to the sentence that introduced it than to the next one, so
|
|
22
|
+
* it belongs to the text above rather than floating between the two. */
|
|
23
|
+
.chat-tool,
|
|
24
|
+
.chat-block {
|
|
25
|
+
margin-block-start: 0.875rem;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/* Between two boxes, less: they are peers in one sequence, and at the larger
|
|
29
|
+
* gap a run of them stops reading as a list. Four bordered boxes at the flat
|
|
30
|
+
* 4px read as one striped block, which is the shape this pair of rules exists
|
|
31
|
+
* to prevent — it is spacing WITHIN a message, never the transcript's gap
|
|
32
|
+
* between messages. */
|
|
33
|
+
.chat-tool + .chat-tool,
|
|
34
|
+
.chat-block + .chat-block,
|
|
35
|
+
.chat-tool + .chat-block,
|
|
36
|
+
.chat-block + .chat-tool {
|
|
37
|
+
margin-block-start: 0.5rem;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/* The first part of a turn has no neighbour to separate from, and the margin
|
|
41
|
+
* would push it away from the turn's own top edge. `:first-child` alone is not
|
|
42
|
+
* enough: a reply commonly opens with a picture the assistant produced, which
|
|
43
|
+
* is the first RENDERED part while the transcript still gives the row its own
|
|
44
|
+
* spacing. */
|
|
45
|
+
.chat-tool:first-child,
|
|
46
|
+
.chat-block:first-child {
|
|
47
|
+
margin-block-start: 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* A disclosure's own marker, replaced by an icon.
|
|
52
|
+
*
|
|
53
|
+
* The native `disclosure-closed` marker renders as a `▶` whose size and colour
|
|
54
|
+
* the page cannot set — it tracks the platform, not the transcript's type
|
|
55
|
+
* scale, and stands out against every other chevron in the app. Removing it
|
|
56
|
+
* needs BOTH rules: `display: list-item` is what draws it, and Safari keeps
|
|
57
|
+
* drawing it until `::-webkit-details-marker` is hidden too.
|
|
58
|
+
*/
|
|
59
|
+
.chat-disclosure > summary {
|
|
60
|
+
display: flex;
|
|
61
|
+
list-style: none;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.chat-disclosure > summary::-webkit-details-marker {
|
|
65
|
+
display: none;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* Rotated by the element's own state, so nothing has to mirror `open` in JS.
|
|
69
|
+
*
|
|
70
|
+
* The class goes on a SPAN wrapping the icon, never on the `<svg>`: a computed
|
|
71
|
+
* transform does not take on that node here, so rotating it silently does
|
|
72
|
+
* nothing while every rule involved still matches. */
|
|
73
|
+
.chat-disclosure-chevron {
|
|
74
|
+
display: inline-flex;
|
|
75
|
+
flex-shrink: 0;
|
|
76
|
+
transition: transform 150ms ease;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
.chat-disclosure[open] > summary .chat-disclosure-chevron {
|
|
80
|
+
transform: rotate(90deg);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
@media (prefers-reduced-motion: reduce) {
|
|
84
|
+
.chat-disclosure-chevron {
|
|
85
|
+
transition: none;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* An OPEN disclosure is a box; a closed one is a line.
|
|
91
|
+
*
|
|
92
|
+
* Only `[open]` carries the frame, and that asymmetry is the decision: closed,
|
|
93
|
+
* the summary is one muted line among the turn's other chrome, and framing it
|
|
94
|
+
* would draw a border around every collapsed reply. Open, its body is prose of
|
|
95
|
+
* the same colour and size as the answer below, so without an edge the reader
|
|
96
|
+
* cannot tell where the thinking stops and the reply starts.
|
|
97
|
+
*
|
|
98
|
+
* `--border` and `--radius`, not literals: the same pair every other box in the
|
|
99
|
+
* app uses, so this tracks the theme instead of pinning one palette.
|
|
100
|
+
*
|
|
101
|
+
* The inline padding is mirrored by a negative margin, so the frame grows
|
|
102
|
+
* OUTWARD from the text. Padding alone would indent the summary relative to the
|
|
103
|
+
* lines above it and the reply below, which is the one thing a frame here must
|
|
104
|
+
* not do — those three all start at the same left edge.
|
|
105
|
+
*/
|
|
106
|
+
.chat-disclosure[open] {
|
|
107
|
+
border: 1px solid var(--border);
|
|
108
|
+
border-radius: var(--radius, 0.625rem);
|
|
109
|
+
padding: 0.5rem 0.625rem;
|
|
110
|
+
margin-inline: -0.625rem;
|
|
111
|
+
/* The same edge colour as the composer and every card in the app, not a
|
|
112
|
+
* louder one: this frames the model's thinking, which must stay quieter than
|
|
113
|
+
* the answer it sits above. */
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* The frame's bottom edge would otherwise touch the reply, putting a border
|
|
117
|
+
* against the first line of the answer. Only when open, because a closed
|
|
118
|
+
* summary is a plain line the turn already spaces. */
|
|
119
|
+
.chat-disclosure[open] {
|
|
120
|
+
margin-block-end: 0.75rem;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/* Inside the box the summary is a heading over its body, so it needs the gap
|
|
124
|
+
* the unframed version got from the surrounding flow. `> summary + *` rather
|
|
125
|
+
* than a class on the body: the body is whatever the renderer put there. */
|
|
126
|
+
.chat-disclosure[open] > summary + * {
|
|
127
|
+
margin-block-start: 0.375rem;
|
|
128
|
+
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The composer's structural rules.
|
|
3
|
+
*
|
|
4
|
+
* Here rather than as Tailwind variants because each needs a selector a class
|
|
5
|
+
* list cannot express: `:has()` looking DOWN at a descendant and acting on a
|
|
6
|
+
* different child, and "every button except one" on the queued row. Written as
|
|
7
|
+
* variants these become strings no reviewer can read a mistake out of.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A composer whose draft has outgrown one line gives the field the whole row,
|
|
12
|
+
* and the controls take their own line beneath it.
|
|
13
|
+
*
|
|
14
|
+
* A STAGED TOOL does the same, unconditionally: a chip sits between `+` and the
|
|
15
|
+
* text, so the field starts mid-row and the little that is left is squeezed
|
|
16
|
+
* against the send button. Two independent reasons to expand, one layout — the
|
|
17
|
+
* text no longer fits BESIDE things, whether because of its own length or
|
|
18
|
+
* because of what shares its line.
|
|
19
|
+
*
|
|
20
|
+
* `:has()` is load-bearing twice over. The condition lives on a DESCENDANT —
|
|
21
|
+
* the textarea reports its own rendered height as `data-multiline`, which is
|
|
22
|
+
* the only element that can know it — and the effect lands on a DIFFERENT
|
|
23
|
+
* child, the field's wrapper. Descendant-to-sibling is the one direction a
|
|
24
|
+
* selector can look, and this is why the rule cannot be a Tailwind variant.
|
|
25
|
+
*
|
|
26
|
+
* `flex-basis`, never `width`: the wrapper is a flex item, so the basis is what
|
|
27
|
+
* the algorithm distributes from and a `width` is ignored outright. Measured —
|
|
28
|
+
* with `width: 100%` the rule matched, loaded, and changed nothing.
|
|
29
|
+
*/
|
|
30
|
+
.composer-box:has(textarea[data-multiline]) > *:has(> textarea),
|
|
31
|
+
.composer-box:has(.tool-chips > *) > *:has(> textarea) {
|
|
32
|
+
flex-basis: 100%;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A wrapped draft sends the `+` DOWN with the trailing controls, not up.
|
|
37
|
+
*
|
|
38
|
+
* Ordering alone cannot do it: `+` leads the shared line, so it also leads the
|
|
39
|
+
* wrap and lands on a line of its own ABOVE the text — measured as three rows,
|
|
40
|
+
* `+` over the draft and the other controls under it. Raising its order past
|
|
41
|
+
* the field's only while the field owns the row puts both halves on the line
|
|
42
|
+
* beneath, `+` still at their left edge.
|
|
43
|
+
*/
|
|
44
|
+
.composer-box:has(textarea[data-multiline]) > .composer-lead,
|
|
45
|
+
.composer-box:has(.tool-chips > *) > .composer-lead {
|
|
46
|
+
order: 3;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* The `+` sits `inset-y` from the left edge, matching the send button's gap to
|
|
50
|
+
* the right and the bottom. The box's horizontal inset is breathing room for
|
|
51
|
+
* the TEXT and is wider, so the difference is given back here — the same
|
|
52
|
+
* correction `.composer-controls` makes at the other end. */
|
|
53
|
+
.composer-lead {
|
|
54
|
+
margin-inline-start: calc(var(--chat-composer-inset-y) - var(--chat-composer-inset-x));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The control row reaches past the box's TEXT inset, at both ends.
|
|
59
|
+
*
|
|
60
|
+
* On the row rather than on the box: the field above it must still start flush
|
|
61
|
+
* with the text, and a box-level inset would move both.
|
|
62
|
+
*/
|
|
63
|
+
.composer-controls {
|
|
64
|
+
/* The row's own gap to the edge must EQUAL the vertical one, and the box's
|
|
65
|
+
* horizontal inset is wider — that width is breathing room for the TEXT, not
|
|
66
|
+
* for a control. Given back at BOTH ends, so the leading `+` and the trailing
|
|
67
|
+
* send button each sit the same distance from their side as from the bottom.
|
|
68
|
+
*
|
|
69
|
+
* `width: auto` is what makes the negative margin WIDEN the row. The class
|
|
70
|
+
* list's `w-full` pins it to 100% of the content box, so the margin only slid
|
|
71
|
+
* it left and the send button kept the full text inset on the right — a gap
|
|
72
|
+
* measured at 21px against the intended 9px. */
|
|
73
|
+
width: auto;
|
|
74
|
+
margin-inline: calc(var(--chat-composer-inset-y) - var(--chat-composer-inset-x));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* A staged tool: a capsule, matching the controls it sits between.
|
|
79
|
+
*
|
|
80
|
+
* `--radius-full`, not a rounded rectangle. The `+` to its left is a circle and
|
|
81
|
+
* the send button to its right is a capsule, so a 4px corner reads as a control
|
|
82
|
+
* from a different set. No border for the same reason — neither neighbour has
|
|
83
|
+
* one, and an outline around a soft ground draws a box the row does not need.
|
|
84
|
+
*/
|
|
85
|
+
.tool-chip {
|
|
86
|
+
border-radius: var(--radius-full, 9999px);
|
|
87
|
+
padding-inline: 0.5rem;
|
|
88
|
+
font-size: 0.75rem;
|
|
89
|
+
background: color-mix(in oklab, var(--muted) 70%, transparent);
|
|
90
|
+
color: var(--muted-foreground);
|
|
91
|
+
transition: background 120ms ease, color 120ms ease;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.tool-chip:hover {
|
|
95
|
+
background: var(--muted);
|
|
96
|
+
color: var(--foreground);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The remove control appears on approach, and holds its column regardless.
|
|
101
|
+
*
|
|
102
|
+
* `opacity`, never `display`: removing it from layout would resize the chip
|
|
103
|
+
* under the pointer, moving the target the user is reaching for. Revealed on
|
|
104
|
+
* `focus-within` too, or it is unreachable without a mouse.
|
|
105
|
+
*/
|
|
106
|
+
.tool-chip > button {
|
|
107
|
+
opacity: 0;
|
|
108
|
+
transition: opacity 120ms ease;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.tool-chip:hover > button,
|
|
112
|
+
.tool-chip:focus-within > button {
|
|
113
|
+
opacity: 0.6;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
.tool-chip > button:hover,
|
|
117
|
+
.tool-chip > button:focus-visible {
|
|
118
|
+
opacity: 1;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The staged row scrolls without a visible scrollbar, fading at each edge.
|
|
123
|
+
*
|
|
124
|
+
* A MASK rather than a gradient overlay: a gradient must name a colour, so it
|
|
125
|
+
* needs one declaration per theme and breaks over any other ground. A mask
|
|
126
|
+
* removes the paint itself, which is correct on every background from one rule.
|
|
127
|
+
*
|
|
128
|
+
* Both edges are declared, but each only bites when there is something to
|
|
129
|
+
* scroll — `animation-timeline: scroll()` drives them from the row's own scroll
|
|
130
|
+
* position, so a row that fits shows no fade at all. Where that timeline is
|
|
131
|
+
* unsupported the row simply scrolls with no fade, which is the pre-existing
|
|
132
|
+
* behaviour and not a regression.
|
|
133
|
+
*/
|
|
134
|
+
/* Registered, or the keyframes below step between values instead of easing:
|
|
135
|
+
an unregistered custom property has no type, so it cannot interpolate. */
|
|
136
|
+
@property --tool-chips-fade-start {
|
|
137
|
+
syntax: "<length>";
|
|
138
|
+
inherits: false;
|
|
139
|
+
initial-value: 0px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
@property --tool-chips-fade-end {
|
|
143
|
+
syntax: "<length>";
|
|
144
|
+
inherits: false;
|
|
145
|
+
initial-value: 0px;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.tool-chips {
|
|
149
|
+
scrollbar-width: none;
|
|
150
|
+
--tool-chips-fade-start: 0px;
|
|
151
|
+
--tool-chips-fade-end: 0px;
|
|
152
|
+
mask-image: linear-gradient(
|
|
153
|
+
to right,
|
|
154
|
+
transparent 0,
|
|
155
|
+
#000 var(--tool-chips-fade-start),
|
|
156
|
+
#000 calc(100% - var(--tool-chips-fade-end)),
|
|
157
|
+
transparent 100%
|
|
158
|
+
);
|
|
159
|
+
animation: tool-chips-fade linear both;
|
|
160
|
+
animation-timeline: scroll(self inline);
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
.tool-chips::-webkit-scrollbar {
|
|
164
|
+
display: none;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Start and end fades, driven by scroll position rather than by a listener.
|
|
169
|
+
*
|
|
170
|
+
* At the very start there is nothing hidden to the left, so only the right edge
|
|
171
|
+
* fades; at the end the reverse. The middle keyframe fades both, which is the
|
|
172
|
+
* only moment content is cut off on each side.
|
|
173
|
+
*/
|
|
174
|
+
@keyframes tool-chips-fade {
|
|
175
|
+
from {
|
|
176
|
+
--tool-chips-fade-start: 0px;
|
|
177
|
+
--tool-chips-fade-end: 1.5rem;
|
|
178
|
+
}
|
|
179
|
+
50% {
|
|
180
|
+
--tool-chips-fade-start: 1.5rem;
|
|
181
|
+
--tool-chips-fade-end: 1.5rem;
|
|
182
|
+
}
|
|
183
|
+
to {
|
|
184
|
+
--tool-chips-fade-start: 1.5rem;
|
|
185
|
+
--tool-chips-fade-end: 0px;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* The field sets its own tracking, because what a host supplies is a CONTROL's.
|
|
191
|
+
*
|
|
192
|
+
* A design system's negative tracking is tuned for labels — a chip, a menu row,
|
|
193
|
+
* something taken in at a glance, where tightening buys density. This field is
|
|
194
|
+
* prose: it holds the same sentences the transcript renders, at the transcript's
|
|
195
|
+
* size, so it must match the transcript exactly. A composer and a transcript
|
|
196
|
+
* that disagree about tracking read as two different conversations, and the
|
|
197
|
+
* disagreement is most visible at the moment it matters — the line you typed
|
|
198
|
+
* sitting directly above the reply to it.
|
|
199
|
+
*
|
|
200
|
+
* `normal` is therefore not "no decision"; it is the same decision the reading
|
|
201
|
+
* surface makes, restated here because the host's `--letter-spacing-base` is
|
|
202
|
+
* declared once high up on a control-sized ancestor and would otherwise arrive
|
|
203
|
+
* as fixed pixels computed for a size this field does not use.
|
|
204
|
+
*/
|
|
205
|
+
.composer-box textarea {
|
|
206
|
+
letter-spacing: normal;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The ring OUTSIDE the border — two lines, one box.
|
|
211
|
+
*
|
|
212
|
+
* `box-shadow`, not a second border or an outline: a border would change the
|
|
213
|
+
* box and push every control 2px inward, and an outline cannot be given the
|
|
214
|
+
* card's radius on every engine. A spread-only shadow paints outside the edge
|
|
215
|
+
* and costs no layout, so the field keeps the geometry the buttons align to.
|
|
216
|
+
*
|
|
217
|
+
* The colour is the BORDER's until focus, so the ring reads as depth rather
|
|
218
|
+
* than as a permanent highlight; on focus both lines move to `--ring` together,
|
|
219
|
+
* because two rings in disagreeing colours read as an error state.
|
|
220
|
+
*
|
|
221
|
+
* The radius is DERIVED, never chosen. Two rounded shapes look parallel only
|
|
222
|
+
* when the outer radius equals the inner one plus the gap between them —
|
|
223
|
+
* otherwise the corners converge or splay while the straight edges stay even,
|
|
224
|
+
* which reads as sloppiness without naming its cause.
|
|
225
|
+
*
|
|
226
|
+
* The controls are circles (`rounded-full`), so their radius is half their
|
|
227
|
+
* height, and the gap the corner has to clear is the vertical inset. That sum
|
|
228
|
+
* comes out at exactly half the composer's own height — a capsule. Which is the
|
|
229
|
+
* point: a capsule is what concentric-with-a-circle MEANS, not a style choice,
|
|
230
|
+
* and it stays correct if the height changes for any reason.
|
|
231
|
+
*
|
|
232
|
+
* The single-line height is what this must follow, never the box's own height:
|
|
233
|
+
* a multi-line draft grows the box, and a capsule at that height would balloon
|
|
234
|
+
* into an oval while the buttons stayed the same circles.
|
|
235
|
+
*/
|
|
236
|
+
.composer-box {
|
|
237
|
+
/* Two axes, because they answer to different things. The vertical inset is
|
|
238
|
+
* structural — it plus the 32px control row IS the composer's height. The
|
|
239
|
+
* horizontal one is breathing room, free to be wider, and it alone decides
|
|
240
|
+
* the corner: a rounded rectangle turns along its SIDES, so the horizontal
|
|
241
|
+
* inset is the distance a corner arc has to clear. */
|
|
242
|
+
--chat-composer-inset-y: 0.5rem;
|
|
243
|
+
--chat-composer-inset-x: 0.875rem;
|
|
244
|
+
--chat-composer-control-h: calc(var(--chat-text, 1rem) * var(--chat-leading, 1.65) + 8px);
|
|
245
|
+
/* CONCENTRIC with the controls: their radius plus the inset that clears them.
|
|
246
|
+
* Derived rather than a second token, so a control radius change moves both
|
|
247
|
+
* arcs together and the corners cannot stop running parallel. */
|
|
248
|
+
border-radius: calc(var(--radius-control, 0.625rem) + var(--chat-composer-inset-y));
|
|
249
|
+
/* The horizontal inset is the TEXT's breathing room and is deliberately wider
|
|
250
|
+
* than the vertical one. The control row gives that difference back on both
|
|
251
|
+
* ends (`.composer-controls`), so a corner button keeps an equal gap to the
|
|
252
|
+
* bottom and the side while the field still starts clear of the edge. */
|
|
253
|
+
padding: var(--chat-composer-inset-y) var(--chat-composer-inset-x);
|
|
254
|
+
box-shadow: 0 0 0 2px color-mix(in oklab, var(--border) 55%, transparent);
|
|
255
|
+
transition: box-shadow 120ms ease;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
.composer-box:focus-within {
|
|
259
|
+
box-shadow: 0 0 0 2px color-mix(in oklab, var(--ring) 40%, transparent);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* A queued prompt: its text is the edit control, its icons appear on approach.
|
|
265
|
+
*
|
|
266
|
+
* Written as CSS rather than a class list because the row needs two structural
|
|
267
|
+
* selectors — "every button EXCEPT the text one" and a reveal driven by the
|
|
268
|
+
* row's own hover — and as Tailwind variants that becomes a string no reviewer
|
|
269
|
+
* can read a mistake out of.
|
|
270
|
+
*
|
|
271
|
+
* The text button carries no visible affordance on purpose. It fills the row,
|
|
272
|
+
* so a background or an underline would draw a second box inside a box the
|
|
273
|
+
* queue already frames; the pointer and the tooltip are the invitation.
|
|
274
|
+
*/
|
|
275
|
+
.queued-row {
|
|
276
|
+
display: flex;
|
|
277
|
+
gap: 0.25rem;
|
|
278
|
+
border-radius: var(--radius-lg, 0.5rem);
|
|
279
|
+
padding-block: 0.375rem;
|
|
280
|
+
padding-inline: 0.625rem 0.25rem;
|
|
281
|
+
font-size: 0.875rem;
|
|
282
|
+
line-height: 1.25rem;
|
|
283
|
+
background: color-mix(in oklab, var(--muted) 60%, transparent);
|
|
284
|
+
color: var(--muted-foreground);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The prompt text, which IS the edit control.
|
|
289
|
+
*
|
|
290
|
+
* Capped and scrollable rather than truncated: a queued prompt is a message the
|
|
291
|
+
* user has not seen sent, so hiding part of it is the failure the strip exists
|
|
292
|
+
* to prevent. The composer sits at the bottom of the transcript, so an
|
|
293
|
+
* uncapped one would grow upward into it.
|
|
294
|
+
*/
|
|
295
|
+
.queued-row > button[data-action="edit"] {
|
|
296
|
+
min-width: 0;
|
|
297
|
+
flex: 1;
|
|
298
|
+
cursor: text;
|
|
299
|
+
text-align: start;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
.queued-row > button[data-action="edit"] span {
|
|
303
|
+
display: block;
|
|
304
|
+
max-height: 5rem;
|
|
305
|
+
overflow-y: auto;
|
|
306
|
+
white-space: pre-line;
|
|
307
|
+
overflow-wrap: break-word;
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
.queued-row > button:not([data-action="edit"]) {
|
|
311
|
+
display: grid;
|
|
312
|
+
place-items: center;
|
|
313
|
+
block-size: 1.5rem;
|
|
314
|
+
inline-size: 1.5rem;
|
|
315
|
+
flex-shrink: 0;
|
|
316
|
+
border-radius: var(--radius-control, 0.375rem);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
.queued-row > button:not([data-action="edit"]):hover {
|
|
320
|
+
background: var(--muted);
|
|
321
|
+
color: var(--foreground);
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* Aligned to the FIRST line, not to the row.
|
|
326
|
+
*
|
|
327
|
+
* `center` would float the icons into the middle of a wrapped prompt, away from
|
|
328
|
+
* the line they read as belonging to; the prompt is capped and scrolls, so the
|
|
329
|
+
* row's height is not the text's. `flex-start` plus a small negative offset
|
|
330
|
+
* puts the 24px button's centre on the 20px first line, which is right whether
|
|
331
|
+
* the prompt takes one line or ten.
|
|
332
|
+
*/
|
|
333
|
+
.queued-row {
|
|
334
|
+
align-items: flex-start;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Revealed on hover AND on keyboard focus. Without `focus-within` the controls
|
|
339
|
+
* are unreachable without a mouse; `opacity` rather than `display` so the row
|
|
340
|
+
* does not reflow and shift the text under the pointer as they appear.
|
|
341
|
+
*/
|
|
342
|
+
.queued-row > button:not([data-action="edit"]) {
|
|
343
|
+
margin-block-start: -2px;
|
|
344
|
+
opacity: 0;
|
|
345
|
+
transition: opacity 120ms ease;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.queued-row:hover > button:not([data-action="edit"]),
|
|
349
|
+
.queued-row:focus-within > button:not([data-action="edit"]) {
|
|
350
|
+
opacity: 1;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Dictation, while the microphone is open.
|
|
355
|
+
*
|
|
356
|
+
* The BUTTON is the indicator. A separate badge or a bar above the composer
|
|
357
|
+
* would be a second place to look for one fact, and the control the user just
|
|
358
|
+
* pressed is where they are already looking.
|
|
359
|
+
*
|
|
360
|
+
* The GLYPH tumbles, reusing `turn-mark-tumble` — the same motion the reasoning
|
|
361
|
+
* marker uses in the transcript, so one vocabulary covers both places this
|
|
362
|
+
* app says "still going". A pulse was tried first and is the wrong answer
|
|
363
|
+
* for the reason `turn-mark.css` already gives: every spinner on the web says
|
|
364
|
+
* "busy", while rotation has a direction, so a glance separates live from
|
|
365
|
+
* stalled.
|
|
366
|
+
*
|
|
367
|
+
* Only the icon turns, never the button: rotating the hit target would spin its
|
|
368
|
+
* focus ring and its hover plate with it.
|
|
369
|
+
*/
|
|
370
|
+
.composer-mic[data-recording] > svg {
|
|
371
|
+
animation: turn-mark-tumble 1.6s linear infinite;
|
|
372
|
+
/* The glyph's own box, so the rotation is about its centre rather than about
|
|
373
|
+
* a corner of the grid cell. */
|
|
374
|
+
transform-origin: 50% 50%;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/**
|
|
378
|
+
* Recording OUTRANKS hover, and gets an edge of its own.
|
|
379
|
+
*
|
|
380
|
+
* `ComposerButton`'s neutral tone carries `hover:text-foreground`, which ties
|
|
381
|
+
* with `aria-pressed:text-destructive` on specificity — so source order decides
|
|
382
|
+
* and the glyph turns white under the pointer. The pointer is over this button
|
|
383
|
+
* almost by definition: the user just clicked it to start.
|
|
384
|
+
*
|
|
385
|
+
* Here rather than in the button's own tone string, which is shared by four
|
|
386
|
+
* controls across three packages. Red is not a hover state; it says the
|
|
387
|
+
* microphone is open, and that fact does not depend on where the pointer is.
|
|
388
|
+
*
|
|
389
|
+
* The RING bounds the spinning glyph. Recording swaps the mic for a 14px filled
|
|
390
|
+
* square, and a small square turning in open space reads as a loose fragment
|
|
391
|
+
* rather than as a control — the ring is what it turns inside. Static, because
|
|
392
|
+
* the glyph already carries the motion and a second moving element would say
|
|
393
|
+
* the same thing twice.
|
|
394
|
+
*
|
|
395
|
+
* `inset` so it paints inside the button's own box: an outward ring would sit
|
|
396
|
+
* on the neighbouring controls at this spacing, and `box-shadow` keeps it out
|
|
397
|
+
* of layout either way, so the row does not shift when recording starts.
|
|
398
|
+
*/
|
|
399
|
+
.composer-mic[data-recording],
|
|
400
|
+
.composer-mic[data-recording]:hover {
|
|
401
|
+
color: var(--destructive, hsl(0 67% 60%));
|
|
402
|
+
box-shadow: inset 0 0 0 1.5px color-mix(in oklab, var(--destructive, hsl(0 67% 60%)) 45%, transparent);
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
@media (prefers-reduced-motion: reduce) {
|
|
406
|
+
/* Still, but not invisible: recording is the one state here with a privacy
|
|
407
|
+
* cost, and `--destructive` on the glyph carries it once motion is off — the
|
|
408
|
+
* same fallback the turn mark takes. */
|
|
409
|
+
.composer-mic[data-recording] > svg {
|
|
410
|
+
animation: none;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/* The tool sheet's rows.
|
|
415
|
+
*
|
|
416
|
+
* Keyboard and pointer converge on ONE cursor: arrow keys set `data-active`,
|
|
417
|
+
* and hovering a row adopts it, so the sheet never shows two highlights at
|
|
418
|
+
* once. `:hover` is not a second rule here for that reason — the component
|
|
419
|
+
* reports the hover upward and the same attribute paints it.
|
|
420
|
+
*/
|
|
421
|
+
.composer-tool-row[data-active] {
|
|
422
|
+
background: var(--accent);
|
|
423
|
+
color: var(--accent-foreground);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/* The forget control shares the check's column, so a row never grows or
|
|
427
|
+
* reflows when it appears. Hidden until the row is hovered or the control
|
|
428
|
+
* itself is focused: keyboard-only users cannot hover, and a control that Tab
|
|
429
|
+
* can reach but the eye cannot find is worse than an absent one. */
|
|
430
|
+
.composer-tool-forget {
|
|
431
|
+
opacity: 0;
|
|
432
|
+
color: var(--muted-foreground);
|
|
433
|
+
transition: opacity 120ms ease-out;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
.composer-tool-row:hover .composer-tool-forget,
|
|
437
|
+
.composer-tool-forget:focus-visible {
|
|
438
|
+
opacity: 1;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.composer-tool-forget:hover {
|
|
442
|
+
color: var(--foreground);
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
@media (prefers-reduced-motion: reduce) {
|
|
446
|
+
.composer-tool-forget {
|
|
447
|
+
transition: none;
|
|
448
|
+
}
|
|
449
|
+
}
|