@folio-agent/widget 0.4.0 → 0.5.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/README.md CHANGED
@@ -18,6 +18,6 @@ npm install @folio-agent/widget
18
18
  </script>
19
19
  ```
20
20
 
21
- 配色・フォントは CSS カスタムプロパティ6トークン(`--folio-agent-surface` / `text` / `muted` / `accent` / `accent-contrast` / `font`)で上書きできる。未指定時は既定デザインのまま動く。
21
+ 配色・フォントは CSS カスタムプロパティ6トークン(`--folio-agent-surface` / `text` / `muted` / `accent` / `accent-contrast` / `font`)で上書きできる。未指定時はホストの配色に合わせる。変える場合も `surface` / `text` / `accent` の3色で足り、残りはこの3色から導出する。
22
22
 
23
- UI 文言は `lang="en"` 属性で英語に切り替えられる(未指定は日本語)。
23
+ UI 文言は `lang="en"` 属性で英語に切り替えられる(未指定は日本語)。パネルの見出し・案内文・質問候補は `heading` / `greeting` / `suggestions`(`|` 区切り)属性で差し替えられる。
package/dist/styles.d.ts CHANGED
@@ -1 +1 @@
1
- export declare const WIDGET_STYLES = "\n :host {\n all: initial;\n color: inherit;\n color-scheme: inherit;\n font-family: var(--folio-agent-font, inherit);\n }\n .toggle {\n position: fixed;\n right: 20px;\n bottom: 20px;\n width: 56px;\n height: 56px;\n border-radius: 50%;\n border: none;\n background: var(--folio-agent-accent, CanvasText);\n color: var(--folio-agent-accent-contrast, Canvas);\n font-size: 24px;\n cursor: pointer;\n box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);\n z-index: 2147483000;\n }\n .panel {\n position: fixed;\n right: 20px;\n bottom: 88px;\n width: 320px;\n max-height: 420px;\n display: flex;\n flex-direction: column;\n background: var(--folio-agent-surface, Canvas);\n border-radius: 12px;\n box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);\n overflow: hidden;\n z-index: 2147483000;\n }\n .panel[hidden] {\n display: none;\n }\n .messages {\n flex: 1;\n overflow-y: auto;\n padding: 16px;\n display: flex;\n flex-direction: column;\n gap: 10px;\n font-size: 14px;\n line-height: 1.6;\n color: var(--folio-agent-text, CanvasText);\n }\n .message {\n white-space: pre-wrap;\n }\n .message.assistant {\n align-self: flex-start;\n background: color-mix(in srgb, var(--folio-agent-text, CanvasText) 8%, var(--folio-agent-surface, Canvas));\n border: 1px solid color-mix(in srgb, var(--folio-agent-text, CanvasText) 16%, var(--folio-agent-surface, Canvas));\n border-radius: 8px;\n padding: 10px 14px;\n }\n .message.user {\n align-self: flex-end;\n background: color-mix(in srgb, var(--folio-agent-text, CanvasText) 14%, var(--folio-agent-surface, Canvas));\n border: 1px solid color-mix(in srgb, var(--folio-agent-text, CanvasText) 24%, var(--folio-agent-surface, Canvas));\n border-radius: 8px;\n padding: 10px 14px;\n }\n .disclosure {\n font-size: 12px;\n color: var(--folio-agent-muted, color-mix(in srgb, CanvasText 55%, Canvas));\n padding: 0 0 4px;\n }\n .disclosure a {\n color: #2563eb;\n }\n form {\n display: flex;\n border-top: 1px solid color-mix(in srgb, CanvasText 16%, Canvas);\n }\n input {\n flex: 1;\n border: none;\n background: transparent;\n color: inherit;\n padding: 10px;\n font-size: 14px;\n }\n input:focus {\n outline: none;\n }\n button[type=\"submit\"] {\n border: none;\n background: none;\n padding: 0 14px;\n cursor: pointer;\n color: var(--folio-agent-accent, CanvasText);\n font-weight: 600;\n }\n";
1
+ export declare const WIDGET_STYLES = "\n :host {\n all: initial;\n color: inherit;\n color-scheme: inherit;\n font-family: var(--folio-agent-font, inherit);\n\n --_surface: var(--folio-agent-surface, Canvas);\n --_text: var(--folio-agent-text, CanvasText);\n --_accent: var(--folio-agent-accent, var(--_text));\n --_accent-contrast: var(--folio-agent-accent-contrast, var(--_surface));\n --_muted: var(--folio-agent-muted, color-mix(in srgb, var(--_text) 62%, var(--_surface)));\n --_hair: color-mix(in srgb, var(--_text) 12%, transparent);\n --_glass: color-mix(in srgb, var(--_surface) 76%, transparent);\n --_glass-strong: color-mix(in srgb, var(--_surface) 94%, transparent);\n --_field: color-mix(in srgb, var(--_text) 5%, color-mix(in srgb, var(--_surface) 80%, transparent));\n --_chip: color-mix(in srgb, var(--_surface) 60%, transparent);\n --_focus: color-mix(in srgb, var(--_accent) 70%, var(--_text));\n }\n * {\n box-sizing: border-box;\n }\n button,\n textarea {\n font: inherit;\n }\n svg {\n display: block;\n }\n\n .toggle {\n position: fixed;\n right: 20px;\n bottom: calc(20px + env(safe-area-inset-bottom, 0px));\n z-index: 2147483000;\n display: inline-flex;\n align-items: center;\n gap: 8px;\n height: 46px;\n padding: 0 18px 0 14px;\n border-radius: 999px;\n border: 1px solid color-mix(in srgb, var(--_accent-contrast) 14%, transparent);\n background: color-mix(in srgb, var(--_accent) 88%, transparent);\n -webkit-backdrop-filter: blur(16px) saturate(1.6);\n backdrop-filter: blur(16px) saturate(1.6);\n color: var(--_accent-contrast);\n font-size: 14px;\n font-weight: 600;\n letter-spacing: 0.02em;\n cursor: pointer;\n box-shadow: 0 10px 30px rgba(16, 20, 28, 0.22);\n transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.2s ease;\n }\n .toggle:hover {\n transform: translateY(-1px);\n }\n .toggle:active {\n transform: scale(0.97);\n }\n .toggle svg {\n width: 20px;\n height: 20px;\n flex: none;\n }\n .root.open .toggle {\n opacity: 0;\n transform: scale(0.85);\n pointer-events: none;\n }\n\n .panel {\n position: fixed;\n right: 20px;\n bottom: calc(20px + env(safe-area-inset-bottom, 0px));\n z-index: 2147483001;\n width: min(400px, calc(100vw - 40px));\n height: min(620px, calc(100dvh - 40px));\n display: flex;\n flex-direction: column;\n border-radius: 22px;\n border: 1px solid var(--_hair);\n background: var(--_glass);\n -webkit-backdrop-filter: blur(28px) saturate(1.8);\n backdrop-filter: blur(28px) saturate(1.8);\n box-shadow: 0 24px 64px rgba(16, 20, 28, 0.2), 0 2px 6px rgba(16, 20, 28, 0.08);\n color: var(--_text);\n overflow: hidden;\n transform-origin: calc(100% - 40px) calc(100% - 20px);\n opacity: 0;\n visibility: hidden;\n transform: translateY(10px) scale(0.9);\n transition:\n opacity 0.2s ease,\n transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1),\n visibility 0s linear 0.32s;\n }\n .root.open .panel {\n opacity: 1;\n visibility: visible;\n transform: none;\n transition:\n opacity 0.22s ease,\n transform 0.38s cubic-bezier(0.2, 0.9, 0.25, 1.05),\n visibility 0s;\n }\n\n .header {\n display: flex;\n align-items: center;\n gap: 12px;\n padding: 14px 12px 12px 18px;\n border-bottom: 1px solid var(--_hair);\n }\n .titles {\n flex: 1;\n min-width: 0;\n }\n .heading {\n font-size: 15px;\n font-weight: 700;\n line-height: 1.3;\n }\n .subheading {\n font-size: 12px;\n line-height: 1.4;\n color: var(--_muted);\n }\n .close {\n flex: none;\n display: grid;\n place-items: center;\n width: 34px;\n height: 34px;\n border: 0;\n border-radius: 50%;\n background: color-mix(in srgb, var(--_text) 7%, transparent);\n color: var(--_text);\n cursor: pointer;\n }\n .close:hover {\n background: color-mix(in srgb, var(--_text) 12%, transparent);\n }\n .close svg {\n width: 16px;\n height: 16px;\n }\n\n .messages {\n flex: 1;\n overflow-y: auto;\n overscroll-behavior: contain;\n padding: 18px 18px 8px;\n display: flex;\n flex-direction: column;\n gap: 14px;\n font-size: 15px;\n line-height: 1.7;\n }\n .message,\n .greeting {\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n animation: fade-in 0.28s ease both;\n }\n .message.assistant,\n .greeting {\n align-self: stretch;\n }\n .message.user {\n align-self: flex-end;\n max-width: 85%;\n padding: 8px 14px;\n border-radius: 18px 18px 6px 18px;\n background: color-mix(in srgb, var(--_accent) 14%, var(--_surface));\n border: 1px solid color-mix(in srgb, var(--_accent) 28%, var(--_surface));\n }\n @keyframes fade-in {\n from { opacity: 0; transform: translateY(4px); }\n to { opacity: 1; transform: none; }\n }\n\n .suggestions {\n display: flex;\n flex-direction: column;\n align-items: flex-start;\n gap: 8px;\n }\n .suggestions button {\n padding: 7px 14px;\n border: 1px solid var(--_hair);\n border-radius: 999px;\n background: var(--_chip);\n color: var(--_text);\n font-size: 14px;\n text-align: left;\n cursor: pointer;\n transition: background 0.15s ease;\n }\n .suggestions button:hover {\n background: color-mix(in srgb, var(--_text) 8%, transparent);\n }\n\n .typing {\n display: inline-flex;\n gap: 5px;\n padding: 8px 0;\n }\n .typing span {\n width: 7px;\n height: 7px;\n border-radius: 50%;\n background: var(--_muted);\n animation: typing-dot 1.1s ease-in-out infinite;\n }\n .typing span:nth-child(2) {\n animation-delay: 0.15s;\n }\n .typing span:nth-child(3) {\n animation-delay: 0.3s;\n }\n @keyframes typing-dot {\n 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }\n 40% { opacity: 1; transform: translateY(-3px); }\n }\n\n form {\n padding: 8px 12px calc(10px + env(safe-area-inset-bottom, 0px));\n }\n .field {\n display: flex;\n align-items: flex-end;\n gap: 8px;\n padding: 6px 6px 6px 14px;\n border: 1px solid var(--_hair);\n border-radius: 22px;\n background: var(--_field);\n transition: border-color 0.15s ease;\n }\n .field:focus-within {\n border-color: color-mix(in srgb, var(--_text) 32%, transparent);\n }\n textarea {\n flex: 1;\n min-width: 0;\n max-height: 144px;\n padding: 6px 0;\n border: 0;\n outline: 0;\n resize: none;\n background: transparent;\n color: var(--_text);\n /* 16px \u672A\u6E80\u3060\u3068 iOS Safari \u304C\u30D5\u30A9\u30FC\u30AB\u30B9\u6642\u306B\u30DA\u30FC\u30B8\u3092\u62E1\u5927\u3059\u308B */\n font-size: 16px;\n line-height: 1.5;\n }\n textarea::placeholder {\n color: var(--_muted);\n }\n .send {\n flex: none;\n display: grid;\n place-items: center;\n width: 34px;\n height: 34px;\n border: 0;\n border-radius: 50%;\n background: var(--_accent);\n color: var(--_accent-contrast);\n cursor: pointer;\n transition: opacity 0.15s ease, transform 0.15s ease;\n }\n .send:disabled {\n opacity: 0.25;\n cursor: default;\n }\n .send:not(:disabled):active {\n transform: scale(0.92);\n }\n .send svg {\n width: 18px;\n height: 18px;\n }\n .disclosure {\n margin: 8px 0 0;\n font-size: 11px;\n line-height: 1.5;\n text-align: center;\n color: var(--_muted);\n }\n .disclosure a {\n color: inherit;\n }\n\n .toggle:focus-visible,\n .close:focus-visible,\n .send:focus-visible,\n .suggestions button:focus-visible {\n outline: 2px solid var(--_focus);\n outline-offset: 2px;\n }\n\n @media (max-width: 640px) {\n .toggle {\n right: 16px;\n bottom: calc(16px + env(safe-area-inset-bottom, 0px));\n }\n /* \u9AD8\u3055\u3068\u4E0A\u7AEF\u306F visualViewport \u304B\u3089 JS \u304C\u6D41\u3057\u8FBC\u3080\u3002\u30AD\u30FC\u30DC\u30FC\u30C9\u8868\u793A\u4E2D\u3082\u5165\u529B\u6B04\u3092\u898B\u3048\u308B\u4F4D\u7F6E\u306B\u4FDD\u3064\u305F\u3081 */\n .panel {\n top: var(--vv-top, 0px);\n right: 0;\n bottom: auto;\n left: 0;\n width: 100%;\n height: var(--vv-height, 100dvh);\n border: 0;\n border-radius: 0;\n background: var(--_glass-strong);\n transform-origin: 50% 100%;\n transform: translateY(32px);\n }\n .header {\n padding-top: calc(12px + env(safe-area-inset-top, 0px));\n }\n }\n\n @media (prefers-reduced-motion: reduce) {\n .toggle,\n .panel,\n .root.open .panel {\n transform: none;\n }\n .panel {\n transition: opacity 0.15s linear, visibility 0s linear 0.15s;\n }\n .root.open .panel {\n transition: opacity 0.15s linear;\n }\n .message,\n .greeting,\n .typing span {\n animation: none;\n }\n }\n";
package/dist/styles.js CHANGED
@@ -1,104 +1,350 @@
1
1
  // テーマ注入: 利用側サイトのグローバルCSSで
2
2
  // folio-agent-widget { --folio-agent-accent: #...; }
3
3
  // のようにカスタムプロパティを指定するとShadow DOM内に継承されテーマが反映される。
4
- // 未指定の場合は各プロパティがホスト配色(CSSシステムカラー)から導出した値を使い、
5
- // サイトのライト/ダークいずれの配色にも自動で馴染む。
4
+ // 決めるのは surface / text / accent の3色で足り、境界線・吹き出し・入力欄などは
5
+ // すべてこの3色から color-mix で導出する。未指定の場合はホスト配色(CSSシステムカラー)
6
+ // から導出し、サイトのライト/ダークいずれの配色にも自動で馴染む。
6
7
  export const WIDGET_STYLES = `
