design-spine 0.1.0 → 0.2.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 +29 -10
- package/bin/resolve-bun.d.ts +23 -0
- package/bin/resolve-bun.js +66 -0
- package/bin/spine.js +18 -6
- package/package.json +21 -10
- package/src/assets.d.ts +10 -0
- package/src/cli.ts +92 -33
- package/src/lib/capture.ts +3 -1
- package/src/lib/docker.ts +196 -0
- package/src/lib/routes.ts +2 -0
- package/src/lib/server.ts +14 -0
- package/src/lib/summary.ts +70 -0
- package/src/ui/index.html +3 -710
- package/src/ui/review.css +1313 -0
- package/src/ui/review.js +1575 -0
package/src/ui/index.html
CHANGED
|
@@ -5,226 +5,7 @@
|
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
6
|
<title>Design Spine review</title>
|
|
7
7
|
<link rel="icon" href="data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2032%2032%22%3E%3Cpath%20fill%3D%22%23b94713%22%20d%3D%22M3%202h5v28H3z%22%2F%3E%3Cpath%20fill%3D%22%2320231f%22%20d%3D%22M12%202h17v7H12zm0%2011h17v7H12zm0%2011h17v7H12z%22%2F%3E%3C%2Fsvg%3E">
|
|
8
|
-
<
|
|
9
|
-
:root {
|
|
10
|
-
color-scheme: light dark;
|
|
11
|
-
--bg: #f7f6f2; --surface: #fff; --ink: #20231f; --muted: #62675e; --line: #dedfd7;
|
|
12
|
-
--accent: #b34512; --accent-bg: #f9eadf; --ok: #25633f; --ok-bg: #e8f2e9;
|
|
13
|
-
--bad: #a83232; --bad-bg: #f9e8e5; --warn: #785411; --warn-bg: #f8efd8;
|
|
14
|
-
--focus: #b94713; --on-strong: #fff; --radius: 8px;
|
|
15
|
-
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
16
|
-
}
|
|
17
|
-
@media (prefers-color-scheme: dark) {
|
|
18
|
-
:root { --bg: #171b18; --surface: #202620; --ink: #eef0e8; --muted: #adb5a7; --line: #3a4438;
|
|
19
|
-
--accent: #ffac7c; --accent-bg: #3c2b22; --ok: #8fdaac; --ok-bg: #223a2b;
|
|
20
|
-
--bad: #ffaaa4; --bad-bg: #402929; --warn: #ead08a; --warn-bg: #3b3422;
|
|
21
|
-
--focus: #ffac7c; --on-strong: #171b18; }
|
|
22
|
-
}
|
|
23
|
-
* { box-sizing: border-box; }
|
|
24
|
-
body { margin: 0; background: var(--bg); color: var(--ink); font: 14px/1.5 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; }
|
|
25
|
-
button, input, select, textarea { font: inherit; color: inherit; }
|
|
26
|
-
button { cursor: pointer; }
|
|
27
|
-
button, select, input { min-height: 44px; }
|
|
28
|
-
button:disabled { opacity: .55; cursor: wait; }
|
|
29
|
-
a { color: inherit; }
|
|
30
|
-
button, input, select, textarea, a { -webkit-tap-highlight-color: transparent; }
|
|
31
|
-
:focus-visible { outline: 2px solid var(--focus); outline-offset: 3px; }
|
|
32
|
-
.skip { position: fixed; left: 16px; top: -80px; z-index: 50; background: var(--surface); padding: 12px; }
|
|
33
|
-
.skip:focus { top: 8px; }
|
|
34
|
-
header { height: 64px; padding: 0 24px; border-bottom: 1px solid var(--line); display: flex; gap: 24px; align-items: center; background: var(--surface); }
|
|
35
|
-
.brand { display: flex; gap: 12px; align-items: center; font-weight: 700; font-size: 18px; letter-spacing: -.5px; }
|
|
36
|
-
.mark { width: 25px; height: 30px; border-left: 5px solid var(--accent); display: grid; gap: 3px; padding-left: 4px; }
|
|
37
|
-
.mark i { background: var(--ink); height: 7px; width: 16px; }
|
|
38
|
-
.header-label { border-left: 1px solid var(--line); padding-left: 24px; color: var(--muted); font-size: 12px; }
|
|
39
|
-
.header-actions { margin-left: auto; display: flex; gap: 8px; }
|
|
40
|
-
.button, .pill, .actions button { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 8px 16px; font-weight: 600; }
|
|
41
|
-
.button:hover, .pill:hover, .actions button:hover { border-color: var(--muted); background: var(--bg); }
|
|
42
|
-
.button.primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
|
|
43
|
-
.workspace { display: grid; grid-template-columns: 208px minmax(0, 1fr); min-height: calc(100vh - 64px); }
|
|
44
|
-
aside { padding: 32px 20px; border-right: 1px solid var(--line); }
|
|
45
|
-
.sidebar-sticky { position: sticky; top: 24px; }
|
|
46
|
-
.eyebrow { font: 11px/1.4 var(--mono); text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
|
|
47
|
-
#route-nav { min-height: 44px; display: grid; gap: 4px; margin-top: 16px; }
|
|
48
|
-
#route-nav a { text-decoration: none; display: flex; justify-content: space-between; gap: 8px; border-radius: 6px; padding: 11px 12px; font: 12px/1.5 var(--mono); overflow-wrap: anywhere; }
|
|
49
|
-
#route-nav a:hover { background: var(--accent-bg); color: var(--accent); }
|
|
50
|
-
#route-nav span { color: var(--muted); }
|
|
51
|
-
.sidebar-note { border-top: 1px solid var(--line); margin-top: 32px; padding-top: 20px; font-size: 12px; color: var(--muted); }
|
|
52
|
-
main { min-width: 0; max-width: 1800px; width: 100%; padding: 16px 28px 120px; margin: 0 auto; }
|
|
53
|
-
.intro { display: flex; justify-content: space-between; gap: 24px; align-items: end; }
|
|
54
|
-
h1 { font-size: clamp(28px, 3vw, 36px); font-weight: 650; letter-spacing: -2px; line-height: 1.1; margin: 6px 0; }
|
|
55
|
-
.subtitle { color: var(--muted); margin: 0; max-width: 52ch; }
|
|
56
|
-
.run-context { max-width: 270px; color: var(--muted); text-align: right; font: 11px/1.7 var(--mono); overflow-wrap: anywhere; }
|
|
57
|
-
#runid { display: block; }
|
|
58
|
-
.stats { min-height: 64px; display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-top: 8px; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); }
|
|
59
|
-
.stat { padding: 8px 16px; border-right: 1px solid var(--line); display: flex; flex-direction: column; gap: 4px; }
|
|
60
|
-
.stat:last-child { border: 0; }
|
|
61
|
-
.stat b { font-size: 24px; font-weight: 550; letter-spacing: -1px; font-variant-numeric: tabular-nums; }
|
|
62
|
-
.stat span { color: var(--muted); font-size: 12px; }
|
|
63
|
-
.stat.flagged b { color: var(--accent); }
|
|
64
|
-
.progress-row { display: flex; align-items: center; gap: 16px; margin: 10px 0 12px; color: var(--muted); font-size: 12px; }
|
|
65
|
-
progress { appearance: none; border: 0; background: var(--line); height: 4px; flex: 1; border-radius: 4px; }
|
|
66
|
-
progress::-webkit-progress-bar { background: var(--line); border-radius: 4px; }
|
|
67
|
-
progress::-webkit-progress-value { background: var(--accent); border-radius: 4px; }
|
|
68
|
-
progress::-moz-progress-bar { background: var(--accent); }
|
|
69
|
-
.toolbar { display: flex; flex-wrap: wrap; gap: 12px; align-items: end; border-top: 1px solid var(--line); border-bottom: 1px solid var(--line); padding: 10px 0; }
|
|
70
|
-
.field { display: grid; gap: 6px; font-size: 12px; color: var(--muted); }
|
|
71
|
-
.search-field { flex: 1; min-width: 180px; }
|
|
72
|
-
input, select { border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); padding: 8px 12px; width: 100%; }
|
|
73
|
-
.filters { display: flex; flex-wrap: wrap; gap: 8px; }
|
|
74
|
-
.pill { font-size: 12px; }
|
|
75
|
-
.pill[aria-pressed="true"] { background: var(--accent-bg); border-color: var(--accent); color: var(--accent); }
|
|
76
|
-
.results-row { display: flex; justify-content: space-between; gap: 16px; margin: 4px 0 8px; color: var(--muted); font: 11px/1.5 var(--mono); }
|
|
77
|
-
.route { margin: 0 0 48px; scroll-margin-top: 24px; }
|
|
78
|
-
.route > h2 { display: flex; gap: 12px; align-items: center; font-size: 20px; margin: 0; font-weight: 600; }
|
|
79
|
-
.route > h2::before { content: ''; background: var(--accent); width: 4px; height: 20px; }
|
|
80
|
-
.route > h2 code { font: 600 18px var(--mono); overflow-wrap: anywhere; }
|
|
81
|
-
.state { margin: 4px 0 8px; }
|
|
82
|
-
.state > h3 { font: 11px var(--mono); text-transform: uppercase; letter-spacing: .08em; color: var(--muted); margin: 0 0 12px; }
|
|
83
|
-
.row { display: grid; gap: 12px; grid-template-columns: repeat(auto-fill, minmax(min(100%, 232px), 1fr)); align-items: start; }
|
|
84
|
-
.tile { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius); padding: 10px; display: flex; flex-direction: column; gap: 8px; min-width: 0; }
|
|
85
|
-
.tile.focus { border-color: var(--focus); box-shadow: 0 0 0 2px color-mix(in srgb, var(--focus) 20%, transparent); }
|
|
86
|
-
.tile.approved { border-top: 3px solid var(--ok); padding-top: 10px; }
|
|
87
|
-
.tile.rejected { border-top: 3px solid var(--bad); padding-top: 10px; }
|
|
88
|
-
.tile .head { display: flex; justify-content: space-between; gap: 8px; font: 11px/1.5 var(--mono); color: var(--muted); }
|
|
89
|
-
.thumb { border: 1px solid var(--line); border-radius: 4px; overflow: auto; height: 220px; background: var(--bg); }
|
|
90
|
-
.thumb img { display: block; width: 100%; height: auto; object-position: top; cursor: zoom-in; }
|
|
91
|
-
.badges { display: flex; flex-wrap: wrap; gap: 4px; min-height: 22px; }
|
|
92
|
-
.badge { font-size: 11px; font-weight: 600; padding: 2px 6px; border-radius: 4px; background: var(--bad-bg); color: var(--bad); overflow-wrap: anywhere; }
|
|
93
|
-
.badge.warn { background: var(--warn-bg); color: var(--warn); }
|
|
94
|
-
.badge.ok { background: var(--ok-bg); color: var(--ok); }
|
|
95
|
-
.crit { font-size: 12px; border-top: 1px solid var(--line); padding-top: 12px; overflow-wrap: anywhere; }
|
|
96
|
-
.crit ul { margin: 8px 0 0; padding-left: 16px; }
|
|
97
|
-
.crit .sev { font-weight: 700; text-transform: uppercase; font-size: 10px; color: var(--muted); }
|
|
98
|
-
.actions { display: flex; gap: 6px; }
|
|
99
|
-
.actions button { flex: 1; padding: 6px 4px; font-size: 12px; min-width: 0; }
|
|
100
|
-
.tile .comparison-status { margin: 0; }
|
|
101
|
-
.tile-more { font-size: 12px; color: var(--muted); border-top: 1px solid var(--line); padding-top: 4px; }
|
|
102
|
-
.tile-more > summary { cursor: pointer; min-height: 32px; padding: 6px 0; overflow-wrap: anywhere; }
|
|
103
|
-
.tile-more[open] > summary { color: var(--ink); }
|
|
104
|
-
.tile-more > * + * { margin-top: 8px; }
|
|
105
|
-
.actions .approve[aria-pressed="true"] { background: var(--ok); border-color: var(--ok); color: var(--on-strong); }
|
|
106
|
-
.actions .reject[aria-pressed="true"] { background: var(--bad); border-color: var(--bad); color: var(--on-strong); }
|
|
107
|
-
textarea { width: 100%; resize: vertical; min-height: 64px; background: var(--bg); border: 1px solid var(--line); border-radius: 4px; padding: 10px; font-size: 12px; }
|
|
108
|
-
.empty { padding: 64px 24px; text-align: center; color: var(--muted); }
|
|
109
|
-
.empty h2 { color: var(--ink); font-size: 24px; letter-spacing: -.5px; }
|
|
110
|
-
.keys { color: var(--muted); font-size: 11px; }
|
|
111
|
-
kbd { border: 1px solid var(--line); border-bottom-width: 2px; border-radius: 4px; padding: 0 4px; font: 10px var(--mono); }
|
|
112
|
-
#cards[aria-busy="true"] ~ footer { visibility: hidden; }
|
|
113
|
-
footer { display: flex; justify-content: space-between; gap: 16px; border-top: 1px solid var(--line); padding: 20px 0; color: var(--muted); font-size: 11px; }
|
|
114
|
-
#comparison-status { min-height: 64px; padding: 10px 0; margin-bottom: 8px; color: var(--muted); font-size: 12px; display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
|
115
|
-
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 20px 0; border-top: 1px solid var(--line); }
|
|
116
|
-
.pagination[hidden] { display: none; }
|
|
117
|
-
.page-label { color: var(--muted); font-size: 12px; text-align: center; }
|
|
118
|
-
#save-status:empty { visibility: hidden; }
|
|
119
|
-
#save-status { position: fixed; z-index: 20; bottom: 12px; left: 236px; right: 28px; min-height: 64px; padding: 12px 16px; margin-bottom: 0; border: 1px solid var(--line); border-radius: var(--radius); background: var(--surface); font-size: 12px; display: flex; align-items: center; flex-wrap: wrap; gap: 12px; }
|
|
120
|
-
#save-status.error { color: var(--bad); border-color: var(--bad); background: var(--bad-bg); }
|
|
121
|
-
.note-label { display: grid; gap: 6px; font-size: 11px; color: var(--muted); }
|
|
122
|
-
.inspect { width: 100%; font-size: 12px; min-height: 44px; }
|
|
123
|
-
.diagnostics { font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
124
|
-
.diagnostics summary { cursor: pointer; padding: 6px 0; }
|
|
125
|
-
.diagnostics ul { padding-left: 18px; }
|
|
126
|
-
.capture-missing { padding: 24px; color: var(--muted); }
|
|
127
|
-
#lightbox { width: calc(100vw - 48px); max-width: 1600px; height: calc(100vh - 48px); max-height: none; padding: 0; border: 1px solid var(--line); border-radius: 12px; background: var(--bg); color: var(--ink); }
|
|
128
|
-
#lightbox::backdrop { background: #101510db; }
|
|
129
|
-
.viewer-toolbar { padding: 16px 24px; border-bottom: 1px solid var(--line); background: var(--surface); display: flex; gap: 16px; justify-content: space-between; align-items: center; }
|
|
130
|
-
.viewer-toolbar h2 { font: 600 14px var(--mono); margin: 0; overflow-wrap: anywhere; }
|
|
131
|
-
.viewer-toolbar p { margin: 4px 0 0; color: var(--muted); font: 11px var(--mono); }
|
|
132
|
-
.viewer-actions { display: flex; gap: 8px; flex-wrap: wrap; }
|
|
133
|
-
.viewer-actions .button { font-size: 12px; text-decoration: none; display: inline-flex; align-items: center; justify-content: center; }
|
|
134
|
-
#lightbox[open] { display: flex; flex-direction: column; }
|
|
135
|
-
.viewer-toolbar { flex-shrink: 0; }
|
|
136
|
-
.viewer-canvas { overflow: auto; flex: 1; min-height: 0; padding: 24px; }
|
|
137
|
-
#viewer-image { display: block; max-width: 100%; height: auto; margin: 0 auto; background: #fff; }
|
|
138
|
-
#viewer-image.actual { max-width: none; }
|
|
139
|
-
#viewer-image[hidden] { display: none; }
|
|
140
|
-
.evidence-frame { position: relative; width: fit-content; max-width: 100%; margin: 0 auto; }
|
|
141
|
-
.evidence-frame.actual { max-width: none; }
|
|
142
|
-
#diagnostic-overlay, #note-overlay { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
|
|
143
|
-
#note-overlay[hidden], #diagnostic-overlay[hidden] { display: none; }
|
|
144
|
-
#diagnostic-overlay rect { fill: #efb62c26; stroke: #a24c00; stroke-width: 2; vector-effect: non-scaling-stroke; }
|
|
145
|
-
.diagnostic-controls[hidden] { display: none; }
|
|
146
|
-
.diagnostic-controls { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; }
|
|
147
|
-
#viewer-error { color: var(--bad); }
|
|
148
|
-
.viewer-review { flex-shrink: 0; padding: 12px 24px; border-top: 1px solid var(--line); background: var(--surface); max-height: 42%; overflow: auto; }
|
|
149
|
-
.viewer-decisions { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
|
|
150
|
-
#viewer-decision { font: 600 12px var(--mono); margin-right: auto; }
|
|
151
|
-
.viewer-decisions [aria-pressed="true"] { outline: 2px solid currentColor; outline-offset: -4px; }
|
|
152
|
-
#viewer-save-status { margin: 8px 0 0; font-size: 12px; color: var(--muted); overflow-wrap: anywhere; }
|
|
153
|
-
#viewer-save-status.error { color: var(--bad); }
|
|
154
|
-
.viewer-note { margin-top: 8px; font-size: 12px; }
|
|
155
|
-
.viewer-note summary { cursor: pointer; min-height: 32px; padding: 8px 0; }
|
|
156
|
-
.viewer-note .note-label { margin: 8px 0; }
|
|
157
|
-
#viewer-note-help { font-size: 12px; line-height: 1.5; background: var(--surface); }
|
|
158
|
-
#viewer-note { width: 100%; min-height: 64px; resize: vertical; padding: 10px; color: var(--ink); background: var(--bg); border: 1px solid var(--line); border-radius: 4px; font: inherit; }
|
|
159
|
-
@media (max-width: 700px) { .viewer-review { padding: 10px 12px; } .viewer-decisions .button { padding: 8px 10px; font-size: 12px; } }
|
|
160
|
-
.comparison-toolbar { padding: 12px 24px; border-bottom: 1px solid var(--line); display: flex; gap: 16px; align-items: center; flex-wrap: wrap; }
|
|
161
|
-
.comparison-toolbar label { white-space: nowrap; display: flex; gap: 8px; align-items: center; }
|
|
162
|
-
.comparison-toolbar select { padding: 8px; background: var(--surface); border: 1px solid var(--line); border-radius: 4px; }
|
|
163
|
-
#comparison-context { margin: 0; color: var(--muted); font-size: 12px; overflow-wrap: anywhere; }
|
|
164
|
-
.comparison-status { margin: 0; color: var(--muted); font: 11px/1.5 var(--mono); }
|
|
165
|
-
.viewer-pair { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px; }
|
|
166
|
-
.viewer-pair.split { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
167
|
-
.viewer-pair figure { margin: 0; min-width: 0; }
|
|
168
|
-
.viewer-pair figcaption { color: var(--muted); margin-bottom: 8px; font: 12px var(--mono); }
|
|
169
|
-
#baseline-pane[hidden] { display: none; }
|
|
170
|
-
#baseline-image { display: block; max-width: 100%; height: auto; background: #fff; }
|
|
171
|
-
#baseline-image.actual { max-width: none; }
|
|
172
|
-
@media (max-width: 700px) { .viewer-pair.split { grid-template-columns: minmax(0, 1fr); } .comparison-toolbar { padding: 12px; } }
|
|
173
|
-
@media (max-width: 700px) {
|
|
174
|
-
#lightbox { width: calc(100vw - 16px); height: calc(100dvh - 16px); }
|
|
175
|
-
.viewer-toolbar { padding: 12px; flex-direction: column; align-items: stretch; position: relative; }
|
|
176
|
-
.viewer-toolbar > div:first-child { padding-right: 70px; }
|
|
177
|
-
#viewer-close { position: absolute; top: 12px; right: 12px; }
|
|
178
|
-
.viewer-actions { gap: 6px; } .viewer-actions .button { padding: 8px 10px; }
|
|
179
|
-
.viewer-canvas { padding: 12px; }
|
|
180
|
-
#save-status { left: 12px; right: 12px; min-height: 96px; }
|
|
181
|
-
.toolbar > .field:not(.search-field) { flex: 1 1 90px; }
|
|
182
|
-
.toolbar > .search-field { flex-basis: 100%; min-width: 0; }
|
|
183
|
-
.filters .button, .filters .pill { padding: 8px 10px; font-size: 11px; }
|
|
184
|
-
.brand { font-size: 16px; gap: 8px; }
|
|
185
|
-
}
|
|
186
|
-
@media (max-width: 1000px) { #save-status { left: 194px; right: 24px; } main { padding: 32px 24px 120px; } .workspace { grid-template-columns: 170px minmax(0, 1fr); } .intro { align-items: start; flex-direction: column; } .run-context { text-align: left; max-width: 100%; } .stat { padding: 12px; } }
|
|
187
|
-
@media (max-width: 700px) {
|
|
188
|
-
header { height: auto; min-height: 72px; padding: 16px; gap: 12px; flex-wrap: wrap; }
|
|
189
|
-
.header-label { display: none; } .header-actions .button { padding: 8px 12px; font-size: 12px; }
|
|
190
|
-
.workspace { display: block; } aside { border-right: 0; border-bottom: 1px solid var(--line); padding: 12px 16px; }
|
|
191
|
-
.sidebar-sticky { position: static; } aside .eyebrow, .sidebar-note { display: none; }
|
|
192
|
-
#route-nav { display: flex; overflow-x: auto; margin: 0; } #route-nav a { flex-shrink: 0; gap: 16px; }
|
|
193
|
-
main { padding: 20px 16px 140px; } h1 { letter-spacing: -1.5px; } .intro { gap: 16px; }
|
|
194
|
-
.stats { min-height: 82px; grid-template-columns: repeat(5, minmax(0, 1fr)); margin-top: 24px; }
|
|
195
|
-
.stat { padding: 12px 6px; } .stat b { font-size: 24px; } .stat span { font-size: 11px; }
|
|
196
|
-
.progress-row { margin-bottom: 24px; } .row { grid-template-columns: repeat(auto-fill, minmax(min(100%, 150px), 1fr)); gap: 8px; }
|
|
197
|
-
.thumb { height: 200px; } .keys { display: none; } footer { flex-direction: column; }
|
|
198
|
-
.intro > div:first-child { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
|
|
199
|
-
#runid { display: none; } .stats { margin-top: 12px; min-height: 0; } .stat { padding: 8px 4px; } .stat b { font-size: 20px; }
|
|
200
|
-
.progress-row { margin-bottom: 12px; } .toolbar { gap: 8px; }
|
|
201
|
-
.toolbar > .field:not(.search-field) { flex: 1 1 calc(50% - 8px); }
|
|
202
|
-
.toolbar > .field { font-size: 0; gap: 0; } .toolbar > .field select, .toolbar > .field input { font-size: 14px; }
|
|
203
|
-
.filters { flex-wrap: nowrap; overflow-x: auto; width: 100%; } .filters > * { flex-shrink: 0; }
|
|
204
|
-
}
|
|
205
|
-
@media (min-width: 1001px) { #route-nav { max-height: calc(100dvh - 280px); overflow-y: auto; overscroll-behavior: contain; } }
|
|
206
|
-
@media (max-width: 700px) { #save-status { left: 12px; right: 12px; } }
|
|
207
|
-
@media (prefers-reduced-motion: no-preference) { button, a { transition: background .15s, border-color .15s; } }
|
|
208
|
-
#coverage { margin-top: 10px; }
|
|
209
|
-
#coverage-details ul { max-height: 220px; overflow: auto; }
|
|
210
|
-
#coverage-details pre { white-space: pre-wrap; overflow-wrap: anywhere; }
|
|
211
|
-
#overlay-image { position: absolute; inset: 0; width: 100%; height: 100%; opacity: .5; pointer-events: none; }
|
|
212
|
-
#overlay-image[hidden], #blend-control[hidden] { display: none; }
|
|
213
|
-
#blend { width: 100px; }
|
|
214
|
-
#note-overlay circle { fill: #b34512; stroke: white; stroke-width: 2; vector-effect: non-scaling-stroke; }
|
|
215
|
-
.viewer-note fieldset { display: flex; flex-wrap: wrap; gap: 12px; } .viewer-note fieldset label { max-width: 140px; }
|
|
216
|
-
#anchor-status { font-size: 12px; margin: 6px 0; }
|
|
217
|
-
@media (min-width: 1001px) { .intro .subtitle { display: none; } }
|
|
218
|
-
@media (min-width: 1001px) {
|
|
219
|
-
.run-context label { display: flex; gap: 8px; align-items: center; white-space: nowrap; }
|
|
220
|
-
.run-context select { min-width: 0; }
|
|
221
|
-
.run-context { max-width: 420px; }
|
|
222
|
-
#runid { display: inline; margin-left: 8px; }
|
|
223
|
-
.stats { min-height: 44px; }
|
|
224
|
-
.stat { flex-direction: row; align-items: center; padding: 8px 12px; gap: 8px; }
|
|
225
|
-
.stat b { font-size: 20px; }
|
|
226
|
-
}
|
|
227
|
-
</style>
|
|
8
|
+
<link rel="stylesheet" href="/assets/review.css">
|
|
228
9
|
</head>
|
|
229
10
|
<body>
|
|
230
11
|
<a class="skip" href="#main">Skip to review</a>
|
|
@@ -240,7 +21,7 @@ footer { display: flex; justify-content: space-between; gap: 16px; border-top: 1
|
|
|
240
21
|
<details id="coverage" class="diagnostics"><summary id="coverage-summary">Coverage unknown</summary><div id="coverage-details"></div></details>
|
|
241
22
|
<div class="stats" id="stats" role="group" aria-label="Run summary"></div>
|
|
242
23
|
<div class="progress-row"><span id="progress-label">Loading review progress</span><progress id="progress" value="0" max="1" aria-label="Reviewed screenshots"></progress><span id="percent">0%</span></div>
|
|
243
|
-
<div class="toolbar"><label class="field search-field">Search routes or states<input id="search" type="search" placeholder="Find a route, e.g. /pricing" autocomplete="off"></label><label class="field">Review status<select id="status-filter"><option value="">All decisions</option><option value="approve">Approved</option><option value="reject">Rejected</option></select></label><label class="field">Comparison<select id="comparison-filter"><option value="">All evidence</option><option value="new">New · no baseline</option><option value="changed">Changed</option><option value="size-changed">Dimensions changed</option><option value="unchanged">Within threshold</option><option value="no-capture">No capture</option><option value="unavailable">Unavailable</option></select></label><label class="field">Viewport<select id="viewport-filter"><option value="">All widths</option></select></label><label class="field">Capture theme<select id="theme-filter"><option value="">All themes</option><option value="light">Light</option><option value="dark">Dark</option></select></label><div class="filters"><button class="pill" id="f-flagged" aria-pressed="false">Only flagged</button><button class="pill" id="f-changed" aria-pressed="false">Only changed</button><button class="pill" id="f-unrev" aria-pressed="false">Only unreviewed</button><button class="button" id="clear-filters">Clear filters</button></div></div>
|
|
24
|
+
<div class="toolbar"><label class="field search-field">Search routes or states<input id="search" type="search" placeholder="Find a route, e.g. /pricing" autocomplete="off"></label><button class="button" id="toggle-filters" aria-expanded="false" aria-controls="secondary-filters">Filters</button><div id="secondary-filters" class="secondary-filters"><label class="field">Review status<select id="status-filter"><option value="">All decisions</option><option value="approve">Approved</option><option value="reject">Rejected</option></select></label><label class="field">Comparison<select id="comparison-filter"><option value="">All evidence</option><option value="new">New · no baseline</option><option value="changed">Changed</option><option value="size-changed">Dimensions changed</option><option value="unchanged">Within threshold</option><option value="no-capture">No capture</option><option value="unavailable">Unavailable</option></select></label><label class="field">Viewport<select id="viewport-filter"><option value="">All widths</option></select></label><label class="field">Capture theme<select id="theme-filter"><option value="">All themes</option><option value="light">Light</option><option value="dark">Dark</option></select></label><div class="filters"><button class="pill" id="f-flagged" aria-pressed="false">Only flagged</button><button class="pill" id="f-changed" aria-pressed="false">Only changed</button><button class="pill" id="f-unrev" aria-pressed="false">Only unreviewed</button><button class="button" id="clear-filters">Clear filters</button></div></div></div>
|
|
244
25
|
<div class="results-row"><span id="results" aria-live="polite">Loading captures…</span><div class="keys"><kbd>j</kbd>/<kbd>k</kbd> move <kbd>a</kbd> approve <kbd>r</kbd> reject <kbd>Enter</kbd> inspect</div></div>
|
|
245
26
|
<nav id="pagination" class="pagination" aria-label="Capture pages" hidden><button class="button" id="page-prev" data-page-step="-1" aria-label="Previous page">Previous</button><span id="page-label" class="page-label" aria-live="polite"></span><button class="button" id="page-next" data-page-step="1" aria-label="Next page">Next</button></nav>
|
|
246
27
|
<div id="save-status" role="status" aria-live="polite"></div>
|
|
@@ -256,494 +37,6 @@ footer { display: flex; justify-content: space-between; gap: 16px; border-top: 1
|
|
|
256
37
|
<div class="viewer-canvas" tabindex="0" role="region" aria-label="Full-size screenshot preview"><div class="viewer-pair"><figure id="baseline-pane" hidden><figcaption>Baseline</figcaption><img id="baseline-image" alt=""></figure><figure><figcaption id="current-caption">Current</figcaption><div class="evidence-frame"><img id="viewer-image" alt=""><img id="overlay-image" alt="Baseline overlay" hidden><svg id="diagnostic-overlay" aria-hidden="true" hidden></svg><svg id="note-overlay" aria-hidden="true" hidden></svg></div></figure></div><p id="viewer-error" hidden>Screenshot unavailable. Refresh the evidence; check the capture files if it persists. <button class="button" id="viewer-refresh">Refresh evidence</button></p></div>
|
|
257
38
|
<div class="viewer-review"><div class="viewer-decisions"><span id="viewer-decision">Pending</span><button class="button" id="viewer-approve" aria-pressed="false">Approve</button><button class="button" id="viewer-reject" aria-pressed="false">Reject</button><button class="button" id="viewer-pending">Next pending</button></div><p id="viewer-save-status" role="status" aria-live="polite">Decisions save locally.</p><button class="button" id="viewer-retry" hidden>Retry unsaved changes</button><button class="button" id="anchor-note" aria-pressed="false">Locate note on screenshot</button><button class="button" id="clear-anchor" hidden>Clear note location</button><p id="anchor-status"></p><button class="button" id="jump-anchor" hidden>Go to note location</button><details class="viewer-note"><summary id="viewer-note-summary">Review note</summary><label class="note-label" for="viewer-note">Note for this screenshot<textarea id="viewer-note" maxlength="2000" aria-describedby="viewer-note-help"></textarea><small id="viewer-note-help">Up to 2,000 characters. Save the note or include it with a decision. Drafts stay here until saved.</small></label><button class="button" id="viewer-save-note">Save note</button><fieldset><legend>Note location on original screenshot</legend><label>X in pixels <input id="anchor-x" type="number" min="0" step="1" value="0"></label><label>Y in pixels <input id="anchor-y" type="number" min="0" step="1" value="0"></label><button class="button" id="save-anchor">Save location</button></fieldset></details></div>
|
|
258
39
|
</dialog>
|
|
259
|
-
<script>
|
|
260
|
-
const state = { run: null, review: {}, critique: {}, comparison: {}, comparisonLoading: false, comparisonFailed: false, comparisonGeneration: 0, comparedAt: null, threshold: 0.002, onlyChanged: false, page: 0, pageSize: 48, viewerMode: 'current', onlyFlagged: false, onlyUnreviewed: false, search: '', status: '', evidence: '', viewport: '', theme: '', focus: null, drafts: new Map(), pending: new Map(), failures: new Map(), loading: true, viewer: null, returnFocus: null };
|
|
261
|
-
const selectedRun = () => new URL(location.href).searchParams.get('run');
|
|
262
|
-
const apiPath = path => { const url = new URL(path, location.origin); if (selectedRun()) url.searchParams.set('run', selectedRun()); return url.pathname + url.search; };
|
|
263
|
-
const $ = s => document.querySelector(s);
|
|
264
|
-
const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = text; return e; };
|
|
265
|
-
const decision = t => state.review[t.id]?.decision || null;
|
|
266
|
-
const comparisonLabels = { new: 'New · no baseline', changed: 'Changed', unchanged: 'Within threshold', 'size-changed': 'Dimensions changed', 'no-capture': 'Comparison: no capture', unavailable: 'Comparison unavailable' };
|
|
267
|
-
|
|
268
|
-
function flags(t) {
|
|
269
|
-
const f = [];
|
|
270
|
-
if (t.accessibility?.error) f.push(t.accessibility.error);
|
|
271
|
-
if (t.accessibility?.violations.length) f.push(t.accessibility.violations.length + ' automated accessibility findings');
|
|
272
|
-
if (t.stability === 'unstable') f.push('Capture did not stabilize');
|
|
273
|
-
if (t.error) f.push('Capture failed: ' + t.error);
|
|
274
|
-
if (t.status === 0 && !t.error) f.push('No response');
|
|
275
|
-
if (t.status >= 400) f.push('HTTP ' + t.status);
|
|
276
|
-
if (t.overflowX) f.push('Horizontal overflow');
|
|
277
|
-
if (t.blankRegions.count) f.push(t.blankRegions.count + ' blank region' + (t.blankRegions.count > 1 ? 's' : ''));
|
|
278
|
-
if (t.brokenImages.length) f.push(t.brokenImages.length + ' broken image' + (t.brokenImages.length > 1 ? 's' : ''));
|
|
279
|
-
if (t.consoleErrors.length) f.push(t.consoleErrors.length + ' console error' + (t.consoleErrors.length > 1 ? 's' : ''));
|
|
280
|
-
if (t.pageErrors.length) f.push(t.pageErrors.length + ' page error' + (t.pageErrors.length > 1 ? 's' : ''));
|
|
281
|
-
if (t.redirectedTo) f.push('Redirected to ' + t.redirectedTo);
|
|
282
|
-
return f;
|
|
283
|
-
}
|
|
284
|
-
const isFlagged = t => flags(t).length > 0;
|
|
285
|
-
function visibleTiles() {
|
|
286
|
-
const routeOrder = new Map([...new Set((state.run?.tiles || []).map(t => t.route))].map((route, index) => [route, index]));
|
|
287
|
-
return (state.run?.tiles || []).filter(t =>
|
|
288
|
-
(!state.onlyChanged || ['new', 'changed', 'size-changed'].includes(state.comparison[t.id]?.status)) && (!state.onlyFlagged || isFlagged(t)) && (!state.onlyUnreviewed || !decision(t)) &&
|
|
289
|
-
(!state.evidence || state.comparison[t.id]?.status === state.evidence) && (!state.status || decision(t) === state.status) && (!state.viewport || String(t.viewport) === state.viewport) &&
|
|
290
|
-
(!state.theme || t.theme === state.theme) && (t.route + ' ' + t.state).toLowerCase().includes(state.search)
|
|
291
|
-
).sort((a, b) => routeOrder.get(a.route) - routeOrder.get(b.route) || a.state.localeCompare(b.state) || a.viewport - b.viewport || (a.theme === b.theme ? 0 : a.theme === 'light' ? -1 : 1));
|
|
292
|
-
}
|
|
293
|
-
function showEmpty(title, text, action, callback) {
|
|
294
|
-
const box = el('div', 'empty'); box.append(el('h2', null, title), el('p', null, text));
|
|
295
|
-
if (action) { const button = el('button', 'button', action); button.addEventListener('click', callback); box.append(button); }
|
|
296
|
-
$('#cards').replaceChildren(box);
|
|
297
|
-
}
|
|
298
|
-
async function load() {
|
|
299
|
-
state.loading = true; state.run = null; state.review = {}; state.critique = {}; state.comparison = {}; $('#cards').setAttribute('aria-busy', 'true'); $('#progress-label').textContent = 'Loading review progress'; $('#percent').textContent = '…'; updateControls();
|
|
300
|
-
showEmpty('Loading your captures…', 'Reading this run’s screenshots and review decisions.');
|
|
301
|
-
try {
|
|
302
|
-
const response = await fetch(apiPath('/api/run?compare=false'));
|
|
303
|
-
if (!response.ok) throw new Error('The review server returned HTTP ' + response.status + '.');
|
|
304
|
-
const data = await response.json();
|
|
305
|
-
if (!data.run || !Array.isArray(data.run.tiles)) throw new Error('This run does not contain valid capture data.');
|
|
306
|
-
state.comparison = data.comparison?.tiles || {}; state.threshold = data.comparison?.threshold ?? 0.002; state.run = data.run; state.review = data.review?.tiles || {}; state.critique = data.critique?.tiles || {};
|
|
307
|
-
if (!selectedRun() && data.runKey) { const url = new URL(location.href); url.searchParams.set('run', data.runKey); history.replaceState(null, '', url); }
|
|
308
|
-
renderCoverage();
|
|
309
|
-
loadHistory();
|
|
310
|
-
$('#runid').textContent = data.run.id; $('#baseurl').textContent = data.run.baseUrl;
|
|
311
|
-
const nav = $('#route-nav'); nav.replaceChildren();
|
|
312
|
-
for (const [index, route] of [...new Set(data.run.tiles.map(t => t.route))].entries()) {
|
|
313
|
-
const link = el('a', null, route); link.href = '#route-' + index;
|
|
314
|
-
link.dataset.route = route; link.append(el('span'));
|
|
315
|
-
link.addEventListener('click', event => { event.preventDefault(); clearFilters(); const position = visibleTiles().findIndex(t => t.route === route); state.page = Math.floor(Math.max(0, position) / state.pageSize); render(); const section = $('#route-' + index); if (section) { section.scrollIntoView({ block: 'start' }); section.querySelector('h2').focus({ preventScroll: true }); } });
|
|
316
|
-
nav.append(link);
|
|
317
|
-
}
|
|
318
|
-
const select = $('#viewport-filter'); select.replaceChildren(new Option('All widths', ''));
|
|
319
|
-
for (const width of [...new Set(data.run.tiles.map(t => t.viewport))].sort((a, b) => a - b)) select.add(new Option(width + 'px', String(width)));
|
|
320
|
-
state.loading = false; render(); loadComparison(data.comparison);
|
|
321
|
-
const linked = new URL(location.href).searchParams.get('tile'); const linkedTile = data.run.tiles.find(t => t.id === linked); if (linkedTile) openBox(linkedTile);
|
|
322
|
-
} catch (error) {
|
|
323
|
-
state.loading = false; $('#runid').textContent = 'Run unavailable'; $('#results').textContent = 'Unable to load captures'; $('#progress-label').textContent = 'Review progress unavailable'; $('#percent').textContent = 'n/a';
|
|
324
|
-
showEmpty('Could not load this run.', error.message + ' Check that the review server is running, then retry.', 'Retry loading', load);
|
|
325
|
-
} finally { $('#cards').setAttribute('aria-busy', 'false'); updateControls(); }
|
|
326
|
-
}
|
|
327
|
-
async function loadComparison(provided) {
|
|
328
|
-
const generation = ++state.comparisonGeneration;
|
|
329
|
-
state.comparisonLoading = true; state.comparisonFailed = false;
|
|
330
|
-
$('#comparison-status').textContent = 'Comparing captures with local baselines… You can continue reviewing.';
|
|
331
|
-
for (const label of document.querySelectorAll('.tile .comparison-status')) label.textContent = 'Comparing…';
|
|
332
|
-
updateControls();
|
|
333
|
-
try {
|
|
334
|
-
let comparison = provided;
|
|
335
|
-
if (!comparison) {
|
|
336
|
-
const response = await fetch(apiPath('/api/comparison'));
|
|
337
|
-
if (!response.ok) throw new Error('Comparison unavailable');
|
|
338
|
-
comparison = await response.json();
|
|
339
|
-
}
|
|
340
|
-
if (!comparison || !comparison.tiles || typeof comparison.threshold !== 'number') throw new Error('Invalid comparison evidence');
|
|
341
|
-
if (generation !== state.comparisonGeneration) return;
|
|
342
|
-
state.comparison = comparison.tiles; state.threshold = comparison.threshold; state.comparedAt = comparison.comparedAt || null;
|
|
343
|
-
$('#comparison-status').textContent = 'Comparison ready · threshold ' + (state.threshold * 100).toFixed(2) + '% · Baselines change only through the approve command.';
|
|
344
|
-
} catch {
|
|
345
|
-
if (generation !== state.comparisonGeneration) return;
|
|
346
|
-
state.comparisonFailed = true;
|
|
347
|
-
const box = $('#comparison-status'); box.replaceChildren(el('span', null, 'Comparison could not load. Your captures and review notes are still available.'));
|
|
348
|
-
const retry = el('button', 'button', 'Retry comparison'); retry.addEventListener('click', () => loadComparison()); box.append(retry);
|
|
349
|
-
} finally {
|
|
350
|
-
if (generation === state.comparisonGeneration) {
|
|
351
|
-
state.comparisonLoading = false; if (state.onlyChanged || state.evidence) render(); updateControls();
|
|
352
|
-
for (const card of document.querySelectorAll('.tile')) card.querySelector('.comparison-status').textContent = comparisonLabels[state.comparison[card.dataset.id]?.status] || (state.comparisonFailed ? 'Comparison unavailable' : 'Not compared');
|
|
353
|
-
if ($('#lightbox').open) { if (viewerTiles().some(t => t.id === state.viewer)) updateViewer(); else $('#lightbox').close(); }
|
|
354
|
-
}
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
function updateControls() {
|
|
358
|
-
const dirty = state.drafts.size || state.pending.size || state.failures.size;
|
|
359
|
-
for (const control of document.querySelectorAll('.toolbar input, .toolbar select, .toolbar button')) control.disabled = !state.run || state.loading;
|
|
360
|
-
$('#export').disabled = !state.run || state.loading || !!dirty || state.comparisonLoading;
|
|
361
|
-
$('#report').disabled = $('#export').disabled;
|
|
362
|
-
$('#run-select').disabled = !state.run || state.loading || !!dirty;
|
|
363
|
-
$('#f-changed').disabled = !state.run || state.loading || state.comparisonLoading || state.comparisonFailed;
|
|
364
|
-
$('#comparison-filter').disabled = $('#f-changed').disabled;
|
|
365
|
-
updateViewerControls();
|
|
366
|
-
$('#export').title = state.comparisonLoading ? 'Waiting for comparison evidence.' : dirty ? 'Save or retry your changes before exporting.' : 'Download the full run and confirmed review decisions.';
|
|
367
|
-
$('#next-pending').disabled = !visibleTiles().some(t => !decision(t)) || state.loading;
|
|
368
|
-
}
|
|
369
|
-
async function loadHistory() {
|
|
370
|
-
const select = $('#run-select'); select.replaceChildren(new Option(state.run.id, selectedRun() || ''));
|
|
371
|
-
try {
|
|
372
|
-
const response = await fetch('/api/runs'); if (!response.ok) return;
|
|
373
|
-
const runs = await response.json(); if (!Array.isArray(runs)) return;
|
|
374
|
-
const current = selectedRun(); select.replaceChildren();
|
|
375
|
-
if (!current) select.add(new Option(state.run.id + ' · opened run', ''));
|
|
376
|
-
for (const run of runs) select.add(new Option(run.id + ' · ' + run.tiles + ' captures', run.id));
|
|
377
|
-
select.value = current || '';
|
|
378
|
-
} catch { /* The opened run remains available when history cannot load. */ }
|
|
379
|
-
}
|
|
380
|
-
$('#run-select').addEventListener('change', () => {
|
|
381
|
-
if (state.drafts.size || state.pending.size || state.failures.size) return;
|
|
382
|
-
const url = new URL(location.href); if ($('#run-select').value) url.searchParams.set('run', $('#run-select').value); else url.searchParams.delete('run'); url.searchParams.delete('tile'); history.replaceState(null, '', url);
|
|
383
|
-
++state.comparisonGeneration; state.viewer = null; state.focus = null; clearFilters(); load();
|
|
384
|
-
});
|
|
385
|
-
function renderCoverage() {
|
|
386
|
-
const run = state.run; const coverage = run.coverage;
|
|
387
|
-
const summary = $('#coverage-summary'); const details = $('#coverage-details'); details.replaceChildren();
|
|
388
|
-
if (!coverage) { summary.textContent = 'Coverage unknown · legacy run'; details.append(el('p', null, 'Recapture to record planned, skipped and excluded states.')); }
|
|
389
|
-
else {
|
|
390
|
-
const count = outcome => coverage.filter(entry => entry.outcome === outcome).length;
|
|
391
|
-
summary.textContent = count('captured') + ' of ' + coverage.length + ' planned tiles captured · ' + count('failed') + ' failed · ' + count('skipped') + ' unfinished · ' + count('excluded') + ' excluded' + (count('filtered') ? ' · ' + count('filtered') + ' outside this capture’s route filter' : '');
|
|
392
|
-
const list = el('ul');
|
|
393
|
-
for (const entry of coverage) list.append(el('li', null, entry.route + ' · ' + entry.state + ' · ' + entry.viewport + 'px · ' + entry.theme + ': ' + entry.outcome + (entry.reason ? ': ' + entry.reason : '')));
|
|
394
|
-
details.append(list);
|
|
395
|
-
}
|
|
396
|
-
details.append(el('pre', null, run.provenance ? JSON.stringify(run.provenance, null, 2) : 'Capture environment unknown.'));
|
|
397
|
-
}
|
|
398
|
-
$('#report').addEventListener('click', () => { if (!$('#report').disabled) location.href = apiPath('/api/report'); });
|
|
399
|
-
function renderStats() {
|
|
400
|
-
const tiles = state.run.tiles; const n = fn => tiles.filter(fn).length;
|
|
401
|
-
const stats = $('#stats'); stats.replaceChildren();
|
|
402
|
-
for (const [label, value] of [['Captured', tiles.length], ['Flagged', n(isFlagged)], ['Approved', n(t => decision(t) === 'approve')], ['Rejected', n(t => decision(t) === 'reject')], ['Pending', n(t => !decision(t))]]) {
|
|
403
|
-
const box = el('div', 'stat ' + label.toLowerCase()); box.append(el('b', null, String(value)), el('span', null, label)); stats.append(box);
|
|
404
|
-
}
|
|
405
|
-
for (const link of document.querySelectorAll('#route-nav a')) { const routeTiles = tiles.filter(t => t.route === link.dataset.route); link.querySelector('span').textContent = routeTiles.filter(t => !!decision(t)).length + '/' + routeTiles.length; }
|
|
406
|
-
const reviewed = n(t => !!decision(t));
|
|
407
|
-
$('#progress').max = tiles.length || 1; $('#progress').value = reviewed;
|
|
408
|
-
$('#progress-label').textContent = reviewed + ' of ' + tiles.length + ' reviewed' + (tiles.length && reviewed === tiles.length ? ' · Captured evidence reviewed' : '');
|
|
409
|
-
$('#percent').textContent = (tiles.length ? Math.round(reviewed / tiles.length * 100) : 0) + '%';
|
|
410
|
-
const matching = visibleTiles().length;
|
|
411
|
-
$('#results').textContent = matching <= state.pageSize ? matching + ' of ' + tiles.length + ' captures shown' : matching + ' matching captures · ' + tiles.length + ' total';
|
|
412
|
-
updateControls();
|
|
413
|
-
}
|
|
414
|
-
function renderTile(t) {
|
|
415
|
-
const d = decision(t);
|
|
416
|
-
const card = el('article', 'tile' + (d === 'approve' ? ' approved' : d === 'reject' ? ' rejected' : '') + (state.focus === t.id ? ' focus' : ''));
|
|
417
|
-
const more = el('details', 'tile-more'); const moreParts = [];
|
|
418
|
-
if (t.accessibility?.violations.length) { const details = el('details', 'diagnostics'); details.append(el('summary', null, 'Automated accessibility findings')); const list = el('ul'); for (const issue of t.accessibility.violations) list.append(el('li', null, issue.id + ': ' + issue.help + ' · ' + issue.targets.join(', '))); details.append(list); more.append(details); moreParts.push(t.accessibility.violations.length + ' a11y'); }
|
|
419
|
-
if (t.masks?.length) { const details = el('details', 'diagnostics'); details.append(el('summary', null, 'Comparison masks (' + t.masks.length + ')')); const list = el('ul'); for (const mask of t.masks) list.append(el('li', null, mask.selector + ': ' + mask.reason + ' · ' + mask.boxes.map(box => Math.round(box.x) + ',' + Math.round(box.y) + ' ' + Math.round(box.w) + '×' + Math.round(box.h) + 'px').join('; '))); details.append(list); more.append(details); moreParts.push(t.masks.length + ' masks'); }
|
|
420
|
-
card.dataset.id = t.id; card.tabIndex = 0;
|
|
421
|
-
card.setAttribute('aria-label', t.route + ', ' + t.state + ', ' + t.viewport + ' pixels, ' + t.theme + ', ' + (d === 'approve' ? 'approved' : d === 'reject' ? 'rejected' : 'pending'));
|
|
422
|
-
const head = el('div', 'head'); head.append(el('span', null, t.viewport + 'px · ' + t.theme), el('span', null, d === 'approve' ? 'Approved' : d === 'reject' ? 'Rejected' : 'Pending'));
|
|
423
|
-
const thumb = el('div', 'thumb'); thumb.tabIndex = 0; thumb.setAttribute('role', 'region'); thumb.setAttribute('aria-label', 'Screenshot preview for ' + t.id);
|
|
424
|
-
const inspect = el('button', 'button inspect', 'Inspect'); inspect.setAttribute('aria-label', 'Inspect screenshot'); inspect.disabled = !!t.error;
|
|
425
|
-
inspect.addEventListener('click', () => openBox(t, inspect));
|
|
426
|
-
if (!t.error) {
|
|
427
|
-
const img = el('img'); img.loading = 'lazy'; img.src = apiPath('/img/' + encodeURIComponent(t.file));
|
|
428
|
-
img.alt = t.route + ' · ' + t.state + ' · ' + t.viewport + 'px · ' + t.theme;
|
|
429
|
-
img.addEventListener('click', () => openBox(t, inspect));
|
|
430
|
-
img.addEventListener('error', () => { thumb.replaceChildren(el('p', 'capture-missing', 'Screenshot unavailable. Check this run’s capture files.')); inspect.disabled = true; });
|
|
431
|
-
thumb.append(img);
|
|
432
|
-
} else thumb.append(el('p', 'capture-missing', 'No screenshot was captured. See the capture error below.'));
|
|
433
|
-
const badges = el('div', 'badges'); const errors = flags(t);
|
|
434
|
-
if (!errors.length && !t.textClipped.length && !t.warnings?.length) badges.append(el('span', 'badge ok', 'Checks clear'));
|
|
435
|
-
for (const text of errors) badges.append(el('span', 'badge', text));
|
|
436
|
-
if (t.textClipped.length) badges.append(el('span', 'badge warn', t.textClipped.length + ' clipped text warning' + (t.textClipped.length > 1 ? 's' : '')));
|
|
437
|
-
if (t.warnings?.length) badges.append(el('span', 'badge warn', t.warnings.length + ' capture warning' + (t.warnings.length > 1 ? 's' : '')));
|
|
438
|
-
card.append(head, thumb, badges);
|
|
439
|
-
const diagnostics = [...t.consoleErrors, ...t.pageErrors, ...t.brokenImages, ...(t.warnings || []), ...t.textClipped.map(x => x.selector + ': ' + x.text)];
|
|
440
|
-
if (diagnostics.length) {
|
|
441
|
-
const details = el('details', 'diagnostics'); details.append(el('summary', null, 'Diagnostic details (' + diagnostics.length + ')'));
|
|
442
|
-
const list = el('ul'); for (const item of diagnostics) list.append(el('li', null, item)); details.append(list); more.append(details); moreParts.push(diagnostics.length + ' diagnostic' + (diagnostics.length > 1 ? 's' : ''));
|
|
443
|
-
}
|
|
444
|
-
const c = state.critique[t.id];
|
|
445
|
-
if (c) {
|
|
446
|
-
const box = el('div', 'crit'); box.append(el('b', null, c.verdict === 'pass' ? 'Critique: pass' : c.verdict === 'fix' ? 'Critique: needs work' : 'Critique unavailable'));
|
|
447
|
-
if (c.error) box.append(el('p', null, c.error));
|
|
448
|
-
if (c.issues?.length) { const list = el('ul'); for (const issue of c.issues) { const item = el('li'); item.append(el('span', 'sev', issue.severity + ' '), document.createTextNode(issue.what + (issue.where ? ' (' + issue.where + ')' : ''))); list.append(item); } box.append(list); }
|
|
449
|
-
more.append(box); badges.append(el('span', 'badge' + (c.verdict === 'pass' ? ' ok' : c.verdict === 'fix' ? '' : ' warn'), c.verdict === 'pass' ? 'Critique: pass' : c.verdict === 'fix' ? 'Critique: needs work' : 'Critique unavailable')); moreParts.push('critique');
|
|
450
|
-
}
|
|
451
|
-
const comparison = state.comparison[t.id];
|
|
452
|
-
card.append(el('p', 'comparison-status', comparisonLabels[comparison?.status] || (state.comparisonLoading ? 'Comparing…' : 'Not compared')));
|
|
453
|
-
const actions = el('div', 'actions'); actions.append(inspect);
|
|
454
|
-
for (const [value, label] of [['approve', 'Approve'], ['reject', 'Reject']]) {
|
|
455
|
-
const button = el('button', value, label); button.setAttribute('aria-pressed', String(d === value)); button.disabled = state.pending.has(t.id);
|
|
456
|
-
button.setAttribute('aria-label', label + ' ' + t.id); button.addEventListener('click', () => setDecision(t, value)); actions.append(button);
|
|
457
|
-
}
|
|
458
|
-
const noteLabel = el('label', 'note-label', 'Review note');
|
|
459
|
-
const note = el('textarea'); note.placeholder = 'What should change?'; note.maxLength = 2000;
|
|
460
|
-
note.value = state.drafts.get(t.id) ?? state.review[t.id]?.note ?? '';
|
|
461
|
-
note.setAttribute('aria-label', 'Review note for ' + t.id);
|
|
462
|
-
const noteHelp = el('small', null, 'Up to 2,000 characters. Saves when you leave this field.');
|
|
463
|
-
noteHelp.id = 'note-help-' + state.run.tiles.indexOf(t); note.setAttribute('aria-describedby', noteHelp.id);
|
|
464
|
-
note.addEventListener('input', () => { if (note.value === (state.review[t.id]?.note ?? '') && !state.pending.has(t.id)) state.drafts.delete(t.id); else state.drafts.set(t.id, note.value); if (state.failures.has(t.id) && 'note' in state.failures.get(t.id)) state.failures.get(t.id).note = note.value; updateControls(); });
|
|
465
|
-
note.addEventListener('change', () => { if (state.drafts.has(t.id)) save(t, { note: note.value }); });
|
|
466
|
-
noteLabel.append(note, noteHelp); more.prepend(noteLabel);
|
|
467
|
-
const hasNote = !!(state.drafts.get(t.id) ?? state.review[t.id]?.note); if (hasNote) moreParts.unshift('note');
|
|
468
|
-
more.prepend(el('summary', null, 'Note & details' + (moreParts.length ? ' · ' + moreParts.join(' · ') : '')));
|
|
469
|
-
more.open = hasNote || state.failures.has(t.id);
|
|
470
|
-
card.append(actions, more);
|
|
471
|
-
card.addEventListener('focusin', () => { state.focus = t.id; document.querySelectorAll('.tile.focus').forEach(x => x.classList.remove('focus')); card.classList.add('focus'); });
|
|
472
|
-
return card;
|
|
473
|
-
}
|
|
474
|
-
function render() {
|
|
475
|
-
if (!state.run) return;
|
|
476
|
-
renderStats(); const main = $('#cards'); main.replaceChildren(); const visible = visibleTiles();
|
|
477
|
-
state.page = Math.min(state.page, Math.max(0, Math.ceil(visible.length / state.pageSize) - 1));
|
|
478
|
-
for (const pager of document.querySelectorAll('.pagination')) pager.hidden = visible.length <= state.pageSize;
|
|
479
|
-
$('#page-label').textContent = 'Page ' + (state.page + 1) + ' of ' + Math.max(1, Math.ceil(visible.length / state.pageSize)) + ' · ' + Math.min(state.pageSize, visible.length - state.page * state.pageSize) + ' captures on this page';
|
|
480
|
-
$('#pagination-bottom .page-label').textContent = $('#page-label').textContent;
|
|
481
|
-
for (const button of document.querySelectorAll('[data-page-step]')) button.disabled = Number(button.dataset.pageStep) < 0 ? state.page === 0 : (state.page + 1) * state.pageSize >= visible.length;
|
|
482
|
-
if (!state.run.tiles.length) { showEmpty('No captures in this run.', 'Capture your site with the Design Spine CLI, then open the new run for review.'); return; }
|
|
483
|
-
if (!visible.length) { showEmpty('No captures match.', 'Try another route, status, width, or theme. Your review decisions are still saved.', 'Clear filters', clearFilters); return; }
|
|
484
|
-
const groups = new Map();
|
|
485
|
-
for (const t of visible.slice(state.page * state.pageSize, (state.page + 1) * state.pageSize)) { if (!groups.has(t.route)) groups.set(t.route, new Map()); const states = groups.get(t.route); if (!states.has(t.state)) states.set(t.state, []); states.get(t.state).push(t); }
|
|
486
|
-
for (const [route, states] of groups) {
|
|
487
|
-
const section = el('section', 'route'); section.id = 'route-' + [...new Set(state.run.tiles.map(t => t.route))].indexOf(route);
|
|
488
|
-
const heading = el('h2'); heading.tabIndex = -1; heading.append(el('code', null, route)); section.append(heading);
|
|
489
|
-
for (const [name, tiles] of states) { const group = el('div', 'state'); group.append(el('h3', null, name)); const row = el('div', 'row'); for (const t of tiles) row.append(renderTile(t)); group.append(row); section.append(group); }
|
|
490
|
-
main.append(section);
|
|
491
|
-
}
|
|
492
|
-
}
|
|
493
|
-
let saveStatusTimer = 0;
|
|
494
|
-
function setSaveStatus(message, error = false) {
|
|
495
|
-
const box = $('#save-status'); box.classList.toggle('error', error); box.replaceChildren(el('span', null, message));
|
|
496
|
-
clearTimeout(saveStatusTimer); if (!error && !state.failures.size && !state.pending.size) saveStatusTimer = setTimeout(() => { if (!state.failures.size && !state.pending.size) box.replaceChildren(); }, 3000);
|
|
497
|
-
updateViewerControls();
|
|
498
|
-
if (state.failures.size) { const retry = el('button', 'button', 'Retry unsaved changes'); retry.addEventListener('click', retryFailures); box.append(retry); }
|
|
499
|
-
}
|
|
500
|
-
async function save(t, patch) {
|
|
501
|
-
const previous = state.pending.get(t.id) || Promise.resolve();
|
|
502
|
-
const task = previous.catch(() => {}).then(async () => {
|
|
503
|
-
setSaveStatus('Saving changes…');
|
|
504
|
-
try {
|
|
505
|
-
const response = await fetch(apiPath('/api/review'), { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ tileId: t.id, imageHash: t.imageHash, ...patch }) });
|
|
506
|
-
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
507
|
-
const entry = await response.json();
|
|
508
|
-
if (!entry || !['approve', 'reject', null].includes(entry.decision) || typeof entry.note !== 'string') throw new Error('Invalid save response');
|
|
509
|
-
state.review[t.id] = entry;
|
|
510
|
-
if (state.drafts.get(t.id) === entry.note) state.drafts.delete(t.id);
|
|
511
|
-
const failed = state.failures.get(t.id);
|
|
512
|
-
if (failed) { for (const key of Object.keys(patch)) delete failed[key]; if (!Object.keys(failed).length) state.failures.delete(t.id); }
|
|
513
|
-
return true;
|
|
514
|
-
} catch (error) {
|
|
515
|
-
state.failures.set(t.id, { ...state.failures.get(t.id), ...patch });
|
|
516
|
-
return false;
|
|
517
|
-
}
|
|
518
|
-
});
|
|
519
|
-
state.pending.set(t.id, task); updateControls();
|
|
520
|
-
document.querySelectorAll('.tile').forEach(card => { if (card.dataset.id === t.id) card.querySelectorAll('.actions button').forEach(button => button.disabled = true); });
|
|
521
|
-
const saved = await task;
|
|
522
|
-
if (state.pending.get(t.id) === task) state.pending.delete(t.id);
|
|
523
|
-
updateControls();
|
|
524
|
-
document.querySelectorAll('.tile').forEach(card => { if (card.dataset.id === t.id) card.querySelectorAll('.actions button').forEach(button => button.disabled = state.pending.has(t.id) || (button.classList.contains('inspect') && !!t.error)); });
|
|
525
|
-
if (state.failures.size) setSaveStatus('Changes could not be saved. Your drafts remain here; retry before leaving.', true);
|
|
526
|
-
else if (state.pending.size) setSaveStatus('Saving changes…');
|
|
527
|
-
else setSaveStatus(state.drafts.size ? 'Saved. Other note drafts still need saving.' : 'All changes saved locally.');
|
|
528
|
-
return saved;
|
|
529
|
-
}
|
|
530
|
-
async function setDecision(t, value) {
|
|
531
|
-
if (state.pending.has(t.id)) return;
|
|
532
|
-
const before = visibleTiles(); const index = before.findIndex(x => x.id === t.id);
|
|
533
|
-
const viewerBefore = viewerTiles();
|
|
534
|
-
const patch = { decision: decision(t) === value ? null : value };
|
|
535
|
-
if (state.drafts.has(t.id)) patch.note = state.drafts.get(t.id);
|
|
536
|
-
await save(t, patch); const keep = state.focus; render();
|
|
537
|
-
const remaining = visibleTiles();
|
|
538
|
-
if ($('#lightbox').open) reconcileViewer(viewerBefore); else focusTile(remaining.some(x => x.id === keep) ? keep : remaining[Math.min(Math.max(0, index), remaining.length - 1)]?.id, false);
|
|
539
|
-
}
|
|
540
|
-
async function retryFailures() {
|
|
541
|
-
const viewerBefore = viewerTiles();
|
|
542
|
-
for (const [id, failed] of [...state.failures]) {
|
|
543
|
-
const tile = state.run.tiles.find(t => t.id === id); if (!tile || state.pending.has(id)) continue;
|
|
544
|
-
const patch = { ...failed }; if (state.drafts.has(id)) patch.note = state.drafts.get(id);
|
|
545
|
-
await save(tile, patch);
|
|
546
|
-
}
|
|
547
|
-
const keep = state.focus; render(); if ($('#lightbox').open) reconcileViewer(viewerBefore); else focusTile(keep, false);
|
|
548
|
-
}
|
|
549
|
-
function focusTile(id, scroll = true) {
|
|
550
|
-
state.focus = id || null;
|
|
551
|
-
const index = visibleTiles().findIndex(t => t.id === id);
|
|
552
|
-
if (index >= 0 && Math.floor(index / state.pageSize) !== state.page) { state.page = Math.floor(index / state.pageSize); render(); }
|
|
553
|
-
const node = id && document.querySelector('.tile[data-id="' + CSS.escape(id) + '"]');
|
|
554
|
-
if (node) { node.focus({ preventScroll: true }); if (scroll) node.scrollIntoView({ block: 'center', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'instant' : 'smooth' }); }
|
|
555
|
-
}
|
|
556
|
-
function move(direction) {
|
|
557
|
-
const visible = visibleTiles(); if (!visible.length) return;
|
|
558
|
-
const index = visible.findIndex(t => t.id === state.focus);
|
|
559
|
-
const next = index < 0 ? (direction > 0 ? 0 : visible.length - 1) : Math.min(visible.length - 1, Math.max(0, index + direction));
|
|
560
|
-
focusTile(visible[next].id);
|
|
561
|
-
}
|
|
562
|
-
function updateRegions() {
|
|
563
|
-
const tile = state.run?.tiles.find(t => t.id === state.viewer);
|
|
564
|
-
const img = $('#viewer-image');
|
|
565
|
-
const note = state.review[tile?.id]?.anchor; const pin = $('#note-overlay'); pin.replaceChildren(); pin.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight);
|
|
566
|
-
pin.toggleAttribute('hidden', !note || note.imageHash !== tile?.imageHash || !['current', 'split', 'overlay'].includes($('#viewer-mode').value) || !img.naturalWidth);
|
|
567
|
-
if (!pin.hasAttribute('hidden')) { const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); circle.setAttribute('cx', note.x); circle.setAttribute('cy', note.y); circle.setAttribute('r', '10'); pin.append(circle); }
|
|
568
|
-
const supported = ['current', 'split'].includes($('#viewer-mode').value);
|
|
569
|
-
const boxes = supported ? (tile?.blankRegions?.boxes || []).filter(b => [b.x, b.y, b.w, b.h].every(Number.isFinite) && b.w > 0 && b.h > 0) : [];
|
|
570
|
-
$('.diagnostic-controls').hidden = !boxes.length;
|
|
571
|
-
const toggle = $('#viewer-regions'); toggle.disabled = !boxes.length;
|
|
572
|
-
const select = $('#viewer-region'); const selected = select.value; select.replaceChildren(new Option('Choose a region', ''));
|
|
573
|
-
boxes.forEach((box, index) => select.add(new Option('Possible blank region ' + (index + 1), String(index))));
|
|
574
|
-
select.disabled = !boxes.length; select.value = selected;
|
|
575
|
-
const overlay = $('#diagnostic-overlay'); overlay.replaceChildren();
|
|
576
|
-
overlay.toggleAttribute('hidden', !boxes.length || toggle.getAttribute('aria-pressed') !== 'true' || img.hidden || !img.complete || !img.naturalWidth);
|
|
577
|
-
overlay.setAttribute('viewBox', '0 0 ' + img.naturalWidth + ' ' + img.naturalHeight);
|
|
578
|
-
boxes.forEach((box, index) => {
|
|
579
|
-
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
580
|
-
for (const [name, value] of Object.entries({ x: box.x, y: box.y, width: box.w, height: box.h })) rect.setAttribute(name, value);
|
|
581
|
-
rect.dataset.region = index; overlay.append(rect);
|
|
582
|
-
});
|
|
583
|
-
}
|
|
584
|
-
function viewerTiles() { return visibleTiles().filter(t => !t.error); }
|
|
585
|
-
function reconcileViewer(before) {
|
|
586
|
-
const tiles = viewerTiles(); const remaining = new Set(tiles.map(t => t.id));
|
|
587
|
-
if (!remaining.has(state.viewer)) {
|
|
588
|
-
const index = before.findIndex(t => t.id === state.viewer);
|
|
589
|
-
const neighbors = [...before.slice(index + 1), ...before.slice(0, Math.max(0, index)).reverse()];
|
|
590
|
-
state.viewer = neighbors.find(t => remaining.has(t.id))?.id || tiles[0]?.id || null;
|
|
591
|
-
if (state.viewer) { updateViewer(); $('#viewer-close').focus(); }
|
|
592
|
-
else $('#lightbox').close();
|
|
593
|
-
} else updateViewerControls();
|
|
594
|
-
}
|
|
595
|
-
function updateViewerControls() {
|
|
596
|
-
const tile = state.run?.tiles.find(t => t.id === state.viewer);
|
|
597
|
-
const pending = !!tile && state.pending.has(tile.id);
|
|
598
|
-
for (const [id, value] of [['#viewer-approve', 'approve'], ['#viewer-reject', 'reject']]) {
|
|
599
|
-
$(id).disabled = !tile || pending; $(id).setAttribute('aria-pressed', String(!!tile && decision(tile) === value));
|
|
600
|
-
}
|
|
601
|
-
$('#viewer-decision').textContent = tile ? (decision(tile) === 'approve' ? 'Approved' : decision(tile) === 'reject' ? 'Rejected' : 'Pending') : 'Pending';
|
|
602
|
-
$('#viewer-save-note').disabled = !tile || pending || !state.drafts.has(tile.id);
|
|
603
|
-
$('#viewer-pending').disabled = !viewerTiles().some(t => t.id !== state.viewer && !decision(t));
|
|
604
|
-
$('#viewer-retry').hidden = !state.failures.size; $('#viewer-retry').disabled = !!state.pending.size;
|
|
605
|
-
$('#viewer-note-summary').textContent = 'Review note' + (tile && state.drafts.has(tile.id) ? ' · unsaved' : tile && state.review[tile.id]?.note ? ' · saved' : '');
|
|
606
|
-
const status = $('#viewer-save-status'); status.classList.toggle('error', !!state.failures.size);
|
|
607
|
-
status.textContent = state.failures.size ? 'Changes could not be saved. Your drafts remain here; retry before leaving.' : state.pending.size ? 'Saving changes…' : state.drafts.size ? 'Unsaved note drafts remain. Save each note or include it with a decision.' : 'All changes saved locally. Baselines have not changed.';
|
|
608
|
-
}
|
|
609
|
-
function updateViewer() {
|
|
610
|
-
const tiles = viewerTiles(); const index = tiles.findIndex(t => t.id === state.viewer); const tile = tiles[index]; if (!tile) return;
|
|
611
|
-
state.focus = tile.id;
|
|
612
|
-
const url = new URL(location.href); url.searchParams.set('tile', tile.id); history.replaceState(null, '', url);
|
|
613
|
-
$('#viewer-note').value = state.drafts.get(tile.id) ?? state.review[tile.id]?.note ?? '';
|
|
614
|
-
updateViewerControls();
|
|
615
|
-
$('#viewer-title').textContent = tile.route + ' · ' + tile.state;
|
|
616
|
-
$('#viewer-position').textContent = tile.viewport + 'px · ' + tile.theme + ' · ' + (index + 1) + ' of ' + tiles.length;
|
|
617
|
-
const comparison = state.comparison[tile.id] || {};
|
|
618
|
-
const mode = $('#viewer-mode');
|
|
619
|
-
for (const option of mode.options) option.disabled = option.value === 'diff' ? !comparison.diffUrl : ['baseline', 'split', 'overlay'].includes(option.value) && !comparison.baselineUrl;
|
|
620
|
-
if (mode.selectedOptions[0].disabled) mode.value = 'current';
|
|
621
|
-
if (comparison.status === 'size-changed') { mode.querySelector('[value=overlay]').disabled = true; if (mode.value === 'overlay') mode.value = 'current'; }
|
|
622
|
-
const view = mode.value;
|
|
623
|
-
$('#overlay-image').hidden = view !== 'overlay'; $('#overlay-image').src = view === 'overlay' ? comparison.baselineUrl : ''; $('#blend-control').hidden = view !== 'overlay';
|
|
624
|
-
const changes = $('#change-region'); changes.replaceChildren(new Option('Choose a change', ''));
|
|
625
|
-
(comparison.regions || []).forEach((box, index) => changes.add(new Option('Change ' + (index + 1) + ' · y ' + box.y + 'px', String(index))));
|
|
626
|
-
changes.disabled = !(comparison.regions?.length) || view === 'baseline';
|
|
627
|
-
$('#anchor-note').disabled = !tile.imageHash || view !== 'current' || state.pending.has(tile.id);
|
|
628
|
-
$('#anchor-note').setAttribute('aria-pressed', 'false');
|
|
629
|
-
const anchor = state.review[tile.id]?.anchor; $('#clear-anchor').hidden = !anchor; $('#jump-anchor').hidden = !anchor || anchor.imageHash !== tile.imageHash || !['current', 'split', 'overlay'].includes(view);
|
|
630
|
-
$('#anchor-x').value = anchor?.x ?? 0; $('#anchor-y').value = anchor?.y ?? 0; $('#save-anchor').disabled = !tile.imageHash || view !== 'current' || state.pending.has(tile.id);
|
|
631
|
-
$('#anchor-status').textContent = anchor ? (anchor.imageHash === tile.imageHash ? 'Note location: ' + anchor.x + ', ' + anchor.y + 'px.' : 'Note location belongs to different evidence.') : '';
|
|
632
|
-
$('#viewer-size').disabled = view === 'split';
|
|
633
|
-
if (view === 'split') { $('#viewer-image').classList.remove('actual'); $('#baseline-image').classList.remove('actual'); $('#viewer-size').setAttribute('aria-pressed', 'false'); $('#viewer-size').textContent = 'Actual size'; }
|
|
634
|
-
$('#baseline-pane').hidden = view !== 'split'; $('.viewer-pair').classList.toggle('split', view === 'split');
|
|
635
|
-
$('#baseline-image').src = view === 'split' ? comparison.baselineUrl : ''; $('#baseline-image').alt = 'Baseline for ' + tile.route + ', ' + tile.state;
|
|
636
|
-
$('#current-caption').textContent = view === 'baseline' ? 'Baseline' : view === 'diff' ? 'Difference · highlighted pixels' : 'Current';
|
|
637
|
-
$('#comparison-context').textContent = (comparisonLabels[comparison.status] || 'Not compared') + ' · threshold ' + (state.threshold * 100).toFixed(2) + '%' + (comparison.ratio !== undefined ? ' · ' + (comparison.ratio * 100).toFixed(3) + '% pixels differ' : '') + (comparison.baselineRun ? ' · baseline ' + comparison.baselineRun : '') + (comparison.approvedAt ? ' · approved ' + comparison.approvedAt : '') + (comparison.ignored?.length ? ' · ' + comparison.ignored.length + ' comparison masks; original evidence retained' : '') + (comparison.recipeCompatible === false ? ' · capture recipe differs or unknown' : '') + (comparison.provenance ? ' · environment ' + comparison.provenance : '') + (comparison.status === 'size-changed' ? ' · Native dimensions differ; pixel overlay unavailable.' : '');
|
|
638
|
-
const img = $('#viewer-image'); img.hidden = false; $('#viewer-error').hidden = true;
|
|
639
|
-
img.alt = $('#current-caption').textContent + ' for ' + tile.route + ', ' + tile.state + ', ' + tile.viewport + 'px, ' + tile.theme;
|
|
640
|
-
img.src = view === 'baseline' ? comparison.baselineUrl : view === 'diff' ? comparison.diffUrl : apiPath('/img/' + encodeURIComponent(tile.file));
|
|
641
|
-
$('#viewer-download').href = img.src; $('#viewer-download').download = tile.file; $('#viewer-download').setAttribute('aria-disabled', 'false'); $('#viewer-download').removeAttribute('tabindex');
|
|
642
|
-
$('#viewer-prev').disabled = index <= 0; $('#viewer-next').disabled = index >= tiles.length - 1;
|
|
643
|
-
$('.evidence-frame').classList.toggle('actual', img.classList.contains('actual')); updateRegions();
|
|
644
|
-
$('.viewer-canvas').scrollTop = 0; $('.viewer-canvas').scrollLeft = 0;
|
|
645
|
-
}
|
|
646
|
-
function openBox(tile, source = document.activeElement) {
|
|
647
|
-
if (tile.error) return;
|
|
648
|
-
$('#viewer-mode').value = 'current'; state.viewer = tile.id; state.returnFocus = source; $('#viewer-image').classList.remove('actual'); $('#baseline-image').classList.remove('actual'); $('#viewer-size').setAttribute('aria-pressed', 'false'); $('#viewer-size').textContent = 'Actual size';
|
|
649
|
-
const url = new URL(location.href); url.searchParams.set('tile', tile.id); history.replaceState(null, '', url);
|
|
650
|
-
updateViewer(); $('#lightbox').showModal(); $('#viewer-close').focus();
|
|
651
|
-
}
|
|
652
|
-
function changeViewer(direction) { const tiles = viewerTiles(); const index = tiles.findIndex(t => t.id === state.viewer); const tile = tiles[index + direction]; if (tile) { state.viewer = tile.id; updateViewer(); } }
|
|
653
|
-
$('#save-anchor').addEventListener('click', async () => {
|
|
654
|
-
const tile = state.run.tiles.find(t => t.id === state.viewer);
|
|
655
|
-
const x = Number($('#anchor-x').value), y = Number($('#anchor-y').value);
|
|
656
|
-
if (!Number.isInteger(x) || !Number.isInteger(y) || x < 0 || y < 0 || x >= $('#viewer-image').naturalWidth || y >= $('#viewer-image').naturalHeight) { $('#anchor-status').textContent = 'Enter coordinates within the original screenshot dimensions.'; return; }
|
|
657
|
-
await save(tile, { anchor: { x, y, imageHash: tile.imageHash } }); updateViewer();
|
|
658
|
-
});
|
|
659
|
-
$('#jump-anchor').addEventListener('click', () => {
|
|
660
|
-
const anchor = state.review[state.viewer]?.anchor; if (!anchor) return;
|
|
661
|
-
const image = $('#viewer-image'); const canvas = $('.viewer-canvas'); const bounds = image.getBoundingClientRect(); const container = canvas.getBoundingClientRect();
|
|
662
|
-
canvas.scrollTop += bounds.top - container.top + anchor.y * bounds.height / image.naturalHeight - 24;
|
|
663
|
-
});
|
|
664
|
-
$('#blend').addEventListener('input', event => { $('#overlay-image').style.opacity = event.target.value / 100; });
|
|
665
|
-
$('#change-region').addEventListener('change', event => {
|
|
666
|
-
const box = state.comparison[state.viewer]?.regions?.[Number(event.target.value)]; const img = $('#viewer-image');
|
|
667
|
-
if (event.target.value === '' || !box || !img.naturalHeight) return;
|
|
668
|
-
const canvas = $('.viewer-canvas'); const bounds = img.getBoundingClientRect(); const container = canvas.getBoundingClientRect();
|
|
669
|
-
canvas.scrollTop += bounds.top - container.top + box.y * bounds.height / img.naturalHeight - 24;
|
|
670
|
-
});
|
|
671
|
-
$('#anchor-note').addEventListener('click', () => {
|
|
672
|
-
const button = $('#anchor-note'); const active = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(active));
|
|
673
|
-
$('#anchor-status').textContent = active ? 'Click the screenshot to save the location for this note.' : '';
|
|
674
|
-
});
|
|
675
|
-
$('#viewer-image').addEventListener('click', async event => {
|
|
676
|
-
if ($('#anchor-note').getAttribute('aria-pressed') !== 'true') return;
|
|
677
|
-
const tile = state.run.tiles.find(t => t.id === state.viewer); const image = event.currentTarget; const bounds = image.getBoundingClientRect();
|
|
678
|
-
const anchor = { x: Math.min(image.naturalWidth - 1, Math.max(0, Math.floor((event.clientX - bounds.left) * image.naturalWidth / bounds.width))), y: Math.min(image.naturalHeight - 1, Math.max(0, Math.floor((event.clientY - bounds.top) * image.naturalHeight / bounds.height))), imageHash: tile.imageHash };
|
|
679
|
-
await save(tile, { anchor }); updateViewer();
|
|
680
|
-
});
|
|
681
|
-
$('#clear-anchor').addEventListener('click', async () => { const tile = state.run.tiles.find(t => t.id === state.viewer); await save(tile, { anchor: null }); updateViewer(); });
|
|
682
|
-
$('#viewer-note').addEventListener('input', event => {
|
|
683
|
-
const tile = state.run?.tiles.find(t => t.id === state.viewer); if (!tile) return;
|
|
684
|
-
const value = event.target.value;
|
|
685
|
-
if (value === (state.review[tile.id]?.note ?? '') && !state.pending.has(tile.id)) state.drafts.delete(tile.id); else state.drafts.set(tile.id, value);
|
|
686
|
-
if (state.failures.has(tile.id) && 'note' in state.failures.get(tile.id)) state.failures.get(tile.id).note = value;
|
|
687
|
-
updateControls();
|
|
688
|
-
});
|
|
689
|
-
$('#viewer-save-note').addEventListener('click', async () => {
|
|
690
|
-
const tile = state.run?.tiles.find(t => t.id === state.viewer);
|
|
691
|
-
if (tile && state.drafts.has(tile.id) && !state.pending.has(tile.id)) { await save(tile, { note: state.drafts.get(tile.id) }); render(); }
|
|
692
|
-
});
|
|
693
|
-
for (const [id, value] of [['#viewer-approve', 'approve'], ['#viewer-reject', 'reject']]) $(id).addEventListener('click', () => { const tile = state.run?.tiles.find(t => t.id === state.viewer); if (tile) setDecision(tile, value); });
|
|
694
|
-
$('#viewer-pending').addEventListener('click', () => {
|
|
695
|
-
const tiles = viewerTiles(); const index = tiles.findIndex(t => t.id === state.viewer);
|
|
696
|
-
const next = [...tiles.slice(index + 1), ...tiles.slice(0, index)].find(t => !decision(t));
|
|
697
|
-
if (next) { state.viewer = next.id; updateViewer(); }
|
|
698
|
-
});
|
|
699
|
-
$('#viewer-retry').addEventListener('click', retryFailures);
|
|
700
|
-
$('#viewer-close').addEventListener('click', () => $('#lightbox').close());
|
|
701
|
-
$('#lightbox').addEventListener('close', () => { const url = new URL(location.href); url.searchParams.delete('tile'); history.replaceState(null, '', url); const viewed = state.viewer; const inspect = state.returnFocus?.classList.contains('inspect') && state.returnFocus.closest('.tile')?.dataset.id === viewed; state.viewer = null; render(); if (visibleTiles().some(t => t.id === viewed)) { focusTile(viewed, false); if (inspect) document.querySelector('.tile[data-id="' + CSS.escape(viewed) + '"] .inspect')?.focus({ preventScroll: true }); } else $('#clear-filters').focus(); });
|
|
702
|
-
$('#lightbox').addEventListener('click', event => { if (event.target === $('#lightbox')) { const rect = event.target.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) event.target.close(); } });
|
|
703
|
-
$('#viewer-mode').addEventListener('change', () => { $('#viewer-region').value = ''; updateViewer(); });
|
|
704
|
-
$('#viewer-image').addEventListener('load', updateRegions);
|
|
705
|
-
$('#viewer-regions').addEventListener('click', () => { const button = $('#viewer-regions'); const enabled = button.getAttribute('aria-pressed') !== 'true'; button.setAttribute('aria-pressed', String(enabled)); button.textContent = enabled ? 'Hide blank regions' : 'Show blank regions'; updateRegions(); });
|
|
706
|
-
$('#viewer-region').addEventListener('change', () => { const value = $('#viewer-region').value; if (value === '') return; $('#viewer-regions').setAttribute('aria-pressed', 'true'); $('#viewer-regions').textContent = 'Hide blank regions'; updateRegions(); $('#diagnostic-overlay').querySelector('[data-region="' + value + '"]')?.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); });
|
|
707
|
-
$('#viewer-refresh').addEventListener('click', async () => { const button = $('#viewer-refresh'); button.disabled = true; try { await loadComparison(); if (state.viewer) updateViewer(); } finally { button.disabled = false; } });
|
|
708
|
-
$('#baseline-image').addEventListener('error', () => { if (!$('#baseline-pane').hidden) $('#viewer-error').hidden = false; });
|
|
709
|
-
$('#viewer-prev').addEventListener('click', () => changeViewer(-1)); $('#viewer-next').addEventListener('click', () => changeViewer(1));
|
|
710
|
-
$('#viewer-size').addEventListener('click', () => { const actual = $('#viewer-image').classList.toggle('actual'); $('#baseline-image').classList.toggle('actual', actual); $('.evidence-frame').classList.toggle('actual', actual); $('#viewer-size').setAttribute('aria-pressed', String(actual)); $('#viewer-size').textContent = actual ? 'Fit to width' : 'Actual size'; });
|
|
711
|
-
$('#viewer-image').addEventListener('error', () => { $('#viewer-image').hidden = true; $('#diagnostic-overlay').setAttribute('hidden', ''); $('#viewer-download').removeAttribute('href'); $('#viewer-download').setAttribute('aria-disabled', 'true'); $('#viewer-download').tabIndex = -1; $('#viewer-error').hidden = false; });
|
|
712
|
-
document.addEventListener('keydown', event => {
|
|
713
|
-
if (event.metaKey || event.ctrlKey || event.altKey) return;
|
|
714
|
-
if ($('#lightbox').open) { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName) || event.target.isContentEditable) return; if (event.key === 'ArrowRight') { event.preventDefault(); changeViewer(1); } if (event.key === 'ArrowLeft') { event.preventDefault(); changeViewer(-1); } return; }
|
|
715
|
-
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(event.target.tagName) || event.target.isContentEditable) return;
|
|
716
|
-
const tile = state.run?.tiles.find(t => t.id === state.focus);
|
|
717
|
-
if (event.key === 'j') { event.preventDefault(); move(1); }
|
|
718
|
-
else if (event.key === 'k') { event.preventDefault(); move(-1); }
|
|
719
|
-
else if (event.key === 'a' && tile) setDecision(tile, 'approve');
|
|
720
|
-
else if (event.key === 'r' && tile) setDecision(tile, 'reject');
|
|
721
|
-
else if (event.key === 'Enter' && tile && event.target.classList.contains('tile')) { event.preventDefault(); openBox(tile); }
|
|
722
|
-
});
|
|
723
|
-
function clearFilters() {
|
|
724
|
-
state.page = 0; state.search = ''; state.status = ''; state.evidence = ''; state.viewport = ''; state.theme = ''; state.onlyFlagged = false; state.onlyUnreviewed = false; state.onlyChanged = false;
|
|
725
|
-
$('#search').value = ''; for (const id of ['#status-filter', '#comparison-filter', '#viewport-filter', '#theme-filter']) $(id).value = '';
|
|
726
|
-
$('#f-changed').setAttribute('aria-pressed', 'false'); $('#f-flagged').setAttribute('aria-pressed', 'false'); $('#f-unrev').setAttribute('aria-pressed', 'false'); render();
|
|
727
|
-
}
|
|
728
|
-
for (const [id, key] of [['#f-changed', 'onlyChanged'], ['#f-flagged', 'onlyFlagged'], ['#f-unrev', 'onlyUnreviewed']]) $(id).addEventListener('click', event => { state.page = 0; state[key] = !state[key]; if (key === 'onlyChanged') { state.evidence = ''; $('#comparison-filter').value = ''; } event.currentTarget.setAttribute('aria-pressed', String(state[key])); render(); });
|
|
729
|
-
for (const [id, key] of [['#status-filter', 'status'], ['#comparison-filter', 'evidence'], ['#viewport-filter', 'viewport'], ['#theme-filter', 'theme']]) $(id).addEventListener('change', event => { state.page = 0; state[key] = event.target.value; if (key === 'evidence') { state.onlyChanged = false; $('#f-changed').setAttribute('aria-pressed', 'false'); } render(); });
|
|
730
|
-
$('#search').addEventListener('input', event => { state.page = 0; state.search = event.target.value.trim().toLowerCase(); render(); });
|
|
731
|
-
function changePage(direction) { state.page += direction; render(); focusTile(visibleTiles()[state.page * state.pageSize]?.id); }
|
|
732
|
-
for (const button of document.querySelectorAll('[data-page-step]')) button.addEventListener('click', () => changePage(Number(button.dataset.pageStep)));
|
|
733
|
-
$('#clear-filters').addEventListener('click', clearFilters);
|
|
734
|
-
$('#next-pending').addEventListener('click', () => {
|
|
735
|
-
const pending = visibleTiles().filter(t => !decision(t));
|
|
736
|
-
const index = pending.findIndex(t => t.id === state.focus); focusTile(pending[(index + 1) % pending.length]?.id);
|
|
737
|
-
});
|
|
738
|
-
$('#export').addEventListener('click', () => {
|
|
739
|
-
if (!state.run || state.drafts.size || state.pending.size || state.failures.size || state.comparisonLoading) return;
|
|
740
|
-
const report = { exportedAt: new Date().toISOString(), run: state.run, review: { tiles: state.review }, critique: { tiles: state.critique }, comparison: { status: state.comparisonFailed ? 'unavailable' : 'complete', comparedAt: state.comparedAt, threshold: state.threshold, tiles: state.comparison } };
|
|
741
|
-
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2) + '\n'], { type: 'application/json' }));
|
|
742
|
-
const link = el('a'); link.href = url; link.download = 'design-spine-' + state.run.id.replace(/[^a-zA-Z0-9_-]/g, '-') + '.json'; link.click(); setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
743
|
-
setSaveStatus('Review report downloaded. Baselines have not changed.');
|
|
744
|
-
});
|
|
745
|
-
window.addEventListener('beforeunload', event => { if (state.drafts.size || state.pending.size || state.failures.size) { event.preventDefault(); event.returnValue = ''; } });
|
|
746
|
-
load();
|
|
747
|
-
</script>
|
|
40
|
+
<script type="module" src="/assets/review.js"></script>
|
|
748
41
|
</body>
|
|
749
42
|
</html>
|