livechat 0.8.1 → 0.9.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6007e2a9721aeb42f80cee9f9e5609adff1ae7edd787f9329efd74d1d92f94b5
4
- data.tar.gz: 0d9769f92ead60fbb908447204bc7a9dabbf33f2ef8b1eb4b4f501e0df29e7b5
3
+ metadata.gz: 93a0ce2c43a9f0ba3f3ce569aac6758962a949919d924ada0283ebd043c34216
4
+ data.tar.gz: 961e891c713d52e780a52149b87230c8661da575a53b7a9356865db39241803b
5
5
  SHA512:
6
- metadata.gz: ebfe7ab9b60281cb8199960e5cd3a60e5a9af4ee6b0222b8039e3e0103d25e69ea567548f3dcd9252cd881aec235bb4d0187a72f714f402cb84b972b441bd5c4
7
- data.tar.gz: f1018b0d5775439bd90f87520a4a22ba44c1703e8b2a163940403f656ebcb793945e8a99a640267f31bf1215c517d4cf7925d7c6ed9be1a05ca8baa5ca8280c7
6
+ metadata.gz: 20925768376d8fd25aef9323616e9e506c3e39081350ff25166a10eeaeceafbdd2a728a6015fe15407de8d7ed981381ccb1747bc95d10690bd4f9d8e7100b3e6
7
+ data.tar.gz: 78e714d488cc3ef8a29aafe63dc80f08069825c1fc78ff5294ce9c2ccce00cd84ffc2d2a44ad0c5b126d42ea7871ff99147621f3e5384fb844761bed607a9856
data/AGENTS.md CHANGED
@@ -107,7 +107,7 @@ Leave it off unless the app already has Action Cable working. Polling is not a d
107
107
  ### Do not
108
108
 
109
109
  - **Do not copy the widget JavaScript into `app/javascript`, or add a `<script>` tag for it.** `livechat_tag` renders what is needed, and the engine serves the code with a content fingerprint. There is no build step and nothing for esbuild/importmap/Tailwind to know about.
110
- - **Do not build your own inbox.** Use the mounted one; `config.agent_layout = "admin/application"` renders it inside an existing admin shell.
110
+ - **Do not rebuild the inbox, and do not edit views inside the gem.** To put it inside an admin you already have, set `config.base_controller_class = "Admin::BaseController"` — it inherits that controller's layout, helpers, authentication and request context. For the shell alone, `config.agent_layout = "admin/application"`. Both work with nothing else wired up: the inbox's own assets are declared by its views, not the layout.
111
111
  - **Do not add an Action Cable mount to "make chat realtime"** unless you also set `config.action_cable = true`. Polling is the default and is not broken.
112
112
  - **Do not set config outside the initializer.** `rate_limit` in particular is read when the controller class loads; assigning config per-request mutates it process-wide.
113
113
  - **Do not expose attachments by blob URL** — the engine's gated route exists so a leaked signed URL cannot hand over a customer's file.
@@ -119,6 +119,7 @@ Everything is optional; a fresh install works with zero config. Full list with c
119
119
  | Option | Default | Note |
120
120
  | --- | --- | --- |
121
121
  | `authorize_agent` | development only | **Who can read the inbox. Set before deploying.** |
122
+ | `base_controller_class` | `ActionController::Base` | Controller the inbox inherits. Name your admin's and it adopts that layout, helpers, authentication and request context. Public endpoints never inherit it. |
122
123
  | `enabled` | everyone | Per-request gate for the widget and its endpoints |
123
124
  | `current_user` | `nil` | Receives the request; nil means guest-by-cookie |
124
125
  | `visitor_label`, `agent_label` | name/email/to_s | Receive the user |
@@ -143,6 +144,8 @@ Turbo Drive and strict nonce-based CSP work out of the box. 26 locales ship with
143
144
 
144
145
  | Symptom | Cause |
145
146
  | --- | --- |
147
+ | `NameError` for one of your own helpers in the inbox | `isolate_namespace` scopes `helper` to the engine. Use `config.base_controller_class` so the inbox inherits your helpers, rather than `agent_layout` alone. |
148
+ | `NotNullViolation` attaching a file on a uuid-keyed app | The tables were generated bigint. Set `primary_key_type` in `config.generators` before installing, or migrate them to uuid. |
146
149
  | `/livechat` returns 403 "Set Livechat.config.authorize_agent to grant access" | Exactly what it says: still at the development-only default |
147
150
  | No bubble on the page | `livechat_tag` missing from the rendered layout, `config.enabled` false, or `show_launcher = false` with no opener of your own |
148
151
  | No notification emails | `mailer_from` not set — `agent_emails` alone does nothing |
@@ -152,6 +155,10 @@ Turbo Drive and strict nonce-based CSP work out of the box. 26 locales ship with
152
155
 
153
156
  ---
154
157
 
158
+ ## One family
159
+
160
+ `testimonials`, `ideasbugs`, `product_tours`, `i18n_proofreading` are the sibling engines. Same install shape, same host hooks (`base_controller_class`, `agent_layout`), same scoped dashboard CSS, same `primary_key_type`-aware migrations — so what you learn here transfers.
161
+
155
162
  ## Working on the gem itself
156
163
 
