paper_view 0.1.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.
@@ -0,0 +1,98 @@
1
+ module PaperView
2
+ class VersionRow
3
+ MAX_LINES = 3
4
+
5
+ attr_reader :version, :change_set, :burst_size
6
+
7
+ def initialize(version, change_set: nil, burst_size: 1)
8
+ @version = version
9
+ @change_set = change_set || ChangeSet.for(version)
10
+ @burst_size = burst_size
11
+ end
12
+
13
+ def id
14
+ version.id
15
+ end
16
+
17
+ def event
18
+ version.event
19
+ end
20
+
21
+ def date
22
+ version.created_at&.to_date
23
+ end
24
+
25
+ def time
26
+ version.created_at&.strftime("%H:%M")
27
+ end
28
+
29
+ def item_label
30
+ "#{version.item_type} ##{version.item_id}"
31
+ end
32
+
33
+ def item_key
34
+ [version.item_type, version.item_id]
35
+ end
36
+
37
+ def burst?
38
+ burst_size > 1
39
+ end
40
+
41
+ def burst_label
42
+ "#{burst_size} versions within 1s"
43
+ end
44
+
45
+ def destroyed?
46
+ event == "destroy"
47
+ end
48
+
49
+ def lines
50
+ leaves.first(MAX_LINES)
51
+ end
52
+
53
+ def footnotes
54
+ notes = []
55
+ notes << "payload not deserializable" if change_set.unreadable?
56
+ notes << "..." if overflow_count.positive?
57
+ notes << "no attribute changes recorded" if leaves.empty? && !change_set.unreadable?
58
+ notes
59
+ end
60
+
61
+ def console_snippet
62
+ destroyed? ? restore_snippet : rollback_snippet
63
+ end
64
+
65
+ private
66
+
67
+ def rollback_snippet
68
+ <<~RUBY
69
+ version = #{PaperView.config.version_class_name}.find(#{id})
70
+ current = version.item # current record
71
+
72
+ version.changeset # { attr => [old, new] }
73
+ previous = version.changeset.transform_values(&:first)
74
+
75
+ # current.update!(previous) # roll back only these attributes
76
+ RUBY
77
+ end
78
+
79
+ def restore_snippet
80
+ <<~RUBY
81
+ version = #{PaperView.config.version_class_name}.find(#{id})
82
+ item = version.reify # the deleted record
83
+
84
+ item.attributes # columns added since are nil here
85
+
86
+ # item.save! # re-create the record
87
+ RUBY
88
+ end
89
+
90
+ def leaves
91
+ @leaves ||= destroyed? ? [LeafChange.new("record", item_label, nil)] : change_set.flat_map(&:leaves)
92
+ end
93
+
94
+ def overflow_count
95
+ leaves.size - MAX_LINES
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,57 @@
1
+ module PaperView
2
+ class VersionsTable
3
+ def item_types
4
+ return distinct_scan unless postgresql? && item_type_indexed?
5
+
6
+ connection.select_values(skip_scan_sql)
7
+ end
8
+
9
+ def item_type_indexed?
10
+ return @item_type_indexed if defined?(@item_type_indexed)
11
+
12
+ @item_type_indexed = connection.indexes(version_class.table_name).any? { |index| Array(index.columns).first == "item_type" }
13
+ end
14
+
15
+ private
16
+
17
+ def version_class
18
+ PaperView.version_class
19
+ end
20
+
21
+ def connection
22
+ version_class.connection
23
+ end
24
+
25
+ def postgresql?
26
+ connection.adapter_name.match?(/postgres/i)
27
+ end
28
+
29
+ def distinct_scan
30
+ version_class.distinct.order(:item_type).pluck(:item_type)
31
+ end
32
+
33
+ # A plain DISTINCT reads every row. This walks the item_type index
34
+ # and jumps straight from one item_type to the next one.
35
+ def skip_scan_sql
36
+ table = version_class.quoted_table_name
37
+ column = connection.quote_column_name("item_type")
38
+
39
+ <<~SQL
40
+ WITH RECURSIVE paper_view_item_types AS (
41
+ (SELECT #{column} FROM #{table} ORDER BY #{column} LIMIT 1)
42
+ UNION ALL
43
+ SELECT (
44
+ SELECT successor.#{column}
45
+ FROM #{table} successor
46
+ WHERE successor.#{column} > paper_view_item_types.#{column}
47
+ ORDER BY successor.#{column}
48
+ LIMIT 1
49
+ )
50
+ FROM paper_view_item_types
51
+ WHERE paper_view_item_types.#{column} IS NOT NULL
52
+ )
53
+ SELECT #{column} FROM paper_view_item_types WHERE #{column} IS NOT NULL
54
+ SQL
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,36 @@
1
+ <!DOCTYPE html>
2
+
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="utf-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1">
7
+ <meta name="turbo-visit-control" content="reload">
8
+ <meta name="robots" content="noindex, nofollow">
9
+
10
+ <title>PaperView</title>
11
+ <%= tag.link rel: "icon", href: 'data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="10 0 100 100"><text y=".90em" font-size="90">👀</text></svg>' %>
12
+ <%= csrf_meta_tags %>
13
+ <%= csp_meta_tag %>
14
+ <%= render "paper_view/shared/styles" %>
15
+ </head>
16
+
17
+ <body>
18
+ <header class="pv-header">
19
+ <%= link_to root_path, class: "pv-header__brand" do %>
20
+ <span class="pv-header__dot"></span>
21
+ PaperView
22
+ <% end %>
23
+
24
+ <% if main_app.respond_to?(:root_path) %>
25
+ <span class="pv-header__meta"><%= link_to "← Back to app", main_app.root_path %></span>
26
+ <% end %>
27
+ </header>
28
+
29
+ <main class="pv-main pv-stack">
30
+ <%= render "paper_view/shared/flash" %>
31
+ <%= yield %>
32
+ </main>
33
+
34
+ <%= render "paper_view/shared/script" %>
35
+ </body>
36
+ </html>
@@ -0,0 +1,5 @@
1
+ <% flash.each do |type, message| %>
2
+ <div class="pv-flash pv-flash--<%= (type.to_s == "alert") ? "alert" : "notice" %>">
3
+ <%= message %>
4
+ </div>
5
+ <% end %>
@@ -0,0 +1,94 @@
1
+ <%= javascript_tag nonce: true do %>
2
+ document.addEventListener("submit", function (event) {
3
+ var button = event.target.querySelector("button[type=submit]");
4
+ if (button && !button.querySelector(".pv-spinner")) {
5
+ button.classList.add("pv-btn--busy");
6
+ button.insertAdjacentHTML("beforeend", '<span class="pv-spinner"></span>');
7
+ }
8
+ });
9
+
10
+ document.addEventListener("change", function (event) {
11
+ var select = event.target.closest("select[data-pv-autosubmit]");
12
+ if (!select || !select.form) return;
13
+
14
+ if (select.form.requestSubmit) {
15
+ select.form.requestSubmit();
16
+ } else {
17
+ select.form.submit();
18
+ }
19
+ });
20
+
21
+ window.addEventListener("pageshow", function () {
22
+ document.querySelectorAll(".pv-btn--busy").forEach(function (button) {
23
+ button.classList.remove("pv-btn--busy");
24
+ button.querySelectorAll(".pv-spinner").forEach(function (spinner) { spinner.remove(); });
25
+ });
26
+ });
27
+
28
+ (function () {
29
+ var timeline = document.querySelector("[data-pv-timeline]");
30
+ var panels = document.querySelector("[data-pv-panels]");
31
+ if (!timeline || !panels) return;
32
+
33
+ function collapse(panel) {
34
+ panel.querySelectorAll("[data-pv-toggle]").forEach(function (toggle) { setExpanded(toggle, false); });
35
+ panel.querySelectorAll("[data-pv-copy]").forEach(function (button) { button.textContent = "Copy"; });
36
+ }
37
+
38
+ function setExpanded(toggle, expanded) {
39
+ var target = document.getElementById(toggle.getAttribute("data-pv-toggle"));
40
+ toggle.setAttribute("aria-expanded", expanded);
41
+ toggle.querySelector(".pv-disclosure__arrow").textContent = expanded ? "▾" : "▸";
42
+ if (target) target.hidden = !expanded;
43
+ }
44
+
45
+ function select(id) {
46
+ timeline.querySelectorAll("[data-pv-version]").forEach(function (row) {
47
+ var active = row.getAttribute("data-pv-version") === id;
48
+ row.classList.toggle("pv-version--selected", active);
49
+ row.setAttribute("aria-pressed", active);
50
+ });
51
+
52
+ panels.querySelectorAll("[data-pv-panel]").forEach(function (panel) {
53
+ panel.hidden = panel.getAttribute("data-pv-panel") !== id;
54
+ if (panel.hidden) collapse(panel);
55
+ });
56
+
57
+ var url = new URL(window.location.href);
58
+ url.searchParams.set("version", id);
59
+ window.history.replaceState({}, "", url);
60
+ }
61
+
62
+ timeline.addEventListener("click", function (event) {
63
+ if (event.target.closest("a")) return;
64
+ var row = event.target.closest("[data-pv-version]");
65
+ if (row) select(row.getAttribute("data-pv-version"));
66
+ });
67
+
68
+ timeline.addEventListener("keydown", function (event) {
69
+ if (event.key !== "Enter" && event.key !== " ") return;
70
+ var row = event.target.closest("[data-pv-version]");
71
+ if (!row) return;
72
+ event.preventDefault();
73
+ select(row.getAttribute("data-pv-version"));
74
+ });
75
+
76
+ panels.addEventListener("click", function (event) {
77
+ var toggle = event.target.closest("[data-pv-toggle]");
78
+ if (toggle) {
79
+ setExpanded(toggle, toggle.getAttribute("aria-expanded") !== "true");
80
+ return;
81
+ }
82
+
83
+ var copy = event.target.closest("[data-pv-copy]");
84
+ if (!copy || !navigator.clipboard) return;
85
+ navigator.clipboard.writeText(document.getElementById(copy.getAttribute("data-pv-copy")).textContent);
86
+ copy.textContent = "Copied";
87
+ });
88
+
89
+ if (new URL(window.location.href).searchParams.get("version")) {
90
+ var selected = timeline.querySelector(".pv-version--selected");
91
+ if (selected) selected.scrollIntoView({block: "center"});
92
+ }
93
+ })();
94
+ <% end %>
@@ -0,0 +1,388 @@
1
+ <style<%= paper_view_nonce_attribute %>>
2
+ *, *::before, *::after { box-sizing: border-box; }
3
+ [hidden] { display: none !important; }
4
+
5
+ :root {
6
+ color-scheme: light dark;
7
+ --pv-bg: #f3f4f6;
8
+ --pv-surface: #ffffff;
9
+ --pv-surface-muted: #f9fafb;
10
+ --pv-row-hover: #f8fafc;
11
+ --pv-row-selected: #eff6ff;
12
+ --pv-border: #e5e7eb;
13
+ --pv-border-subtle: #f3f4f6;
14
+ --pv-border-input: #d1d5db;
15
+ --pv-pill-bg: #f3f4f6;
16
+ --pv-ink: #111827;
17
+ --pv-ink-secondary: #374151;
18
+ --pv-ink-muted: #4b5563;
19
+ --pv-ink-soft: #6b7280;
20
+ --pv-ink-faint: #9ca3af;
21
+ --pv-accent: #2563eb;
22
+ --pv-accent-hover: #1d4ed8;
23
+ --pv-on-accent: #ffffff;
24
+ --pv-dot: #cbd5e1;
25
+ --pv-del: #b91c1c;
26
+ --pv-del-bg: #fef2f2;
27
+ --pv-del-rule: #fca5a5;
28
+ --pv-add: #15803d;
29
+ --pv-add-bg: #f0fdf4;
30
+ --pv-add-rule: #86efac;
31
+ --pv-warn: #92400e;
32
+ --pv-warn-bg: #fef3c7;
33
+ --pv-radius: 10px;
34
+ --pv-header-h: 48px;
35
+ --pv-sans: ui-sans-serif, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
36
+ --pv-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
37
+ }
38
+
39
+ @media (prefers-color-scheme: dark) {
40
+ :root {
41
+ --pv-bg: #0d1117;
42
+ --pv-surface: #161b22;
43
+ --pv-surface-muted: #1b212a;
44
+ --pv-row-hover: #1b212a;
45
+ --pv-row-selected: #16233b;
46
+ --pv-border: #272e38;
47
+ --pv-border-subtle: #21262d;
48
+ --pv-border-input: #39414d;
49
+ --pv-pill-bg: #21262d;
50
+ --pv-ink: #e6edf3;
51
+ --pv-ink-secondary: #c9d1d9;
52
+ --pv-ink-muted: #b1bac4;
53
+ --pv-ink-soft: #9198a1;
54
+ --pv-ink-faint: #6e7681;
55
+ --pv-accent: #4a8bff;
56
+ --pv-accent-hover: #79a9ff;
57
+ --pv-dot: #39414d;
58
+ --pv-del: #ff7b72;
59
+ --pv-del-bg: #2d1618;
60
+ --pv-del-rule: #7d2a2a;
61
+ --pv-add: #57d364;
62
+ --pv-add-bg: #12261a;
63
+ --pv-add-rule: #2b6b36;
64
+ --pv-warn: #e3b341;
65
+ --pv-warn-bg: #2b2209;
66
+ }
67
+ }
68
+
69
+ body {
70
+ margin: 0;
71
+ background: var(--pv-bg);
72
+ color: var(--pv-ink);
73
+ font-family: var(--pv-sans);
74
+ font-size: 14px;
75
+ line-height: 1.5;
76
+ -webkit-font-smoothing: antialiased;
77
+ }
78
+
79
+ a { color: var(--pv-accent); text-decoration: none; }
80
+ a:hover { color: var(--pv-accent-hover); text-decoration: underline; }
81
+ h1, h2, h3 { margin: 0; font-weight: 600; letter-spacing: -.01em; }
82
+ code { font-family: var(--pv-mono); font-size: .92em; }
83
+
84
+ .pv-header {
85
+ position: sticky;
86
+ top: 0;
87
+ z-index: 10;
88
+ display: flex;
89
+ align-items: center;
90
+ gap: 16px;
91
+ padding: 12px 24px;
92
+ background: var(--pv-surface);
93
+ border-bottom: 1px solid var(--pv-border);
94
+ }
95
+ .pv-header__brand { display: flex; align-items: baseline; gap: 8px; color: var(--pv-ink); font-size: 15px; font-weight: 650; }
96
+ .pv-header__brand:hover { color: var(--pv-ink); text-decoration: none; }
97
+ .pv-header__dot { width: 8px; height: 8px; border-radius: 50%; background: var(--pv-accent); }
98
+ .pv-header__meta { margin-left: auto; color: var(--pv-ink-soft); font-size: 12.5px; }
99
+
100
+ .pv-main { max-width: 1560px; margin: 0 auto; padding: 24px; }
101
+ .pv-stack { display: flex; flex-direction: column; gap: 16px; }
102
+ .pv-spacer { margin-left: auto; }
103
+ .pv-muted { color: var(--pv-ink-soft); }
104
+ .pv-mono { font-family: var(--pv-mono); font-size: 12.5px; }
105
+
106
+ .pv-panel {
107
+ background: var(--pv-surface);
108
+ border: 1px solid var(--pv-border);
109
+ border-radius: var(--pv-radius);
110
+ overflow: hidden;
111
+ }
112
+ .pv-panel__body { padding: 16px; }
113
+
114
+ .pv-flash { padding: 12px 14px; border-radius: var(--pv-radius); border: 1px solid; font-size: 13.5px; }
115
+ .pv-flash--notice { background: var(--pv-add-bg); border-color: var(--pv-add); color: var(--pv-add); }
116
+ .pv-flash--alert { background: var(--pv-del-bg); border-color: var(--pv-del); color: var(--pv-del); }
117
+
118
+ .pv-search { display: flex; align-items: flex-end; gap: 12px; flex-wrap: wrap; }
119
+ .pv-field { display: flex; flex-direction: column; gap: 5px; min-width: 120px; }
120
+ .pv-field--grow { flex: 1 1 240px; max-width: 340px; }
121
+ .pv-field--actions { flex-direction: row; gap: 8px; min-width: auto; }
122
+ .pv-field > label { font-size: 11.5px; font-weight: 600; letter-spacing: .04em; text-transform: uppercase; color: var(--pv-ink-soft); }
123
+ .pv-input {
124
+ width: 100%;
125
+ padding: 7px 10px;
126
+ background: var(--pv-surface);
127
+ color: var(--pv-ink);
128
+ border: 1px solid var(--pv-border-input);
129
+ border-radius: 7px;
130
+ font: inherit;
131
+ font-size: 13px;
132
+ line-height: 1.45;
133
+ }
134
+ .pv-input:focus { outline: 2px solid var(--pv-accent); outline-offset: -1px; border-color: var(--pv-accent); }
135
+ select.pv-input {
136
+ appearance: none;
137
+ padding-right: 28px;
138
+ background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 10 6' fill='none' stroke='%239ca3af' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M1 1l4 4 4-4'/%3E%3C/svg%3E");
139
+ background-repeat: no-repeat;
140
+ background-position: right 10px center;
141
+ background-size: 10px 6px;
142
+ }
143
+
144
+ .pv-btn {
145
+ display: inline-flex;
146
+ align-items: center;
147
+ gap: 6px;
148
+ padding: 7px 13px;
149
+ border: 1px solid var(--pv-border-input);
150
+ border-radius: 7px;
151
+ background: var(--pv-surface);
152
+ color: var(--pv-ink);
153
+ font: inherit;
154
+ font-size: 13px;
155
+ font-weight: 550;
156
+ cursor: pointer;
157
+ white-space: nowrap;
158
+ }
159
+ .pv-btn:hover { background: var(--pv-surface-muted); color: var(--pv-ink); text-decoration: none; }
160
+ .pv-btn--primary { background: var(--pv-accent); border-color: var(--pv-accent); color: var(--pv-on-accent); }
161
+ .pv-btn--primary:hover { filter: brightness(1.07); background: var(--pv-accent); color: var(--pv-on-accent); }
162
+ .pv-btn[disabled], .pv-btn--disabled { opacity: .45; pointer-events: none; }
163
+ .pv-btn--busy { opacity: .75; pointer-events: none; }
164
+
165
+ .pv-spinner {
166
+ width: 13px;
167
+ height: 13px;
168
+ border: 2px solid currentColor;
169
+ border-right-color: transparent;
170
+ border-radius: 50%;
171
+ animation: pv-spin .6s linear infinite;
172
+ }
173
+ @keyframes pv-spin { to { transform: rotate(360deg); } }
174
+ @media (prefers-reduced-motion: reduce) { .pv-spinner { animation-duration: 2.4s; } }
175
+
176
+ .pv-panel--hero { width: 100%; max-width: 880px; margin: 32px auto 0; }
177
+ .pv-hero { padding: 48px 40px 40px; text-align: center; }
178
+ .pv-hero__title { font-size: 22px; }
179
+ .pv-hero__search { margin-top: 28px; text-align: left; }
180
+ .pv-hero__search .pv-field--grow { flex: 1 1 200px; }
181
+ .pv-hero__search .pv-field--actions { align-self: flex-end; }
182
+ .pv-hero__note { margin: 28px 0 0; color: var(--pv-ink-soft); }
183
+
184
+ .pv-layout { display: flex; align-items: flex-start; gap: 16px; }
185
+ .pv-card {
186
+ flex: 1;
187
+ min-width: 0;
188
+ background: var(--pv-surface);
189
+ border: 1px solid var(--pv-border);
190
+ border-radius: var(--pv-radius);
191
+ }
192
+ .pv-card__head {
193
+ display: flex;
194
+ align-items: baseline;
195
+ gap: 10px;
196
+ padding: 14px 18px;
197
+ background: var(--pv-surface-muted);
198
+ border-bottom: 1px solid var(--pv-border);
199
+ border-radius: var(--pv-radius) var(--pv-radius) 0 0;
200
+ }
201
+ .pv-card__title { font: 600 14px/1.2 var(--pv-sans); color: var(--pv-ink); }
202
+ .pv-card__meta { font: 400 13px/1.2 var(--pv-sans); color: var(--pv-ink-soft); }
203
+
204
+ .pv-timeline__day {
205
+ position: sticky;
206
+ top: var(--pv-header-h);
207
+ z-index: 1;
208
+ padding: 8px 18px;
209
+ background: var(--pv-surface-muted);
210
+ border-bottom: 1px solid var(--pv-border-subtle);
211
+ font: 600 11px/1.2 var(--pv-sans);
212
+ letter-spacing: .06em;
213
+ text-transform: uppercase;
214
+ color: var(--pv-ink-soft);
215
+ }
216
+
217
+ .pv-version {
218
+ display: grid;
219
+ grid-template-columns: 64px 22px 1fr auto;
220
+ gap: 0 12px;
221
+ padding: 12px 18px;
222
+ border-bottom: 1px solid var(--pv-border-subtle);
223
+ cursor: pointer;
224
+ }
225
+ .pv-version--stream { grid-template-columns: 64px 22px 168px 1fr auto; }
226
+ .pv-version:hover { background: var(--pv-row-hover); }
227
+ .pv-version--selected, .pv-version--selected:hover { background: var(--pv-row-selected); }
228
+ .pv-version:focus-visible { outline: 2px solid var(--pv-accent); outline-offset: -2px; }
229
+ .pv-timeline__group:last-child .pv-version:last-child { border-bottom: 0; }
230
+
231
+ .pv-version__time { font: 400 12px/20px var(--pv-mono); color: var(--pv-ink-soft); text-align: right; }
232
+ .pv-version__id { font: 400 12px/20px var(--pv-mono); color: var(--pv-ink-faint); }
233
+ .pv-version__rail { display: flex; flex-direction: column; align-items: center; }
234
+ .pv-version__dot { flex: none; width: 9px; height: 9px; margin-top: 6px; border-radius: 50%; background: var(--pv-dot); }
235
+ .pv-version__line { flex: 1; width: 1px; margin-top: 4px; background: var(--pv-border); }
236
+ .pv-version--selected .pv-version__dot { background: var(--pv-accent); }
237
+
238
+ .pv-version__item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
239
+ .pv-version__item-link { font: 600 13px/20px var(--pv-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
240
+ .pv-version__item .pv-pill { align-self: flex-start; }
241
+
242
+ .pv-version__content { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
243
+ .pv-version__meta { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
244
+ .pv-version__actor { font: 600 13px/20px var(--pv-sans); color: var(--pv-ink); }
245
+ .pv-version--stream .pv-version__actor { font-weight: 400; color: var(--pv-ink-secondary); }
246
+ .pv-version__note { font: 400 12px/18px var(--pv-sans); color: var(--pv-ink-soft); }
247
+
248
+ .pv-pill { font: 500 11px/16px var(--pv-sans); border-radius: 4px; padding: 1px 6px; white-space: nowrap; }
249
+ .pv-pill--event, .pv-pill--update { color: var(--pv-ink-muted); background: var(--pv-pill-bg); }
250
+ .pv-pill--create { color: var(--pv-add); background: var(--pv-add-bg); }
251
+ .pv-pill--destroy { color: var(--pv-del); background: var(--pv-del-bg); }
252
+ .pv-pill--burst { color: var(--pv-warn); background: var(--pv-warn-bg); }
253
+
254
+ .pv-change { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font: 400 12px/18px var(--pv-mono); }
255
+ .pv-change__path { color: var(--pv-ink); }
256
+ .pv-change__arrow { color: var(--pv-ink-faint); }
257
+ .pv-value {
258
+ max-width: 280px;
259
+ padding: 1px 6px;
260
+ border-radius: 4px;
261
+ overflow: hidden;
262
+ text-overflow: ellipsis;
263
+ white-space: nowrap;
264
+ }
265
+ .pv-version--stream .pv-value { max-width: 240px; }
266
+ .pv-value--old { color: var(--pv-del); background: var(--pv-del-bg); }
267
+ .pv-value--new { color: var(--pv-add); background: var(--pv-add-bg); }
268
+
269
+ .pv-side { position: sticky; top: calc(var(--pv-header-h) + 16px); width: 330px; flex: none; }
270
+ .pv-detail__head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pv-border); }
271
+ .pv-detail__id { font: 600 13px/1.2 var(--pv-mono); color: var(--pv-ink); }
272
+ .pv-detail__stamp { font: 400 12px/1.2 var(--pv-sans); color: var(--pv-ink-soft); }
273
+ .pv-detail__meta { display: grid; gap: 12px; padding: 14px 16px; border-bottom: 1px solid var(--pv-border); }
274
+ .pv-detail__meta--split { grid-template-columns: 1fr 1fr; }
275
+ .pv-detail__field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
276
+ .pv-detail__item { font: 400 13px/1.4 var(--pv-mono); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
277
+ .pv-detail__who { font: 400 13px/1.4 var(--pv-sans); color: var(--pv-ink); }
278
+ .pv-detail__body { display: flex; flex-direction: column; gap: 14px; padding: 14px 16px; }
279
+ .pv-detail__note { margin: 0; font: 400 12px/1.6 var(--pv-sans); color: var(--pv-ink-soft); }
280
+ .pv-label { font: 600 11px/1.2 var(--pv-sans); letter-spacing: .06em; text-transform: uppercase; color: var(--pv-ink-faint); }
281
+
282
+ .pv-attribute { display: flex; flex-direction: column; gap: 6px; }
283
+ .pv-attribute__name { font: 600 12px/1.3 var(--pv-mono); color: var(--pv-ink); word-break: break-all; }
284
+ .pv-attribute__unchanged {
285
+ align-self: flex-start;
286
+ padding: 0;
287
+ border: 0;
288
+ background: none;
289
+ font: 400 11px/1.4 var(--pv-sans);
290
+ color: var(--pv-ink-faint);
291
+ text-align: left;
292
+ cursor: pointer;
293
+ }
294
+ .pv-attribute__unchanged:hover { color: var(--pv-ink-soft); }
295
+ .pv-bars { display: flex; flex-direction: column; gap: 3px; }
296
+ .pv-bar { padding: 4px 8px; font: 400 12px/1.5 var(--pv-mono); white-space: pre-wrap; word-break: break-all; }
297
+ .pv-bar--old { color: var(--pv-del); background: var(--pv-del-bg); border-left: 2px solid var(--pv-del-rule); }
298
+ .pv-bar--new { color: var(--pv-add); background: var(--pv-add-bg); border-left: 2px solid var(--pv-add-rule); }
299
+ .pv-keys { display: flex; flex-direction: column; gap: 3px; padding-left: 8px; border-left: 2px solid var(--pv-border); }
300
+ .pv-keys__name { font: 400 11px/1.4 var(--pv-mono); color: var(--pv-ink-soft); word-break: break-all; }
301
+ .pv-keys__values { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap; font: 400 12px/1.5 var(--pv-mono); }
302
+ .pv-keys__values .pv-value { max-width: none; overflow: visible; white-space: normal; word-break: break-all; }
303
+
304
+ .pv-disclosures { display: grid; grid-template-columns: 1fr 1fr; column-gap: 12px; }
305
+ .pv-disclosure {
306
+ padding: 0;
307
+ border: 0;
308
+ background: none;
309
+ color: var(--pv-ink-soft);
310
+ font: 500 12px/1.4 var(--pv-sans);
311
+ text-align: left;
312
+ cursor: pointer;
313
+ }
314
+ .pv-disclosure:hover { color: var(--pv-ink); }
315
+ .pv-disclosure--end { text-align: right; }
316
+ .pv-raw, .pv-snippet { grid-column: 1 / -1; margin: 8px 0 0; }
317
+ .pv-raw {
318
+ padding: 10px 12px;
319
+ background: var(--pv-surface-muted);
320
+ border: 1px solid var(--pv-border);
321
+ border-radius: 6px;
322
+ font: 400 11px/1.6 var(--pv-mono);
323
+ color: var(--pv-ink-secondary);
324
+ white-space: pre-wrap;
325
+ word-break: break-all;
326
+ }
327
+ .pv-snippet { border: 1px solid var(--pv-border); border-radius: 6px; overflow: hidden; }
328
+ .pv-snippet__head { display: flex; align-items: center; justify-content: space-between; gap: 8px; padding: 6px 10px; background: var(--pv-surface-muted); border-bottom: 1px solid var(--pv-border); }
329
+ .pv-snippet__label { font: 400 11px/1.4 var(--pv-sans); color: var(--pv-ink-faint); }
330
+ .pv-snippet__copy {
331
+ padding: 4px 8px;
332
+ background: var(--pv-surface);
333
+ border: 1px solid var(--pv-border-input);
334
+ border-radius: 5px;
335
+ color: var(--pv-ink-secondary);
336
+ font: 500 11px/1 var(--pv-sans);
337
+ cursor: pointer;
338
+ }
339
+ .pv-snippet__copy:hover { background: var(--pv-pill-bg); }
340
+ .pv-snippet__code { margin: 0; padding: 10px 12px; font: 400 11px/1.7 var(--pv-mono); color: var(--pv-ink); white-space: pre-wrap; word-break: break-all; }
341
+
342
+ .pv-empty { margin: 0; padding: 48px 16px; text-align: center; color: var(--pv-ink-soft); }
343
+ .pv-footer {
344
+ display: grid;
345
+ grid-template-columns: 1fr auto 1fr;
346
+ align-items: center;
347
+ gap: 12px;
348
+ padding: 12px 16px;
349
+ border-top: 1px solid var(--pv-border);
350
+ }
351
+ .pv-footer__end { grid-column: 3; display: flex; align-items: center; justify-content: flex-end; gap: 8px; }
352
+ .pv-head-form { display: flex; align-items: center; }
353
+ .pv-select-mini {
354
+ padding: 3px 6px;
355
+ background: var(--pv-surface);
356
+ color: var(--pv-ink-soft);
357
+ border: 1px solid var(--pv-border);
358
+ border-radius: 6px;
359
+ font: inherit;
360
+ font-size: 12px;
361
+ }
362
+ .pv-select-mini:hover { color: var(--pv-ink); border-color: var(--pv-border-input); }
363
+ .pv-pagination { grid-column: 2; display: flex; align-items: center; justify-content: center; gap: 6px; flex-wrap: wrap; }
364
+ .pv-pagination__page { padding: 4px 10px; border-radius: 6px; color: var(--pv-ink-soft); font-size: 13px; }
365
+ .pv-pagination__page:hover { background: var(--pv-surface-muted); color: var(--pv-ink); text-decoration: none; }
366
+ .pv-pagination__page--current { background: var(--pv-accent); color: var(--pv-on-accent); font-weight: 600; }
367
+ .pv-pagination__page--current:hover { background: var(--pv-accent); color: var(--pv-on-accent); }
368
+ .pv-pagination__gap { padding: 4px 2px; color: var(--pv-ink-soft); }
369
+
370
+ @media (max-width: 900px) {
371
+ .pv-layout { flex-direction: column; }
372
+ .pv-side { position: static; width: 100%; }
373
+ }
374
+
375
+ @media (max-width: 720px) {
376
+ .pv-main { padding: 16px; }
377
+ .pv-footer { grid-template-columns: 1fr; justify-items: center; }
378
+ .pv-footer__end, .pv-pagination { grid-column: 1; justify-content: center; }
379
+ .pv-header { padding: 12px 16px; }
380
+ }
381
+
382
+ @media (max-width: 640px) {
383
+ .pv-version, .pv-version--stream { grid-template-columns: 52px 16px 1fr; padding: 12px 14px; }
384
+ .pv-version__rail { grid-row: 1 / -1; }
385
+ .pv-version__item, .pv-version__content, .pv-version__id { grid-column: 3; }
386
+ .pv-value, .pv-version--stream .pv-value { max-width: 100%; }
387
+ }
388
+ </style>