7
8
  :host {
8
9
  all: initial;
9
10
  color: inherit;
10
11
  color-scheme: inherit;
11
12
  font-family: var(--folio-agent-font, inherit);
13
+
14
+ --_surface: var(--folio-agent-surface, Canvas);
15
+ --_text: var(--folio-agent-text, CanvasText);
16
+ --_accent: var(--folio-agent-accent, var(--_text));
17
+ --_accent-contrast: var(--folio-agent-accent-contrast, var(--_surface));
18
+ --_muted: var(--folio-agent-muted, color-mix(in srgb, var(--_text) 62%, var(--_surface)));
19
+ --_hair: color-mix(in srgb, var(--_text) 12%, transparent);
20
+ --_glass: color-mix(in srgb, var(--_surface) 76%, transparent);
21
+ --_glass-strong: color-mix(in srgb, var(--_surface) 94%, transparent);
22
+ --_field: color-mix(in srgb, var(--_text) 5%, color-mix(in srgb, var(--_surface) 80%, transparent));
23
+ --_chip: color-mix(in srgb, var(--_surface) 60%, transparent);
24
+ --_focus: color-mix(in srgb, var(--_accent) 70%, var(--_text));
12
25
  }
26
+ * {
27
+ box-sizing: border-box;
28
+ }
29
+ button,
30
+ textarea {
31
+ font: inherit;
32
+ }
33
+ svg {
34
+ display: block;
35
+ }
36
+
13
37
  .toggle {
14
38
  position: fixed;
15
39
  right: 20px;
16
- bottom: 20px;
17
- width: 56px;
18
- height: 56px;
19
- border-radius: 50%;
20
- border: none;
21
- background: var(--folio-agent-accent, CanvasText);
22
- color: var(--folio-agent-accent-contrast, Canvas);
23
- font-size: 24px;
24
- cursor: pointer;
25
- box-shadow: 0 2px 8px rgba(0, 0, 0, 0.25);
40
+ bottom: calc(20px + env(safe-area-inset-bottom, 0px));
26
41
  z-index: 2147483000;
42
+ display: inline-flex;
43
+ align-items: center;
44
+ gap: 8px;
45
+ height: 46px;
46
+ padding: 0 18px 0 14px;
47
+ border-radius: 999px;
48
+ border: 1px solid color-mix(in srgb, var(--_accent-contrast) 14%, transparent);
49
+ background: color-mix(in srgb, var(--_accent) 88%, transparent);
50
+ -webkit-backdrop-filter: blur(16px) saturate(1.6);
51
+ backdrop-filter: blur(16px) saturate(1.6);
52
+ color: var(--_accent-contrast);
53
+ font-size: 14px;
54
+ font-weight: 600;
55
+ letter-spacing: 0.02em;
56
+ cursor: pointer;
57
+ box-shadow: 0 10px 30px rgba(16, 20, 28, 0.22);
58
+ transition: transform 0.28s cubic-bezier(0.2, 0.8, 0.2, 1), opacity 0.2s ease;
27
59
  }