157
164
  ```bash
@@ -169,5 +176,6 @@ Conventions this codebase holds to — follow them rather than the first thing t
169
176
  - **The widget is plain ES5-style JS served by the engine**, no build step, no framework, config read from a JSON block so a Turbo visit re-reads the current page's settings.
170
177
  - **Visitor scoping is never by conversation id.** The widget's endpoints resolve the thread from the signed-in id or the guest cookie, so no id in a request can address someone else's conversation. Keep it that way.
171
178
  - **The dummy app pins `config.active_job.queue_adapter = :test`.** Do not remove it or let it drift back to the `:async` default. Attaching a file enqueues Active Storage's analysis job, and `:async` runs it on a background thread that checks out its own connection — writes no test transaction covers, landing in the middle of whatever runs next. That is a suite that fails order-dependently in a test which never created a row, and it is miserable to trace back.
179
+ - **`lib/livechat/dashboard.css` is half shared.** Everything above the `GEM-SPECIFIC` banner is the design system all five gems in the family ship — the same tokens, the same `.page-head`/`.tabs`/`.filters`/`.card`/`.badge`/`button`, the same `.dashboard-shell` + `.record-row` + `.detail-panel` two-pane dashboard — identical in every repo apart from the `lvc` prefix. Diff it against a sibling before changing it, and carry the change to the other four. Anything only this gem has goes below the banner. New dashboard markup reuses the shared class names rather than inventing a domain-specific one.
172
180
  - Every user-facing change bumps `lib/livechat/version.rb` and adds a `CHANGELOG.md` entry that says what it costs, not only what it adds.
173
181
  - Commit messages are prose that explains the tradeoff — read `git log` before writing one.
data/CHANGELOG.md CHANGED
@@ -1,5 +1,38 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ - **One design system across the family.** The stylesheet now opens with a
6
+ shared core — the colour tokens, `.page-head`, `.tabs`, `.filters`, `.card`,
7
+ `.badge`, buttons and form controls, and the `.dashboard-shell` +
8
+ `.record-row` + `.detail-panel` two-pane dashboard — identical in all five
9
+ gems of the family, apart from the `--lvc-` prefix. The five had drifted:
10
+ sidebars between 380px and 430px, reading columns between 860px and 1020px,
11
+ two different tab styles and three different button styles. The sidebar is
12
+ now 330–430px everywhere, a reading page 1020px, a dashboard 1280px.
13
+ Everything below the `GEM-SPECIFIC` banner is what only this gem has.
14
+ - **The inbox markup uses the shared class names.** `.inbox-shell`,
15
+ `.inbox-sidebar` and `.inbox-thread` are `.dashboard-shell`,
16
+ `.dashboard-sidebar` and `.dashboard-detail`; `.conversation-list`,
17
+ `.conversation-row`, `.conversation-main`, `.conversation-topline`,
18
+ `.conversation-meta`, `.conversation-time` and `.conversation-side` are
19
+ `.record-*`, with `.conversation-name` becoming `.record-name` and
20
+ `.conversation-preview` `.record-copy`; `.conversation-panel` is
21
+ `.detail-panel` and its `.head-row` is `.panel-head`. The body class on the
22
+ inbox is `lvc-index` (was `lvc-inbox`) and the polling element is
23
+ `#lvc-poll` (was `#lvc-index`). A conversation row now reads name, then
24
+ message preview, then ids — the order the sibling gems use. If you styled or
25
+ scripted any of those names in a host app, that is the breaking change — no
26
+ configuration, route or database change is involved.
27
+ - **The narrow-screen rules work again.** The `max-width: 760px` block sat above
28
+ the component rules it means to override, and CSS nesting adds no specificity,
29
+ so the desktop grid won every tie: showing one pane at a time on a phone had
30
+ quietly stopped working. The media query moved to the end of the file.
31
+ - Smaller fixes that came with the shared core: a submit input is styled as a
32
+ button rather than a full-width field, the filter row keeps its search box and
33
+ button on one line, and a `code.key` truncates inside a list row instead of
34
+ wrapping over three lines.
35
+
3
36
  ## 0.8.0
4
37
 
5
38
  - **`config.agent_layout` now works on its own.** The inbox's stylesheet and
data/README.md CHANGED
@@ -316,15 +316,24 @@ bundle exec rake test
316
316
  bundle exec rubocop