60
+ .toggle:hover {
61
+ transform: translateY(-1px);
62
+ }
63
+ .toggle:active {
64
+ transform: scale(0.97);
65
+ }
66
+ .toggle svg {
67
+ width: 20px;
68
+ height: 20px;
69
+ flex: none;
70
+ }
71
+ .root.open .toggle {
72
+ opacity: 0;
73
+ transform: scale(0.85);
74
+ pointer-events: none;
75
+ }
76
+
28
77
  .panel {
29
78
  position: fixed;
30
79
  right: 20px;
31
- bottom: 88px;
32
- width: 320px;
33
- max-height: 420px;
80
+ bottom: calc(20px + env(safe-area-inset-bottom, 0px));
81
+ z-index: 2147483001;
82
+ width: min(400px, calc(100vw - 40px));
83
+ height: min(620px, calc(100dvh - 40px));
34
84
  display: flex;
35
85
  flex-direction: column;
36
- background: var(--folio-agent-surface, Canvas);
37
- border-radius: 12px;
38
- box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
86
+ border-radius: 22px;
87
+ border: 1px solid var(--_hair);
88
+ background: var(--_glass);
89
+ -webkit-backdrop-filter: blur(28px) saturate(1.8);
90
+ backdrop-filter: blur(28px) saturate(1.8);
91
+ box-shadow: 0 24px 64px rgba(16, 20, 28, 0.2), 0 2px 6px rgba(16, 20, 28, 0.08);
92
+ color: var(--_text);
39
93
  overflow: hidden;
40
- z-index: 2147483000;
94
+ transform-origin: calc(100% - 40px) calc(100% - 20px);
95
+ opacity: 0;
96
+ visibility: hidden;
97
+ transform: translateY(10px) scale(0.9);
98
+ transition:
99
+ opacity 0.2s ease,
100
+ transform 0.32s cubic-bezier(0.2, 0.8, 0.2, 1),
101
+ visibility 0s linear 0.32s;
102
+ }
103
+ .root.open .panel {
104
+ opacity: 1;
105
+ visibility: visible;
106
+ transform: none;
107
+ transition:
108
+ opacity 0.22s ease,
109
+ transform 0.38s cubic-bezier(0.2, 0.9, 0.25, 1.05),
110
+ visibility 0s;
41
111
  }