317
317
  ```
318
318
 
319
- ## Also by the same author
320
-
321
- - [testimonials](https://github.com/yshmarov/testimonials) testimonials,
322
- reviews and NPS for Rails.
323
- - [ideasbugs](https://github.com/yshmarov/ideasbugs) — in-app bug reports and
324
- feature requests.
325
- - [i18n_proofreading](https://github.com/yshmarov/i18n_proofreading) in-context
326
- translation proofreading.
327
- - [SupeRails](https://superails.com) Rails screencasts.
319
+ ## One family
320
+
321
+ Five Rails engines built on the same backbone, so adopting a second one is
322
+ mostly muscle memory:
323
+
324
+ | Gem | What it does |
325
+ | --- | --- |
326
+ | [testimonials](https://github.com/yshmarov/testimonials) | Testimonials, reviews and NPS — text and video, collected in your own app |
327
+ | [ideasbugs](https://github.com/yshmarov/ideasbugs) | In-app bug reports and feature requests, with a triage queue |
328
+ | **livechat** *(this gem)* | Live chat between your visitors and your agents, self-hosted |
329
+ | [product_tours](https://github.com/yshmarov/product_tours) | Product tours and video tutorials, shown in-app at the right moment |
330
+ | [i18n_proofreading](https://github.com/yshmarov/i18n_proofreading) | In-context translation fixes suggested by your own users |
331
+
332
+ They share the install shape (`generate <gem>:install`, mount, one initializer),
333
+ the same host hooks (`base_controller_class` to inherit your admin's controller,
334
+ `agent_layout` for just the shell), one dashboard design system — the same
335
+ two-pane layout, colour tokens and components in all five, scoped so it cannot
336
+ touch your own CSS — and migrations that follow your app's `primary_key_type`.
328
337
 
329
338
  ## License
330
339
 
@@ -1,5 +1,5 @@
1
- <div class="conversation-panel">
2
- <div class="head-row">
1
+ <div class="detail-panel">
2
+ <div class="panel-head">
3
3
  <h1><%= conversation.display_name %> <span class="case-id">#<%= conversation.id %></span></h1>
4
4
  <% if conversation.open? %>
5
5
  <%= button_to resolve_conversation_path(conversation), method: :post,
@@ -1,10 +1,12 @@
1
- <% content_for :body_class, 'lvc-inbox' %>
1
+ <% content_for :body_class, 'lvc-index' %>
2
2
 
3
3
  <%= render layout: 'livechat/shared/dashboard' do %>
4
- <h1><%= t('livechat.dashboard.title', default: 'LiveChat') %></h1>
4
+ <div class="page-head">
5
+ <h1><%= t('livechat.dashboard.title', default: 'LiveChat') %></h1>
6
+ </div>
5
7
 
6
- <div class="inbox-shell <%= 'has-selected' if @selected_conversation %>">
7
- <aside class="inbox-sidebar" aria-label="<%= t('livechat.dashboard.conversations', default: 'Conversations') %>">
8
+ <div class="dashboard-shell <%= 'has-selected' if @selected_conversation %>">
9
+ <aside class="dashboard-sidebar" aria-label="<%= t('livechat.dashboard.conversations', default: 'Conversations') %>">
8
10
  <div class="tabs">
9
11
  <% Livechat::Conversation::STATUSES.each do |status| %>
10
12
  <%= link_to conversations_path(status: status, q: @query), class: ('active' if @status == status) do %>
@@ -25,12 +27,12 @@
25
27
  <%# The list keeps itself fresh (dashboard.js reloads when the token moves) —
26
28
  unless the agent is mid-search or mid-reply, whose typing must never be eaten. %>
27
29
  <% cable = livechat_cable_data('livechat:inbox') %>
28
- <div id="lvc-index" data-poll-url="<%= poll_conversations_path %>"
30
+ <div id="lvc-poll" data-poll-url="<%= poll_conversations_path %>"
29
31
  data-token="<%= [Livechat::Message.maximum(:id).to_i, Livechat::Conversation.count,
30
32
  @counts.fetch('open', 0)].join('-') %>"
31
33
  <%= tag.attributes(data: cable) if cable.any? %>></div>
32
34
 
33
- <div class="conversation-list">
35
+ <div class="record-list">
34
36
  <% if @conversations.empty? %>
35
37
  <p class="empty"><%= t('livechat.dashboard.empty',
36
38
  default: 'Nothing here. When a visitor writes, the conversation lands on this page.') %></p>
@@ -39,27 +41,29 @@
39
41
  <% unread = @unread.fetch(conversation.id, 0) %>
40
42
  <%= link_to conversations_path(status: @status, q: @query, offset: @offset,
41
43
  conversation_id: conversation.id),
42
- class: ['conversation-row',
44
+ class: ['record-row',
43
45
  ('active' if @selected_conversation&.id == conversation.id),
44
46
  ('unread' if unread.positive?)].compact do %>
45
- <span class="conversation-main">
46
- <span class="conversation-topline">
47
- <span class="conversation-name"><%= conversation.display_name %></span>
48
- <span class="muted conversation-time">
47
+ <span class="record-main">
48
+ <span class="record-topline">
49
+ <span class="record-name"><%= conversation.display_name %></span>
50
+ <span class="muted record-time">
49
51
  <% if conversation.last_activity_at %>
50
52
  <%= time_ago_in_words(conversation.last_activity_at) %>
51
53
  <% end %>
52
54
  </span>
53
55
  </span>
54
- <span class="muted conversation-meta">
56
+ <%# Same row shape as the sibling gems: the name and time on top,
57
+ the copy that identifies the record, then the muted ids. %>
58
+ <span class="muted record-copy"><%= conversation.last_message_preview %></span>
59
+ <span class="muted record-meta">
55
60
  <span class="case-id">#<%= conversation.id %></span>
56
61
  <% if conversation.visitor_email.present? && conversation.visitor_email != conversation.display_name %>
57
62
  <span><%= conversation.visitor_email %></span>
58
63
  <% end %>
59
64
  </span>
60
- <span class="muted conversation-preview"><%= conversation.last_message_preview %></span>
61
65
  </span>
62
- <span class="conversation-side">
66
+ <span class="record-side">
63
67
  <% if unread.positive? %>
64
68
  <span class="badge unread-count"><%= unread %></span>
65
69
  <% end %>
@@ -88,7 +92,7 @@
88
92
  </div>
89
93
  </aside>
90
94
 
91
- <main class="inbox-thread" aria-label="<%= t('livechat.dashboard.current_chat', default: 'Current chat') %>">
95
+ <main class="dashboard-detail" aria-label="<%= t('livechat.dashboard.current_chat', default: 'Current chat') %>">
92
96
  <% if @selected_conversation %>
93
97
  <p class="mobile-back">
94
98
  <%= link_to "← #{t('livechat.dashboard.back', default: 'All conversations')}",
@@ -4,72 +4,59 @@
4
4
  *
5
5
  * :root custom properties, all `--lvc-` prefixed so they can
6
6
  * neither overwrite a host's nor be overwritten by them.
7
- * .lvc-page the page frame (was bare html/body). Only the gem's own
7
+ * .lvc-page the page frame (was bare html/body). Only the gem's own
8
8
  * layout puts this class on <body>, so inside a host admin
9
9
  * these rules simply do not apply.
10
- * .lvc-dashboard everything else, nested. Names like .container, .card
10
+ * .lvc-dashboard everything else, nested. Names like .container, .card
11
11
  * and .tabs are confined to the dashboard's own markup, so
12
12
  * loading this file inside a host cannot restyle its chrome.
13
13
  *
14
14
  * Nothing here styles a bare `body`, `a`, `table` or `*` at the top level.
15
+ *
16
+ * Two sections follow: the design system shared by the whole gem family,
17
+ * then what only this gem has. A rule that belongs in the shared section
18
+ * belongs in all five copies of it.
19
+ *
20
+ * Order inside a section matters — components, then the page frame, then the
21
+ * narrow-screen overrides. Nesting adds no specificity, so the last rule
22
+ * wins the ties, and an override placed before the rule it means to beat
23
+ * silently does nothing.
15
24
  */
16
25
 
26
+ /* ---------------------------------------------------------------------------
27
+ * SHARED CORE — identical in testimonials, ideasbugs, livechat, product_tours
28
+ * and i18n_proofreading apart from the prefix. Diff it against a sibling gem
29
+ * before you change it, and carry the change to all five.
30
+ * ------------------------------------------------------------------------- */
17
31
 
18
32
  :root {
19
- color-scheme: light dark;
20
- --lvc-bg: #f6f7f9; --lvc-surface: #fff; --lvc-text: #1c2024; --lvc-muted: #6b7280;
21
- --lvc-border: #e5e7eb; --lvc-accent: #2563eb; --lvc-accent-text: #fff;
22
- --lvc-open: #16a34a; --lvc-resolved: #6b7280; --lvc-danger: #dc2626;
33
+ color-scheme: light dark;
34
+ --lvc-bg: #f6f7f9;
35
+ --lvc-surface: #fff;
36
+ --lvc-text: #1c2024;
37
+ --lvc-muted: #6b7280;
38
+ --lvc-border: #e5e7eb;
39
+ --lvc-accent: #2563eb;
40
+ --lvc-accent-text: #fff;
41
+ --lvc-code: #f0f1f3;
42
+ /* The four semantic states every gem in the family badges something with. */
43
+ --lvc-success: #16a34a;
44
+ --lvc-warning: #d97706;
45
+ --lvc-danger: #dc2626;
46
+ --lvc-neutral: #6b7280;
23
47
  }
24
48
  @media (prefers-color-scheme: dark) {
25
49
  :root {
26
- --lvc-bg: #111418; --lvc-surface: #1a1f26; --lvc-text: #e6e8ea; --lvc-muted: #9aa2ab;
27
- --lvc-border: #2a313a; --lvc-accent: #3b82f6;
28
- }
29
- }
30
- @media (max-width: 760px) {
31
- .lvc-page { font-size: 14px; }
32
- .lvc-inbox { overflow: auto; }
33
- .lvc-inbox, .lvc-inbox .container { min-height: 100vh; height: auto; }
34
- .lvc-inbox .container { padding-bottom: 10px; }
35
- .lvc-dashboard {
36
- & .container { padding: 14px 10px 24px; }
37
- & .inbox-shell { display: block; min-height: calc(100vh - 62px); }
38
- & .inbox-sidebar,
39
- & .inbox-thread { min-height: calc(100vh - 62px); }
40
- & .inbox-shell.has-selected .inbox-sidebar { display: none; }
41
- & .inbox-shell:not(.has-selected) .inbox-thread { display: none; }
42
- & .conversation-list { overflow: visible; }
43
- & .conversation-row { padding: 12px; }
44
- & .conversation-meta { display: none; }
45
- & .conversation-panel { min-height: calc(100vh - 64px); }
46
- & .inbox-thread .conversation-panel { padding: 0; }
47
- & .mobile-back { display: block; flex: 0 0 auto; margin: 10px 10px 0; font-size: 13px; }
48
- & .conversation-panel .head-row h1 { font-size: 18px; }
49
- & .status-toggle { width: 152px; min-height: 34px; }
50
- & .status-toggle span { padding: 0 7px; font-size: 12px; }
51
- & .thread .msg { max-width: 88%; }
50
+ --lvc-bg: #111418;
51
+ --lvc-surface: #1a1f26;
52
+ --lvc-text: #e6e8ea;
53
+ --lvc-muted: #9aa2ab;
54
+ --lvc-border: #2a313a;
55
+ --lvc-accent: #3b82f6;
56
+ --lvc-code: #232a33;
52
57
  }
53
58
  }
54
59
 
55
- /* Page frame — the gem's own layout only. */
56
- .lvc-page { margin: 0; background: var(--lvc-bg); color: var(--lvc-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; }
57
- /* Desktop inbox: a durable conversation list on the left and the active thread on the right, with each pane owning its own scroll. */
58
- .lvc-inbox, .lvc-inbox .container { height: 100vh; height: 100dvh; }
59
- .lvc-inbox { overflow: hidden; }
60
- .lvc-inbox .container { max-width: 1280px; display: flex; flex-direction: column; padding-bottom: 16px; }
61
- .lvc-inbox .container h1 { flex: 0 0 auto; margin-bottom: 14px; }
62
- /* Conversation page: fill the viewport and scroll the THREAD, not the page — so a reply never jumps the page, and the composer stays put. */
63
- .lvc-convo, .lvc-convo .container { height: 100vh; height: 100dvh; }
64
- .lvc-convo { overflow: hidden; }
65
- .lvc-convo .container { display: flex; flex-direction: column; padding-bottom: 16px; }
66
- .lvc-convo .breadcrumb, .lvc-convo .head-row, .lvc-convo .context-line,
67
- .lvc-convo .flash { flex: 0 0 auto; }
68
- .lvc-convo .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
69
- .lvc-convo .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
70
- .lvc-convo .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
71
- .lvc-convo .reply { flex: 0 0 auto; }
72
-
73
60
  /* Dashboard components — any layout. */
74
61
  .lvc-dashboard {
75
62
  /* A scoping device, not a layout one: without this the wrapper would
@@ -79,36 +66,231 @@
79
66
  & * { box-sizing: border-box; }
80
67
  & a { color: var(--lvc-accent); text-decoration: none; }
81
68
  & a:hover { text-decoration: underline; }
82
- & .container { max-width: 860px; margin: 0 auto; padding: 24px 16px 64px; }
83
- & h1 { font-size: 22px; margin: 0 0 16px; }
84
- & .tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--lvc-border); margin-bottom: 16px; flex-wrap: wrap; }
85
- & .tabs a { padding: 8px 14px; border-radius: 8px 8px 0 0; color: var(--lvc-muted); }
86
- & .tabs a.active { color: var(--lvc-text); font-weight: 600; border: 1px solid var(--lvc-border); border-bottom: 2px solid var(--lvc-surface); background: var(--lvc-surface); margin-bottom: -1px; }
87
- & .tabs a:hover { text-decoration: none; color: var(--lvc-text); }
88
- & .count { display: inline-block; min-width: 20px; padding: 0 6px; margin-left: 4px; border-radius: 999px; background: var(--lvc-border); font-size: 12px; text-align: center; }
89
- & .filters { margin-bottom: 16px; display: flex; gap: 8px; }
90
- & .filters input[type=search] { flex: 1; max-width: 320px; padding: 6px 10px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; }
91
- & .card { background: var(--lvc-surface); border: 1px solid var(--lvc-border); border-radius: 12px; overflow: hidden; }
92
- & .card.pad { padding: 16px; }
69
+ & :focus-visible { outline: 2px solid var(--lvc-accent); outline-offset: 2px; }
70
+
71
+ /* Page furniture. `.container` is the reading width; an index page widens it
72
+ to 1280px in the page frame below. */
73
+ & .container { max-width: 1020px; margin: 0 auto; padding: 24px 16px 64px; }
74
+ & h1 { margin: 0 0 16px; font-size: 22px; }
75
+ & h2 { margin: 0 0 12px; font-size: 16px; }
76
+ & .page-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
77
+ & .page-head h1 { margin: 0; }
78
+ & .lede { margin: 4px 0 0; color: var(--lvc-muted); }
79
+ & .breadcrumb { margin: 0 0 12px; font-size: 13px; }
80
+ & .muted { color: var(--lvc-muted); font-size: 13px; }
81
+ & .case-id { color: var(--lvc-muted); font-weight: 400; font-size: 15px; }
82
+ & .spacer { flex: 1; }
83
+ & .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
84
+
85
+ /* Section switcher: the pills above the dashboard when a gem has more than
86
+ one section. Lives on the page, so a host `admin_layout` can drop it. */
87
+ & .nav { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
88
+ & .nav a { padding: 6px 16px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); color: var(--lvc-muted); font-weight: 600; }
89
+ & .nav a:hover { text-decoration: none; color: var(--lvc-text); }
90
+ & .nav a.active { border-color: var(--lvc-accent); background: var(--lvc-accent); color: var(--lvc-accent-text); }
91
+ & .nav a.active:hover { color: var(--lvc-accent-text); }
92
+
93
+ /* Tabs: one system for the whole family — an underlined row of equal-width
94
+ tabs, sized to sit at the top of the sidebar. */
95
+ & .tabs { display: flex; gap: 6px; }
96
+ & .tabs a { position: relative; display: flex; flex: 1 1 0; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 10px 8px; border-radius: 8px; color: var(--lvc-muted); font-weight: 600; }
97
+ & .tabs a:hover { background: var(--lvc-bg); color: var(--lvc-text); text-decoration: none; }
98
+ & .tabs a.active { color: var(--lvc-text); }
99
+ & .tabs a.active::after { content: ""; position: absolute; right: 20px; bottom: 0; left: 20px; height: 2px; border-radius: 999px; background: var(--lvc-accent); }
100
+ & .count { display: inline-block; min-width: 20px; padding: 0 6px; border-radius: 999px; background: color-mix(in srgb, var(--lvc-muted) 14%, transparent); font-size: 12px; text-align: center; font-variant-numeric: tabular-nums; }
101
+ & .tabs a.active .count { background: var(--lvc-border); }
102
+
103
+ /* Filter row under the tabs: a full-width select over a search + submit row,
104
+ however many of the three a gem actually has. */
105
+ & .filters { display: flex; flex-wrap: wrap; gap: 8px; margin: 0; padding: 12px; border-bottom: 1px solid var(--lvc-border); }
106
+ & .filters label { flex: 1 1 100%; color: var(--lvc-muted); font-size: 13px; }
107
+ & .filters select { flex: 1 1 100%; }
108
+ /* Basis 0, not auto: the controls below are `width: 100%`, which would
109
+ otherwise push the submit button onto its own row. */
110
+ & .filters input[type="search"] { flex: 1 1 0; min-width: 0; }
111
+ & .filters button,
112
+ & .filters input[type="submit"] { flex: 0 0 auto; }
113
+
114
+ /* Form controls. 16px so iOS does not zoom the page on focus. */
115
+ & input,
116
+ & select,
117
+ & textarea { width: 100%; min-height: 38px; padding: 7px 9px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; font-size: 16px; letter-spacing: 0; }
118
+ & textarea { resize: vertical; }
119
+ & input[type="checkbox"],
120
+ & input[type="radio"] { width: auto; min-width: 0; min-height: 0; }
121
+ & input:focus,
122
+ & select:focus,
123
+ & textarea:focus { border-color: var(--lvc-accent); outline: 2px solid color-mix(in srgb, var(--lvc-accent) 22%, transparent); outline-offset: 0; }
124
+ & .field { margin-bottom: 16px; }
125
+ & .field label { display: block; margin-bottom: 5px; font-weight: 600; }
126
+ & .field small { display: block; margin-top: 5px; color: var(--lvc-muted); }
127
+
128
+ /* A submit input is a button, whatever the markup says. */
129
+ & button,
130
+ & .button,
131
+ & input[type="submit"] { display: inline-flex; width: auto; align-items: center; justify-content: center; min-height: 38px; padding: 8px 14px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; cursor: pointer; }
132
+ & button:hover,
133
+ & .button:hover,
134
+ & input[type="submit"]:hover { border-color: var(--lvc-muted); text-decoration: none; }
135
+ & button.primary,
136
+ & .button.primary,
137
+ & input[type="submit"].primary { border-color: var(--lvc-accent); background: var(--lvc-accent); color: var(--lvc-accent-text); }
138
+ & button.danger,
139
+ & .button.danger { color: var(--lvc-danger); }
140
+ & button.small,
141
+ & .button.small { min-height: 32px; padding: 4px 10px; font-size: 13px; }
142
+ & .actions,
143
+ & .form-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
144
+ & .actions form { display: inline; }
145
+ & .form-actions { padding-top: 12px; }
146
+
147
+ & .card { overflow: hidden; border: 1px solid var(--lvc-border); border-radius: 12px; background: var(--lvc-surface); }
148
+ & .card.pad { padding: 16px; overflow: visible; }
93
149
  & table { width: 100%; border-collapse: collapse; }
94
150
  & th,
95
151
  & td { padding: 10px 14px; text-align: left; vertical-align: top; }
96
152
  & th { font-size: 12px; text-transform: uppercase; letter-spacing: .04em; color: var(--lvc-muted); border-bottom: 1px solid var(--lvc-border); }
97
153
  & tr + tr td { border-top: 1px solid var(--lvc-border); }
98
- & tr.unread td { font-weight: 600; }
99
- & .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: 12px; font-weight: 600; color: #fff; white-space: nowrap; }
154
+ & td.row-actions { white-space: nowrap; text-align: right; }
155
+ & td.row-actions form { display: inline-block; }
156
+ & td.row-actions form + form { margin-left: 6px; }
157
+
158
+ & .badge { display: inline-flex; padding: 2px 10px; border-radius: 999px; color: #fff; font-size: 12px; font-weight: 600; white-space: nowrap; }
159
+ & .badge.locale { border: 1px solid var(--lvc-border); background: transparent; color: var(--lvc-muted); }
160
+ & code.key { max-width: 100%; padding: 2px 7px; border-radius: 6px; background: var(--lvc-code); font: 12.5px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
161
+
162
+ & .flash,
163
+ & .errors { margin-bottom: 16px; padding: 10px 12px; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); }
164
+ & .flash.notice { border-color: var(--lvc-success); }
165
+ & .flash.alert,
166
+ & .errors { border-color: var(--lvc-danger); }
167
+ & .errors ul { margin-bottom: 0; }
168
+
169
+ & dl { display: grid; grid-template-columns: max-content minmax(0, 1fr); gap: 6px 16px; margin: 0; }
170
+ & dt { color: var(--lvc-muted); }
171
+ & dd { margin: 0; overflow-wrap: anywhere; }
172
+ & .message { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; }
173
+
174
+ & .empty { padding: 48px 16px; color: var(--lvc-muted); text-align: center; }
175
+ & .empty-state { max-width: 320px; margin: auto; padding: 24px; text-align: center; }
176
+ & .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
177
+ & .empty-state p { margin: 0; }
178
+
179
+ /* The two-pane dashboard: the queue on the left, the selected record on the
180
+ right, each pane owning its own scroll. */
181
+ & .dashboard-shell { display: grid; flex: 1 1 auto; grid-template-columns: minmax(330px, 430px) 1fr; gap: 16px; min-height: 0; }
182
+ & .dashboard-sidebar { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; border: 1px solid var(--lvc-border); border-radius: 8px; background: var(--lvc-surface); }
183
+ & .dashboard-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; }
184
+ & .dashboard-sidebar .filters { flex: 0 0 auto; }
185
+
186
+ /* One row shape for every gem: a topline of badges and a timestamp, the copy
187
+ that identifies the record, then a muted line of ids and authors. */
188
+ & .record-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
189
+ & .record-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; border-bottom: 1px solid var(--lvc-border); color: var(--lvc-text); }
190
+ & .record-row:hover { background: var(--lvc-bg); text-decoration: none; }
191
+ & .record-row.active { background: color-mix(in srgb, var(--lvc-accent) 9%, var(--lvc-surface)); box-shadow: inset 3px 0 0 var(--lvc-accent); }
192
+ & .record-main { display: flex; min-width: 0; flex-direction: column; gap: 5px; }
193
+ & .record-topline,
194
+ & .record-meta { display: flex; align-items: center; gap: 8px; min-width: 0; }
195
+ & .record-name,
196
+ & .record-copy,
197
+ & .record-meta span,
198
+ /* A key is `overflow-wrap: anywhere` in prose; in a row it truncates. */
199
+ & .record-topline code.key,
200
+ & .record-meta code.key { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
201
+ & .record-name { font-weight: 600; }
202
+ & .record-copy { display: block; font-weight: 600; }
203
+ & .record-time { margin-left: auto; white-space: nowrap; }
204
+ & .record-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; padding-top: 1px; }
205
+ & .pager { display: flex; flex: 0 0 auto; justify-content: space-between; gap: 16px; min-height: 42px; margin: 0; padding: 10px 12px; border-top: 1px solid var(--lvc-border); }
206
+
207
+ /* The selected record. `.detail-scroll` is the only part that scrolls, so the
208
+ panel heading and its actions stay put. */
209
+ & .dashboard-detail { display: flex; min-width: 0; min-height: 0; flex-direction: column; overflow: hidden; }
210
+ & .dashboard-detail .detail-panel { flex: 1 1 auto; min-height: 0; }
211
+ & .dashboard-detail .detail-scroll { flex: 1 1 auto; overflow-y: auto; padding-right: 2px; }
212
+ & .dashboard-detail .actions { margin-bottom: 0; }
213
+ & .detail-panel { display: flex; min-width: 0; flex-direction: column; }
214
+ /* The panel's own heading row: the title on the left, whatever acts on the
215
+ whole record on the right. */
216
+ & .panel-head { display: flex; flex: 0 0 auto; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
217
+ & .panel-head h1 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin: 0; }
218
+ & .detail-scroll { display: flex; min-height: 0; flex-direction: column; gap: 12px; }
219
+ & .mobile-back { display: none; }
220
+ }
221
+
222
+ /* Page frame — the gem's own layout only. After the components, so it wins the
223
+ specificity ties against them. */
224
+ .lvc-page { margin: 0; min-height: 100vh; background: var(--lvc-bg); color: var(--lvc-text); font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif; letter-spacing: 0; }
225
+ /* An index page is the dashboard: it fills the viewport and never scrolls
226
+ itself, because the queue and the detail pane each scroll on their own. */
227
+ .lvc-index, .lvc-index .container { height: 100vh; height: 100dvh; }
228
+ .lvc-index { overflow: hidden; }
229
+ .lvc-index .container { display: flex; max-width: 1280px; flex-direction: column; padding-bottom: 16px; }
230
+ .lvc-index .nav,
231
+ .lvc-index .page-head { flex: 0 0 auto; }
232
+ /* Standalone show pages keep normal page scrolling. */
233
+ .lvc-show { min-height: 100vh; overflow: auto; }
234
+ .lvc-show .detail-panel { width: 100%; }
235
+ .lvc-show .detail-scroll { overflow: visible; }
236
+
237
+ /* Narrow screens: one pane at a time, the queue until a record is picked.
238
+ Last in the file, so these overrides actually reach the rules above. */
239
+ @media (max-width: 760px) {
240
+ .lvc-page { font-size: 14px; }
241
+ .lvc-index, .lvc-show { overflow-x: hidden; }
242
+ .lvc-index { overflow-y: auto; }
243
+ .lvc-index, .lvc-index .container { min-height: 100vh; height: auto; }
244
+ .lvc-index .container { padding-bottom: 10px; }
245
+ .lvc-dashboard {
246
+ & .container { padding: 14px 10px 24px; }
247
+ & .page-head { margin-bottom: 12px; }
248
+ & .detail-panel,
249
+ & .detail-scroll,
250
+ & .card,
251
+ & dl,
252
+ & dd { min-width: 0; max-width: 100%; }
253
+ & .dashboard-detail .card { width: 100%; }
254
+ & .message,
255
+ & .lede,
256
+ & dd,
257
+ & .muted { overflow-wrap: anywhere; }
258
+ & .dashboard-shell { display: block; min-height: calc(100vh - 62px); }
259
+ & .dashboard-sidebar,
260
+ & .dashboard-detail { min-height: calc(100vh - 62px); }
261
+ & .dashboard-shell.has-selected .dashboard-sidebar { display: none; }
262
+ & .dashboard-shell:not(.has-selected) .dashboard-detail { display: none; }
263
+ & .record-list { overflow: visible; }
264
+ & .record-row { padding: 12px; }
265
+ & .record-meta { display: none; }
266
+ & .dashboard-detail .detail-panel { min-height: calc(100vh - 64px); }
267
+ & .mobile-back { display: block; flex: 0 0 auto; margin: 4px 0 10px; font-size: 13px; }
268
+ & .panel-head h1 { font-size: 18px; }
269
+ & dl { grid-template-columns: 1fr; gap: 3px; }
270
+ & dd { margin-bottom: 8px; }
271
+ }
272
+ }
273
+
274
+ /* ---------------------------------------------------------------------------
275
+ * GEM-SPECIFIC — livechat only: the open/resolved toggle, agent avatars, the
276
+ * message thread, and the composer.
277
+ * ------------------------------------------------------------------------- */
278
+
279
+ :root {
280
+ --lvc-open: var(--lvc-success);
281
+ --lvc-resolved: var(--lvc-neutral);
282
+ }
283
+
284
+ .lvc-dashboard {
100
285
  & .badge.status-open { background: var(--lvc-open); }
101
286
  & .badge.status-resolved { background: var(--lvc-resolved); }
102
287
  & .badge.unread-count { background: var(--lvc-danger); }
103
- & .muted { color: var(--lvc-muted); font-size: 13px; }
104
- & .pager { margin-top: 16px; display: flex; gap: 16px; }
105
- & .empty { padding: 48px 16px; text-align: center; color: var(--lvc-muted); }
106
- & .breadcrumb { margin-bottom: 12px; font-size: 13px; }
107
- & .flash { margin: 0 0 16px; padding: 10px 14px; border-radius: 10px; border: 1px solid var(--lvc-border); background: var(--lvc-surface); }
108
- & .flash.alert { color: var(--lvc-danger); border-color: var(--lvc-danger); }
109
- & .head-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 12px; }
110
- & .head-row h1 { margin: 0; flex: 1; }
111
- & .head-row form { display: inline; }
288
+ & tr.unread td { font-weight: 600; }
289
+ & .record-row.unread .record-name,
290
+ & .record-row.unread .record-copy { font-weight: 700; }
291
+ & .record-side .avatars { min-height: 26px; }
292
+
293
+ /* One switch reading open|resolved, sliding to the side it is on. */
112
294
  & .status-toggle-form { flex: 0 0 auto; }
113
295
  & .status-toggle { position: relative; display: grid; grid-template-columns: 1fr 1fr; gap: 0; width: 176px; min-height: 36px; padding: 3px; border-radius: 999px; border: 1px solid var(--lvc-border); background: var(--lvc-bg); color: var(--lvc-muted); overflow: hidden; }
114
296
  & .status-toggle::before { content: ""; position: absolute; z-index: 0; top: 3px; bottom: 3px; width: calc(50% - 3px); border-radius: 999px; background: var(--lvc-surface); box-shadow: 0 1px 2px rgba(15, 23, 42, .12); transition: left .16s ease; }
@@ -118,10 +300,7 @@
118
300
  & .status-toggle.is-open span:first-child { color: var(--lvc-open); }
119
301
  & .status-toggle.is-resolved span:last-child { color: var(--lvc-text); }
120
302
  & .status-toggle:hover { text-decoration: none; color: var(--lvc-text); }
121
- & button,
122
- & .button { padding: 8px 14px; border: 1px solid var(--lvc-border); border-radius: 8px; cursor: pointer; background: var(--lvc-surface); color: var(--lvc-text); font: inherit; }
123
- & button.primary { background: var(--lvc-accent); border-color: var(--lvc-accent); color: var(--lvc-accent-text); }
124
- & .case-id { color: var(--lvc-muted); font-weight: 400; font-size: 15px; }
303
+
125
304
  & .avatars { display: inline-flex; }
126
305
  & .avatars .avatar { width: 26px; height: 26px; border-radius: 50%; display: inline-flex; align-items: center; justify-content: center; font-size: 10px; font-weight: 700; color: #fff; border: 2px solid var(--lvc-surface); cursor: default; }
127
306
  & .avatars .avatar + .avatar { margin-left: -8px; }
@@ -131,6 +310,8 @@
131
310
  & .avatar.color-3 { background: #dc2626; }
132
311
  & .avatar.color-4 { background: #7c3aed; }
133
312
  & .avatar.color-5 { background: #0891b2; }
313
+
314
+ /* The thread: the visitor on the left, agents on the right. */
134
315
  & .context-line { margin: -6px 0 16px; overflow-wrap: anywhere; }
135
316
  & .thread { display: flex; flex-direction: column; gap: 4px; padding: 16px; }
136
317
  & .thread .who { font-size: 12px; color: var(--lvc-muted); margin: 10px 4px 2px; }
@@ -144,75 +325,66 @@
144
325
  & .thread .msg .atts { display: flex; flex-direction: column; gap: 6px; margin-top: 6px; }
145
326
  & .thread .msg .att-img img { max-width: 100%; max-height: 240px; border-radius: 8px; display: block; }
146
327
  & .thread .msg .att-audio { display: block; width: 280px; max-width: 100%; height: 36px; }
147
- & .thread .msg .att-file { display: inline-block; padding: 6px 10px; border-radius: 8px; background: rgba(0,0,0,.06); color: inherit; font-size: 13px; overflow-wrap: anywhere; }
148
- & .thread .msg.agent .att-file { background: rgba(255,255,255,.2); }
328
+ & .thread .msg .att-file { display: inline-block; padding: 6px 10px; border-radius: 8px; background: rgba(0, 0, 0, .06); color: inherit; font-size: 13px; overflow-wrap: anywhere; }
329
+ & .thread .msg.agent .att-file { background: rgba(255, 255, 255, .2); }
149
330
  & #new-messages { margin: 12px 0 0; }
150
331
  & #new-messages a { font-weight: 600; }
151
- /* Unified composer box (matches the visitor widget): the reply on top, a toolbar row below (tools left, send right), all inside one bordered box that lights up on focus. */
332
+
333
+ /* Unified composer box (matches the visitor widget): the reply on top, a
334
+ toolbar row below (tools left, send right), all inside one bordered box
335
+ that lights up on focus. */
152
336
  & .reply { display: flex; flex-direction: column; gap: 6px; margin: 10px 12px; padding: 8px 10px; border: 1px solid var(--lvc-border); border-radius: 14px; background: var(--lvc-bg); }
153
337
  & .reply:focus-within { border-color: var(--lvc-accent); }
154
- & .reply textarea { -webkit-appearance: none; appearance: none; border: none; background: none; resize: none; outline: none; font: inherit; color: var(--lvc-text); padding: 4px 4px 0; min-height: 40px; max-height: 160px; overflow-y: auto; }
155
- & .reply input[type=file].reply-file { font-size: 12px; color: var(--lvc-muted); }
338
+ & .reply textarea { -webkit-appearance: none; appearance: none; min-height: 40px; max-height: 160px; padding: 4px 4px 0; border: none; background: none; resize: none; outline: none; font: inherit; color: var(--lvc-text); overflow-y: auto; }
339
+ & .reply input[type="file"].reply-file { min-height: 0; padding: 0; border: 0; background: none; font-size: 12px; color: var(--lvc-muted); }
156
340
  & .reply-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
157
341
  & .reply-tools { display: flex; align-items: center; gap: 2px; }
158
- & .reply .attach { width: 32px; height: 32px; padding: 0; border: none; background: none; color: var(--lvc-muted); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
342
+ & .reply .attach { width: 32px; min-height: 32px; height: 32px; padding: 0; border: none; background: none; color: var(--lvc-muted); border-radius: 8px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
159
343
  & .reply .attach:hover { background: var(--lvc-border); color: var(--lvc-text); }
160
344
  & .reply .attach.recording-on { color: var(--lvc-danger); }
161
345
  & .reply.is-recording .attach { display: none; }
162
346
  & .reply .chips { display: flex; flex-wrap: wrap; gap: 6px; }
163
347
  & .reply .chip { display: inline-flex; align-items: center; gap: 6px; max-width: 100%; padding: 4px 6px 4px 10px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); font-size: 12px; }
164
348
  & .reply .chip-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 220px; }
165
- & .reply .chip-x { padding: 0 2px; border: none; background: none; color: var(--lvc-muted); font-size: 16px; line-height: 1; cursor: pointer; }
349
+ & .reply .chip-x { min-height: 0; padding: 0 2px; border: none; background: none; color: var(--lvc-muted); font-size: 16px; line-height: 1; cursor: pointer; }
166
350
  & .reply .chip-x:hover { color: var(--lvc-text); }
167
351
  & .reply .recording { display: inline-flex; align-items: center; gap: 8px; height: 32px; padding: 0 4px 0 8px; border: 1px solid var(--lvc-border); border-radius: 999px; background: var(--lvc-surface); color: var(--lvc-muted); font-size: 12px; white-space: nowrap; }
168
352
  & .reply .recording[hidden] { display: none; }
169
353
  & .reply .recording-status { display: inline-grid; grid-template-columns: 8px 24px 34px; align-items: center; gap: 6px; font-variant-numeric: tabular-nums; }
170
- & .reply .recording-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lvc-danger); box-shadow: 0 0 0 3px rgba(220,38,38,.12); }
354
+ & .reply .recording-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--lvc-danger); box-shadow: 0 0 0 3px rgba(220, 38, 38, .12); }
171
355
  & .reply .recording-rec { font-size: 10px; font-weight: 700; letter-spacing: .04em; color: var(--lvc-danger); }
172
356
  & .reply .recording-actions { display: flex; align-items: center; gap: 4px; }
173
- & .reply .recording-actions button { width: 24px; height: 24px; padding: 0; border: none; border-radius: 50%; background: none; color: var(--lvc-muted); display: flex; align-items: center; justify-content: center; }
357
+ & .reply .recording-actions button { width: 24px; height: 24px; min-height: 24px; padding: 0; border: none; border-radius: 50%; background: none; color: var(--lvc-muted); display: flex; align-items: center; justify-content: center; }
174
358
  & .reply .recording-actions button:hover { background: var(--lvc-border); color: var(--lvc-text); }
175
359
  & .reply .recording-actions .stop-recording { background: var(--lvc-danger); color: #fff; }
176
360
  & .reply .recording-actions .stop-recording:hover { background: #b91c1c; color: #fff; }
177
- & .reply .send { width: 32px; min-width: 32px; height: 32px; padding: 0; border: none; border-radius: 50%; background: var(--lvc-accent); color: var(--lvc-accent-text); cursor: pointer; display: flex; align-items: center; justify-content: center; }
178
- & .inbox-shell { flex: 1 1 auto; min-height: 0; display: grid; grid-template-columns: minmax(320px, 380px) 1fr; gap: 16px; }
179
- & .inbox-sidebar { min-width: 0; min-height: 0; display: flex; flex-direction: column; background: var(--lvc-surface); border: 1px solid var(--lvc-border); border-radius: 8px; overflow: hidden; }
180
- & .inbox-sidebar .tabs { flex: 0 0 auto; margin: 12px 12px 0; padding: 0; background: transparent; border: 0; border-radius: 0; gap: 6px; flex-wrap: nowrap; }
181
- & .inbox-sidebar .tabs a { flex: 1 1 0; position: relative; display: flex; align-items: center; justify-content: center; gap: 6px; min-height: 34px; padding: 5px 12px 8px; border-radius: 8px; color: var(--lvc-muted); font-weight: 600; }
182
- & .inbox-sidebar .tabs a.active { color: var(--lvc-text); border: 0; background: transparent; box-shadow: none; margin: 0; }
183
- & .inbox-sidebar .tabs a.active::after { content: ""; position: absolute; left: 24px; right: 24px; bottom: 0; height: 2px; border-radius: 999px; background: var(--lvc-accent); }
184
- & .inbox-sidebar .tabs a:not(.active):hover { text-decoration: none; color: var(--lvc-text); background: var(--lvc-bg); }
185
- & .inbox-sidebar .tabs .count { margin-left: 0; background: color-mix(in srgb, var(--lvc-muted) 14%, transparent); }
186
- & .inbox-sidebar .tabs a.active .count { background: var(--lvc-border); }
187
- & .inbox-sidebar .filters { flex: 0 0 auto; margin: 0; padding: 12px; border-bottom: 1px solid var(--lvc-border); }
188
- & .inbox-sidebar .filters input[type=search] { max-width: none; }
189
- & .conversation-list { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
190
- & .conversation-row { display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 12px; padding: 13px 14px; color: var(--lvc-text); border-bottom: 1px solid var(--lvc-border); }
191
- & .conversation-row:hover { text-decoration: none; background: var(--lvc-bg); }
192
- & .conversation-row.active { background: color-mix(in srgb, var(--lvc-accent) 9%, var(--lvc-surface)); box-shadow: inset 3px 0 0 var(--lvc-accent); }
193
- & .conversation-row.unread .conversation-name,
194
- & .conversation-row.unread .conversation-preview { font-weight: 700; }
195
- & .conversation-main { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
196
- & .conversation-topline,
197
- & .conversation-meta { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
198
- & .conversation-name,
199
- & .conversation-preview,
200
- & .conversation-meta span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
201
- & .conversation-name { font-weight: 600; }
202
- & .conversation-time { margin-left: auto; white-space: nowrap; }
203
- & .conversation-preview { display: block; }
204
- & .conversation-side { display: flex; flex-direction: column; align-items: flex-end; justify-content: space-between; gap: 8px; padding-top: 1px; }
205
- & .conversation-side .avatars { min-height: 26px; }
206
- & .inbox-sidebar .pager { flex: 0 0 auto; margin: 0; padding: 10px 12px; border-top: 1px solid var(--lvc-border); }
207
- & .inbox-thread { min-width: 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
208
- & .inbox-thread .conversation-panel { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; padding: 0; }
209
- & .conversation-panel .head-row,
210
- & .conversation-panel .context-line { flex: 0 0 auto; }
211
- & .conversation-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-radius: 8px; }
212
- & .conversation-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
213
- & .conversation-panel .reply { flex: 0 0 auto; }
214
- & .empty-state { margin: auto; max-width: 320px; padding: 24px; text-align: center; }
215
- & .empty-state h2 { margin: 0 0 6px; font-size: 18px; }
216
- & .empty-state p { margin: 0; }
217
- & .mobile-back { display: none; }
361
+ & .reply .send { width: 32px; min-width: 32px; height: 32px; min-height: 32px; padding: 0; border: none; border-radius: 50%; background: var(--lvc-accent); color: var(--lvc-accent-text); cursor: pointer; display: flex; align-items: center; justify-content: center; }
362
+
363
+ /* Inside the thread pane only the messages scroll, so a reply never jumps the
364
+ page and the composer stays put. */
365
+ & .detail-panel .panel-head,
366
+ & .detail-panel .context-line { flex: 0 0 auto; }
367
+ & .detail-panel .card { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; border-radius: 8px; }
368
+ & .detail-panel .thread { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
369
+ & .detail-panel .reply { flex: 0 0 auto; }
370
+ }
371
+
372
+ /* The conversation page: same thread, its own page. Fills the viewport and
373
+ scrolls the thread rather than the page. */
374
+ .lvc-convo, .lvc-convo .container { height: 100vh; height: 100dvh; }
375
+ .lvc-convo { overflow: hidden; }
376
+ .lvc-convo .container { display: flex; flex-direction: column; padding-bottom: 16px; }
377
+ .lvc-convo .breadcrumb,
378
+ .lvc-convo .panel-head,
379
+ .lvc-convo .context-line,
380
+ .lvc-convo .flash { flex: 0 0 auto; }
381
+ .lvc-convo .detail-panel { flex: 1 1 auto; min-height: 0; }
382
+
383
+ @media (max-width: 760px) {
384
+ .lvc-dashboard {
385
+ & .detail-panel .panel-head h1 { font-size: 18px; }
386
+ & .status-toggle { width: 152px; min-height: 34px; }
387
+ & .status-toggle span { padding: 0 7px; font-size: 12px; }
388
+ & .thread .msg { max-width: 88%; }
389
+ }
218
390
  }
@@ -295,7 +295,7 @@
295
295
  // (new message, new thread, resolve/reopen), just reload — unless the
296
296
  // agent is mid-search, whose typing must never be eaten.
297
297
  function watchIndex() {
298
- var index = document.getElementById("lvc-index");
298
+ var index = document.getElementById("lvc-poll");
299
299
  if (!index) return;
300
300
 
301
301
  var token = index.getAttribute("data-token");
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Livechat
4
- VERSION = '0.8.1'
4
+ VERSION = '0.9.0'
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: livechat
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.1
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Yaroslav Shmarov