42
- .panel[hidden] {
43
- display: none;
112
+
113
+ .header {
114
+ display: flex;
115
+ align-items: center;
116
+ gap: 12px;
117
+ padding: 14px 12px 12px 18px;
118
+ border-bottom: 1px solid var(--_hair);
44
119
  }
120
+ .titles {
121
+ flex: 1;
122
+ min-width: 0;
123
+ }
124
+ .heading {
125
+ font-size: 15px;
126
+ font-weight: 700;
127
+ line-height: 1.3;
128
+ }
129
+ .subheading {
130
+ font-size: 12px;
131
+ line-height: 1.4;
132
+ color: var(--_muted);
133
+ }
134
+ .close {
135
+ flex: none;
136
+ display: grid;
137
+ place-items: center;
138
+ width: 34px;
139
+ height: 34px;
140
+ border: 0;
141
+ border-radius: 50%;
142
+ background: color-mix(in srgb, var(--_text) 7%, transparent);
143
+ color: var(--_text);
144
+ cursor: pointer;
145
+ }
146
+ .close:hover {
147
+ background: color-mix(in srgb, var(--_text) 12%, transparent);
148
+ }
149
+ .close svg {
150
+ width: 16px;
151
+ height: 16px;
152
+ }
153
+
45
154
  .messages {
46
155
  flex: 1;
47
156
  overflow-y: auto;
48
- padding: 16px;
157
+ overscroll-behavior: contain;
158
+ padding: 18px 18px 8px;
49
159
  display: flex;
50
160
  flex-direction: column;
51
- gap: 10px;
52
- font-size: 14px;
53
- line-height: 1.6;
54
- color: var(--folio-agent-text, CanvasText);
161
+ gap: 14px;
162
+ font-size: 15px;
163
+ line-height: 1.7;
55
164
  }
56
- .message {
165
+ .message,
166
+ .greeting {
57
167
  white-space: pre-wrap;
168
+ overflow-wrap: anywhere;
169
+ animation: fade-in 0.28s ease both;
58
170
  }
59
- .message.assistant {
60
- align-self: flex-start;
61
- background: color-mix(in srgb, var(--folio-agent-text, CanvasText) 8%, var(--folio-agent-surface, Canvas));
62
- border: 1px solid color-mix(in srgb, var(--folio-agent-text, CanvasText) 16%, var(--folio-agent-surface, Canvas));
63
- border-radius: 8px;
64
- padding: 10px 14px;
171
+ .message.assistant,
172
+ .greeting {
173
+ align-self: stretch;
65
174
  }
66
175
  .message.user {
67
176
  align-self: flex-end;
68
- background: color-mix(in srgb, var(--folio-agent-text, CanvasText) 14%, var(--folio-agent-surface, Canvas));
69
- border: 1px solid color-mix(in srgb, var(--folio-agent-text, CanvasText) 24%, var(--folio-agent-surface, Canvas));
70
- border-radius: 8px;
71
- padding: 10px 14px;
177
+ max-width: 85%;
178
+ padding: 8px 14px;
179
+ border-radius: 18px 18px 6px 18px;
180
+ background: color-mix(in srgb, var(--_accent) 14%, var(--_surface));
181
+ border: 1px solid color-mix(in srgb, var(--_accent) 28%, var(--_surface));
72
182
  }
73
- .disclosure {
74
- font-size: 12px;
75
- color: var(--folio-agent-muted, color-mix(in srgb, CanvasText 55%, Canvas));
76
- padding: 0 0 4px;
183
+ @keyframes fade-in {
184
+ from { opacity: 0; transform: translateY(4px); }
185
+ to { opacity: 1; transform: none; }
77
186
  }
78
- .disclosure a {
79
- color: #2563eb;
187
+
188
+ .suggestions {
189
+ display: flex;
190
+ flex-direction: column;
191
+ align-items: flex-start;
192
+ gap: 8px;
193
+ }
194
+ .suggestions button {
195
+ padding: 7px 14px;
196
+ border: 1px solid var(--_hair);
197
+ border-radius: 999px;
198
+ background: var(--_chip);
199
+ color: var(--_text);
200
+ font-size: 14px;
201
+ text-align: left;
202
+ cursor: pointer;
203
+ transition: background 0.15s ease;
204
+ }
205
+ .suggestions button:hover {
206
+ background: color-mix(in srgb, var(--_text) 8%, transparent);
80
207
  }
208
+
209
+ .typing {
210
+ display: inline-flex;
211
+ gap: 5px;
212
+ padding: 8px 0;
213
+ }
214
+ .typing span {
215
+ width: 7px;
216
+ height: 7px;
217
+ border-radius: 50%;
218
+ background: var(--_muted);
219
+ animation: typing-dot 1.1s ease-in-out infinite;
220
+ }
221
+ .typing span:nth-child(2) {
222
+ animation-delay: 0.15s;
223
+ }
224
+ .typing span:nth-child(3) {
225
+ animation-delay: 0.3s;
226
+ }
227
+ @keyframes typing-dot {
228
+ 0%, 80%, 100% { opacity: 0.25; transform: translateY(0); }
229
+ 40% { opacity: 1; transform: translateY(-3px); }
230
+ }
231
+
81
232
  form {
233
+ padding: 8px 12px calc(10px + env(safe-area-inset-bottom, 0px));
234
+ }
235
+ .field {
82
236
  display: flex;
83
- border-top: 1px solid color-mix(in srgb, CanvasText 16%, Canvas);
237
+ align-items: flex-end;
238
+ gap: 8px;
239
+ padding: 6px 6px 6px 14px;
240
+ border: 1px solid var(--_hair);
241
+ border-radius: 22px;
242
+ background: var(--_field);
243
+ transition: border-color 0.15s ease;
244
+ }
245
+ .field:focus-within {
246
+ border-color: color-mix(in srgb, var(--_text) 32%, transparent);
84
247
  }
85
- input {
248
+ textarea {
86
249
  flex: 1;
87
- border: none;
250
+ min-width: 0;
251
+ max-height: 144px;
252
+ padding: 6px 0;
253
+ border: 0;
254
+ outline: 0;
255
+ resize: none;
88
256
  background: transparent;
89
- color: inherit;
90
- padding: 10px;
91
- font-size: 14px;
257
+ color: var(--_text);
258
+ /* 16px 未満だと iOS Safari がフォーカス時にページを拡大する */
259
+ font-size: 16px;
260
+ line-height: 1.5;
92
261
  }
93
- input:focus {
94
- outline: none;
262
+ textarea::placeholder {
263
+ color: var(--_muted);
95
264
  }
96
- button[type="submit"] {
97
- border: none;
98
- background: none;
99
- padding: 0 14px;
265
+ .send {
266
+ flex: none;
267
+ display: grid;
268
+ place-items: center;
269
+ width: 34px;
270
+ height: 34px;
271
+ border: 0;
272
+ border-radius: 50%;
273
+ background: var(--_accent);
274
+ color: var(--_accent-contrast);
100
275
  cursor: pointer;
101
- color: var(--folio-agent-accent, CanvasText);
102
- font-weight: 600;
276
+ transition: opacity 0.15s ease, transform 0.15s ease;
277
+ }
278
+ .send:disabled {
279
+ opacity: 0.25;
280
+ cursor: default;
281
+ }
282
+ .send:not(:disabled):active {
283
+ transform: scale(0.92);
284
+ }
285
+ .send svg {
286
+ width: 18px;
287
+ height: 18px;
288
+ }
289
+ .disclosure {
290
+ margin: 8px 0 0;
291
+ font-size: 11px;
292
+ line-height: 1.5;
293
+ text-align: center;
294
+ color: var(--_muted);
295
+ }
296
+ .disclosure a {
297
+ color: inherit;
298
+ }
299
+
300
+ .toggle:focus-visible,
301
+ .close:focus-visible,
302
+ .send:focus-visible,
303
+ .suggestions button:focus-visible {
304
+ outline: 2px solid var(--_focus);
305
+ outline-offset: 2px;
306
+ }
307
+
308
+ @media (max-width: 640px) {
309
+ .toggle {
310
+ right: 16px;
311
+ bottom: calc(16px + env(safe-area-inset-bottom, 0px));
312
+ }
313
+ /* 高さと上端は visualViewport から JS が流し込む。キーボード表示中も入力欄を見える位置に保つため */
314
+ .panel {
315
+ top: var(--vv-top, 0px);
316
+ right: 0;
317
+ bottom: auto;
318
+ left: 0;
319
+ width: 100%;
320
+ height: var(--vv-height, 100dvh);
321
+ border: 0;
322
+ border-radius: 0;
323
+ background: var(--_glass-strong);
324
+ transform-origin: 50% 100%;
325
+ transform: translateY(32px);
326
+ }
327
+ .header {
328
+ padding-top: calc(12px + env(safe-area-inset-top, 0px));
329
+ }
330
+ }
331
+
332
+ @media (prefers-reduced-motion: reduce) {
333
+ .toggle,
334
+ .panel,
335
+ .root.open .panel {
336
+ transform: none;
337
+ }
338
+ .panel {
339
+ transition: opacity 0.15s linear, visibility 0s linear 0.15s;
340
+ }
341
+ .root.open .panel {
342
+ transition: opacity 0.15s linear;
343
+ }
344
+ .message,
345
+ .greeting,
346
+ .typing span {
347
+ animation: none;
348
+ }
103
349
  }
104
350
  `;
@@ -2,5 +2,6 @@ export declare class FolioAgentWidgetElement extends HTMLElement {
2
2
  #private;
3
3
  static readonly tagName = "folio-agent-widget";
4
4
  connectedCallback(): void;
5
+ disconnectedCallback(): void;
5
6
  }
6
7
  export declare function defineFolioAgentWidget(tagName?: string): void;
@@ -1,19 +1,31 @@
1
1
  import { WIDGET_STYLES } from "./styles.js";
2
2
  const WIDGET_TEXT = {
3
3
  ja: {
4
- toggleAriaLabel: "チャットを開く",
5
- inputPlaceholder: "メッセージを入力",
4
+ toggleLabel: "質問する",
5
+ headingText: "このサイトについて質問",
6
+ subheadingText: "公開している情報をもとに AI が答えます",
7
+ closeLabel: "閉じる",
8
+ greetingText: "制作実績・記事の内容・仕事の依頼について答えます。",
9
+ suggestions: ["どんな制作実績がありますか?", "どんな考え方で仕事をしていますか?", "仕事を依頼するには?"],
10
+ inputPlaceholder: "質問を入力",
6
11
  submitLabel: "送信",
12
+ typingLabel: "回答を作成中",
7
13
  disclosureText: "入力内容は品質改善のため記録されます。",
8
- policyLinkText: "利用データの扱いについて",
14
+ policyLinkText: "利用データの扱い",
9
15
  configErrorText: "設定エラー: endpoint属性が指定されていません。",
10
16
  answerFallbackText: "回答を取得できませんでした。しばらくしてから再度お試しください。",
11
17
  networkErrorText: "通信エラーが発生しました。しばらくしてから再度お試しください。",
12
18
  },
13
19
  en: {
14
- toggleAriaLabel: "Open chat",
15
- inputPlaceholder: "Type a message",
20
+ toggleLabel: "Ask",
21
+ headingText: "Ask about this site",
22
+ subheadingText: "AI answers from what this site publishes",
23
+ closeLabel: "Close",
24
+ greetingText: "Ask about past work, articles, or how to request a project.",
25
+ suggestions: ["What have you built?", "How do you approach your work?", "How can I request a project?"],
26
+ inputPlaceholder: "Type a question",
16
27
  submitLabel: "Send",
28
+ typingLabel: "Writing an answer",
17
29
  disclosureText: "Your input is logged for quality improvement.",
18
30
  policyLinkText: "About data usage",
19
31
  configErrorText: "Configuration error: the endpoint attribute is not set.",
@@ -21,99 +33,277 @@ const WIDGET_TEXT = {
21
33
  networkErrorText: "A network error occurred. Please try again later.",
22
34
  },
23
35
  };
36
+ const CHAT_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M20 12.5c0 3.9-3.6 7-8 7-1.1 0-2.2-.2-3.1-.6L4.5 20l1.2-3.4C4.6 15.5 4 14 4 12.5c0-3.9 3.6-7 8-7s8 3.1 8 7Z"/><path d="M9 12.5h.01M12 12.5h.01M15 12.5h.01" stroke-width="2.4"/></svg>`;
37
+ const CLOSE_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg>`;
38
+ const SEND_ICON = `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12 19V5M6 11l6-6 6 6"/></svg>`;
39
+ const COMPACT_QUERY = "(max-width: 640px)";
40
+ const FINE_POINTER_QUERY = "(pointer: fine)";
24
41
  function resolveLanguage(value) {
25
42
  return value === "en" ? "en" : "ja";
26
43
  }
44
+ function matches(query) {
45
+ return typeof window.matchMedia === "function" && window.matchMedia(query).matches;
46
+ }
47
+ function element(tag, className) {
48
+ const el = document.createElement(tag);
49
+ if (className)
50
+ el.className = className;
51
+ return el;
52
+ }
27
53
  export class FolioAgentWidgetElement extends HTMLElement {
28
54
  static tagName = "folio-agent-widget";
29
55
  #open = false;
30
- #hasShownDisclosure = false;
56
+ #pending = false;
31
57
  #messages = [];
58
+ #text = WIDGET_TEXT.ja;
59
+ #rootEl;
60
+ #toggleEl;
32
61
  #panelEl;
62
+ #closeEl;
33
63
  #messagesEl;
64
+ #suggestionsEl;
65
+ #typingEl;
34
66
  #inputEl;
35
- #text = WIDGET_TEXT.ja;
67
+ #sendEl;
68
+ #scrollLocked = false;
69
+ #previousOverflow = "";
70
+ #onViewportChange = () => this.#syncViewport();
36
71
  connectedCallback() {
72
+ if (this.shadowRoot)
73
+ return;
37
74
  this.#text = WIDGET_TEXT[resolveLanguage(this.getAttribute("lang"))];
38
75
  const root = this.attachShadow({ mode: "open" });
39
76
  const style = document.createElement("style");
40
77
  style.textContent = WIDGET_STYLES;
41
78
  root.appendChild(style);
42
- const toggle = document.createElement("button");
43
- toggle.className = "toggle";
79
+ const wrapper = element("div", "root");
80
+ wrapper.append(this.#buildToggle(), this.#buildPanel());
81
+ root.appendChild(wrapper);
82
+ this.#rootEl = wrapper;
83
+ }
84
+ disconnectedCallback() {
85
+ this.#watchViewport(false);
86
+ this.#lockScroll(false);
87
+ }
88
+ #buildToggle() {
89
+ const toggle = element("button", "toggle");
44
90
  toggle.type = "button";
45
- toggle.setAttribute("aria-label", this.#text.toggleAriaLabel);
46
- toggle.textContent = "💬";
47
- toggle.addEventListener("click", () => this.#togglePanel());
48
- root.appendChild(toggle);
49
- const panel = document.createElement("div");
50
- panel.className = "panel";
51
- panel.hidden = true;
52
- const messages = document.createElement("div");
53
- messages.className = "messages";
54
- panel.appendChild(messages);
55
- const form = document.createElement("form");
56
- const input = document.createElement("input");
57
- input.type = "text";
91
+ toggle.setAttribute("aria-expanded", "false");
92
+ toggle.setAttribute("aria-controls", "panel");
93
+ toggle.innerHTML = CHAT_ICON;
94
+ const label = element("span");
95
+ label.textContent = this.#text.toggleLabel;
96
+ toggle.appendChild(label);
97
+ toggle.addEventListener("click", () => this.#setOpen(true));
98
+ this.#toggleEl = toggle;
99
+ return toggle;
100
+ }
101
+ #buildPanel() {
102
+ const heading = this.getAttribute("heading") ?? this.#text.headingText;
103
+ const panel = element("section", "panel");
104
+ panel.id = "panel";
105
+ panel.setAttribute("role", "dialog");
106
+ panel.setAttribute("aria-label", heading);
107
+ panel.setAttribute("aria-hidden", "true");
108
+ panel.inert = true;
109
+ panel.addEventListener("keydown", (event) => {
110
+ if (event.key === "Escape")
111
+ this.#setOpen(false);
112
+ });
113
+ const header = element("header", "header");
114
+ const titles = element("div", "titles");
115
+ const headingEl = element("div", "heading");
116
+ headingEl.textContent = heading;
117
+ const subheading = element("div", "subheading");
118
+ subheading.textContent = this.#text.subheadingText;
119
+ titles.append(headingEl, subheading);
120
+ const close = element("button", "close");
121
+ close.type = "button";
122
+ close.setAttribute("aria-label", this.#text.closeLabel);
123
+ close.innerHTML = CLOSE_ICON;
124
+ close.addEventListener("click", () => this.#setOpen(false));
125
+ header.append(titles, close);
126
+ const messages = element("div", "messages");
127
+ messages.setAttribute("aria-live", "polite");
128
+ const greeting = this.getAttribute("greeting") ?? this.#text.greetingText;
129
+ if (greeting.trim()) {
130
+ const greetingEl = element("div", "greeting");
131
+ greetingEl.textContent = greeting;
132
+ messages.appendChild(greetingEl);
133
+ }
134
+ const suggestions = this.#buildSuggestions();
135
+ if (suggestions)
136
+ messages.appendChild(suggestions);
137
+ panel.append(header, messages, this.#buildForm());
138
+ this.#panelEl = panel;
139
+ this.#closeEl = close;
140
+ this.#messagesEl = messages;
141
+ return panel;
142
+ }
143
+ #buildSuggestions() {
144
+ const attr = this.getAttribute("suggestions");
145
+ const items = (attr === null ? this.#text.suggestions : attr.split("|"))
146
+ .map((item) => item.trim())
147
+ .filter((item) => item.length > 0);
148
+ if (items.length === 0)
149
+ return undefined;
150
+ const list = element("div", "suggestions");
151
+ for (const item of items) {
152
+ const button = element("button");
153
+ button.type = "button";
154
+ button.textContent = item;
155
+ button.addEventListener("click", () => void this.#sendMessage(item));
156
+ list.appendChild(button);
157
+ }
158
+ this.#suggestionsEl = list;
159
+ return list;
160
+ }
161
+ #buildForm() {
162
+ const form = element("form");
163
+ const field = element("div", "field");
164
+ const input = element("textarea");
165
+ input.rows = 1;
58
166
  input.placeholder = this.#text.inputPlaceholder;
59
- input.autocomplete = "off";
60
- const submit = document.createElement("button");
61
- submit.type = "submit";
62
- submit.textContent = this.#text.submitLabel;
63
- form.appendChild(input);
64
- form.appendChild(submit);
167
+ input.setAttribute("aria-label", this.#text.inputPlaceholder);
168
+ input.setAttribute("enterkeyhint", "send");
169
+ input.addEventListener("input", () => this.#syncInput());
170
+ input.addEventListener("keydown", (event) => {
171
+ if (event.key !== "Enter" || event.shiftKey || event.isComposing)
172
+ return;
173
+ event.preventDefault();
174
+ form.requestSubmit();
175
+ });
176
+ const send = element("button", "send");
177
+ send.type = "submit";
178
+ send.disabled = true;
179
+ send.setAttribute("aria-label", this.#text.submitLabel);
180
+ send.innerHTML = SEND_ICON;
181
+ field.append(input, send);
182
+ form.append(field, this.#buildDisclosure());
65
183
  form.addEventListener("submit", (event) => {
66
184
  event.preventDefault();
67
185
  const text = input.value;
68
186
  input.value = "";
187
+ this.#syncInput();
69
188
  void this.#sendMessage(text);
70
189
  });
71
- panel.appendChild(form);
72
- root.appendChild(panel);
73
- this.#panelEl = panel;
74
- this.#messagesEl = messages;
75
190
  this.#inputEl = input;
191
+ this.#sendEl = send;
192
+ return form;
76
193
  }
77
- #togglePanel() {
78
- this.#open = !this.#open;
79
- if (!this.#panelEl)
80
- return;
81
- this.#panelEl.hidden = !this.#open;
82
- if (this.#open && !this.#hasShownDisclosure) {
83
- this.#hasShownDisclosure = true;
84
- this.#renderDisclosure();
85
- }
86
- if (this.#open) {
87
- this.#inputEl?.focus();
88
- }
89
- }
90
- #renderDisclosure() {
91
- if (!this.#messagesEl)
92
- return;
93
- const disclosure = document.createElement("div");
94
- disclosure.className = "disclosure";
95
- disclosure.append(`${this.#text.disclosureText} `);
194
+ #buildDisclosure() {
195
+ const disclosure = element("p", "disclosure");
196
+ disclosure.append(this.#text.disclosureText);
96
197
  const policyHref = this.getAttribute("policy-href");
97
198
  if (policyHref) {
98
- const link = document.createElement("a");
199
+ disclosure.append(" ");
200
+ const link = element("a");
99
201
  link.href = policyHref;
100
202
  link.target = "_blank";
101
203
  link.rel = "noopener noreferrer";
102
204
  link.textContent = this.#text.policyLinkText;
103
205
  disclosure.appendChild(link);
104
206
  }
105
- this.#messagesEl.appendChild(disclosure);
207
+ return disclosure;
208
+ }
209
+ #setOpen(open) {
210
+ if (this.#open === open || !this.#rootEl || !this.#panelEl || !this.#toggleEl)
211
+ return;
212
+ this.#open = open;
213
+ this.#rootEl.classList.toggle("open", open);
214
+ this.#toggleEl.setAttribute("aria-expanded", String(open));
215
+ this.#toggleEl.inert = open;
216
+ this.#panelEl.setAttribute("aria-hidden", String(!open));
217
+ this.#panelEl.inert = !open;
218
+ this.#watchViewport(open);
219
+ this.#lockScroll(open && matches(COMPACT_QUERY));
220
+ if (open) {
221
+ // タッチ端末で開いた瞬間にキーボードを出すと、パネルの登場とキーボードの動きが重なる
222
+ if (matches(FINE_POINTER_QUERY))
223
+ this.#inputEl?.focus();
224
+ else
225
+ this.#closeEl?.focus({ preventScroll: true });
226
+ }
227
+ else {
228
+ this.#inputEl?.blur();
229
+ this.#toggleEl.focus({ preventScroll: true });
230
+ }
231
+ }
232
+ #watchViewport(on) {
233
+ const viewport = window.visualViewport;
234
+ if (!viewport)
235
+ return;
236
+ if (on) {
237
+ viewport.addEventListener("resize", this.#onViewportChange);
238
+ viewport.addEventListener("scroll", this.#onViewportChange);
239
+ this.#syncViewport();
240
+ }
241
+ else {
242
+ viewport.removeEventListener("resize", this.#onViewportChange);
243
+ viewport.removeEventListener("scroll", this.#onViewportChange);
244
+ }
245
+ }
246
+ #syncViewport() {
247
+ const viewport = window.visualViewport;
248
+ if (!viewport || !this.#panelEl)
249
+ return;
250
+ this.#panelEl.style.setProperty("--vv-height", `${viewport.height}px`);
251
+ this.#panelEl.style.setProperty("--vv-top", `${viewport.offsetTop}px`);
252
+ this.#scrollToEnd();
253
+ }
254
+ // 全画面表示中に背後のページがスクロールすると、iOS でパネルごと画面外へずれる
255
+ #lockScroll(lock) {
256
+ if (lock === this.#scrollLocked)
257
+ return;
258
+ const rootStyle = document.documentElement.style;
259
+ if (lock) {
260
+ this.#previousOverflow = rootStyle.overflow;
261
+ rootStyle.overflow = "hidden";
262
+ }
263
+ else {
264
+ rootStyle.overflow = this.#previousOverflow;
265
+ }
266
+ this.#scrollLocked = lock;
267
+ }
268
+ #syncInput() {
269
+ const input = this.#inputEl;
270
+ if (!input || !this.#sendEl)
271
+ return;
272
+ input.style.height = "auto";
273
+ input.style.height = `${input.scrollHeight}px`;
274
+ this.#sendEl.disabled = this.#pending || input.value.trim().length === 0;
275
+ }
276
+ #setPending(pending) {
277
+ this.#pending = pending;
278
+ this.#syncInput();
279
+ if (pending && this.#messagesEl) {
280
+ const typing = element("div", "typing");
281
+ typing.setAttribute("role", "status");
282
+ typing.setAttribute("aria-label", this.#text.typingLabel);
283
+ typing.append(element("span"), element("span"), element("span"));
284
+ this.#messagesEl.appendChild(typing);
285
+ this.#typingEl = typing;
286
+ this.#scrollToEnd();
287
+ }
288
+ else {
289
+ this.#typingEl?.remove();
290
+ this.#typingEl = undefined;
291
+ }
106
292
  }
107
293
  async #sendMessage(rawText) {
108
294
  const text = rawText.trim();
109
- if (!text)
295
+ if (!text || this.#pending)
110
296
  return;
297
+ this.#suggestionsEl?.remove();
298
+ this.#suggestionsEl = undefined;
111
299
  this.#appendMessage({ role: "user", text });
112
300
  const endpoint = this.getAttribute("endpoint");
113
301
  if (!endpoint) {
114
302
  this.#appendMessage({ role: "assistant", text: this.#text.configErrorText });
115
303
  return;
116
304
  }
305
+ this.#setPending(true);
306
+ let answer;
117
307
  try {
118
308
  const response = await fetch(endpoint, {
119
309
  method: "POST",
@@ -121,27 +311,26 @@ export class FolioAgentWidgetElement extends HTMLElement {
121
311
  body: JSON.stringify({ message: text }),
122
312
  });
123
313
  const body = (await response.json());
124
- this.#appendMessage({
125
- role: "assistant",
126
- text: body.answer ?? this.#text.answerFallbackText,
127
- });
314
+ answer = body.answer ?? this.#text.answerFallbackText;
128
315
  }
129
316
  catch {
130
- this.#appendMessage({
131
- role: "assistant",
132
- text: this.#text.networkErrorText,
133
- });
317
+ answer = this.#text.networkErrorText;
134
318
  }
319
+ this.#setPending(false);
320
+ this.#appendMessage({ role: "assistant", text: answer });
135
321
  }
136
322
  #appendMessage(message) {
137
323
  this.#messages.push(message);
138
324
  if (!this.#messagesEl)
139
325
  return;
140
- const el = document.createElement("div");
141
- el.className = `message ${message.role}`;
326
+ const el = element("div", `message ${message.role}`);
142
327
  el.textContent = message.text;
143
328
  this.#messagesEl.appendChild(el);
144
- this.#messagesEl.scrollTop = this.#messagesEl.scrollHeight;
329
+ this.#scrollToEnd();
330
+ }
331
+ #scrollToEnd() {
332
+ if (this.#messagesEl)
333
+ this.#messagesEl.scrollTop = this.#messagesEl.scrollHeight;
145
334
  }
146
335
  }
147
336
  export function defineFolioAgentWidget(tagName = FolioAgentWidgetElement.tagName) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@folio-agent/widget",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "description": "Framework-agnostic reception chat widget (silent until clicked)",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -32,6 +32,6 @@
32
32
  "prepublishOnly": "tsc -b --force"
33
33
  },
34
34
  "devDependencies": {
35
- "happy-dom": "^20.10.6"
35
+ "happy-dom": "^20.14.0"
36
36
  }
37
37
  }