screengraft 0.59.2 → 0.61.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "screengraft",
3
- "version": "0.59.2",
3
+ "version": "0.61.0",
4
4
  "description": "Put a UI screenshot or screen recording onto a photographed device screen with the perspective exactly right \u2014 a homography you confirm by hand, not a generative guess.",
5
5
  "keywords": [
6
6
  "mockup",
@@ -0,0 +1,125 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ screengraft -- the images a designer has actually used, per role.
4
+
5
+ The picker used to list whatever was newest on ~/Desktop and ~/Downloads. That
6
+ is a guess about where files live; this is a record of what was chosen. Every
7
+ source that reaches the session through /api/use or /api/upload is written
8
+ here, newest first, one list per role (a photograph of a device and a
9
+ screenshot to put on it are different populations), and the picker shows the
10
+ ones that still exist on disk.
11
+
12
+ Store: ~/.screengraft/recents.json
13
+ {"photo": [{"path": ..., "name": ..., "used": <epoch>}, ...], "screenshot": [...]}
14
+
15
+ An uploaded file's path is its session copy, which the launch-time sweep
16
+ removes when nothing was saved from that session -- so an upload stays recent
17
+ only as long as its session does. A file chosen by path is read through in
18
+ place and stays for as long as the file does.
19
+
20
+ First run: seeds itself from the sessions that already exist, oldest first,
21
+ so the list is not empty for someone who has been using the tool for a week.
22
+ """
23
+ import json
24
+ import os
25
+ import time
26
+
27
+ ROLES = ("photo", "screenshot")
28
+ CAP = 60
29
+
30
+
31
+ def store_path() -> str:
32
+ return os.path.join(os.path.expanduser("~"), ".screengraft", "recents.json")
33
+
34
+
35
+ def _load() -> dict:
36
+ try:
37
+ with open(store_path()) as f:
38
+ d = json.load(f)
39
+ if not isinstance(d, dict):
40
+ raise ValueError
41
+ except (OSError, ValueError):
42
+ d = _seed()
43
+ for r in ROLES:
44
+ d.setdefault(r, [])
45
+ return d
46
+
47
+
48
+ def _save(d: dict):
49
+ p = store_path()
50
+ os.makedirs(os.path.dirname(p), exist_ok=True)
51
+ tmp = p + ".tmp"
52
+ with open(tmp, "w") as f:
53
+ json.dump(d, f, indent=1)
54
+ os.replace(tmp, p)
55
+
56
+
57
+ def _seed() -> dict:
58
+ """Once: what the existing sessions say was used, in the order they ran."""
59
+ d = {r: [] for r in ROLES}
60
+ root = os.path.join(os.path.expanduser("~"), ".screengraft", "sessions")
61
+ try:
62
+ names = sorted(os.listdir(root))
63
+ except OSError:
64
+ return d
65
+ for n in names:
66
+ sp = os.path.join(root, n, "state.json")
67
+ try:
68
+ with open(sp) as f:
69
+ st = json.load(f)
70
+ used = os.path.getmtime(sp)
71
+ except (OSError, ValueError):
72
+ continue
73
+ for r in ROLES:
74
+ p = st.get(r)
75
+ if isinstance(p, str) and os.path.isfile(p):
76
+ _push(d, r, p, used)
77
+ return d
78
+
79
+
80
+ def _push(d: dict, role: str, path: str, used: float):
81
+ lst = [e for e in d[role] if e.get("path") != path]
82
+ lst.insert(0, {"path": path, "name": os.path.basename(path), "used": used})
83
+ d[role] = lst[:CAP]
84
+
85
+
86
+ def record(role: str, path: str):
87
+ if role not in ROLES or not path:
88
+ return
89
+ d = _load()
90
+ _push(d, role, os.path.realpath(path), time.time())
91
+ _save(d)
92
+
93
+
94
+ def items(role: str, limit: int = 40) -> list:
95
+ """Newest-used first, only what still exists; dead entries are dropped."""
96
+ if role not in ROLES:
97
+ return []
98
+ d = _load()
99
+ live, out = [], []
100
+ for e in d[role]:
101
+ p = e.get("path")
102
+ if not (isinstance(p, str) and os.path.isfile(p)):
103
+ continue
104
+ live.append(e)
105
+ try:
106
+ st = os.stat(p)
107
+ except OSError:
108
+ continue
109
+ out.append({"path": p, "name": e.get("name") or os.path.basename(p), "used": e.get("used", 0),
110
+ "mtime": st.st_mtime, "bytes": st.st_size,
111
+ "ext": os.path.splitext(p)[1].lower(),
112
+ "folder": _short_folder(os.path.dirname(p))})
113
+ if len(live) != len(d[role]):
114
+ d[role] = live
115
+ _save(d)
116
+ return out[:limit]
117
+
118
+
119
+ def _short_folder(dirpath: str) -> str:
120
+ home = os.path.expanduser("~")
121
+ if dirpath.startswith(os.path.join(home, ".screengraft", "sessions")):
122
+ return "dropped in"
123
+ if dirpath.startswith(home + os.sep):
124
+ return "~" + dirpath[len(home):]
125
+ return dirpath
package/scripts/ui.py CHANGED
@@ -50,6 +50,7 @@ import numpy as np # noqa: E402
50
50
  import detect as D # noqa: E402
51
51
  import fitfile as FF # noqa: E402
52
52
  import fits as FIT # noqa: E402
53
+ import recents as RECENT # noqa: E402
53
54
  import dof as DOF # noqa: E402
54
55
  import grade as _grade # noqa: E402
55
56
  import scan as S # noqa: E402
@@ -727,6 +728,25 @@ def _dof_args(b):
727
728
  ROLES = ("photo", "screenshot")
728
729
 
729
730
 
731
+ def _thumb_for(item):
732
+ """A thumbnail for a recent: scan.thumb for stills; the first frame for a clip."""
733
+ out_dir = os.path.join(SESSION.dir, "thumbs")
734
+ if item.get("ext") in VIDEO_EXT:
735
+ os.makedirs(out_dir, exist_ok=True)
736
+ out = os.path.join(out_dir, f"{abs(hash(item['path']))}_{int(item.get('mtime') or 0)}_clip.jpg")
737
+ if not os.path.exists(out):
738
+ try:
739
+ fr = W.read_frame_at(item["path"], 0)
740
+ h, w = fr.shape[:2]
741
+ sc = 320 / max(h, w)
742
+ fr = cv2.resize(fr, (max(1, int(w * sc)), max(1, int(h * sc))), interpolation=cv2.INTER_AREA)
743
+ cv2.imwrite(out, fr, [cv2.IMWRITE_JPEG_QUALITY, 80])
744
+ except Exception:
745
+ return None
746
+ return out
747
+ return S.thumb(item, out_dir)
748
+
749
+
730
750
  def _adopt(role, path):
731
751
  """Make a chosen source the session's, and answer what the page needs.
732
752
 
@@ -751,6 +771,7 @@ def _adopt(role, path):
751
771
  im, real = _read_image(path)
752
772
  meta = {"video": False}
753
773
  SESSION.update(**{role: real})
774
+ RECENT.record(role, real)
754
775
  if role == "photo":
755
776
  # The quad is a property of the PHOTOGRAPH, so a fit saved on an earlier
756
777
  # run is a better starting position than any detector -- and a stronger
@@ -956,10 +977,15 @@ class Handler(BaseHTTPRequestHandler):
956
977
  "home": HOME, "presets": PRESETS, "version": VERSION,
957
978
  "build": BUILD})
958
979
  if u.path == "/api/recent":
959
- items = S.scan(days=int(q.get("days", ["14"])[0]), limit=int(q.get("limit", ["40"])[0]))
980
+ # What was USED, per role -- not what is newest in two folders.
981
+ # The folder scan (scan.py) still exists for the CLI and for a
982
+ # first run with nothing used yet; ?role= selects the list.
983
+ role = (q.get("role") or ["photo"])[0]
984
+ limit = int(q.get("limit", ["40"])[0])
985
+ items = RECENT.items(role, limit)
960
986
  for it in items:
961
- it["thumb"] = S.thumb(it, os.path.join(SESSION.dir, "thumbs"))
962
- return self._json({"items": items})
987
+ it["thumb"] = _thumb_for(it)
988
+ return self._json({"items": items, "role": role})
963
989
  if u.path == "/file":
964
990
  return self._file(_safe_local_path(q["path"][0]))
965
991
  if u.path == "/api/job":
@@ -5,7 +5,7 @@ description: Injects a UI screenshot OR a screen recording onto a photographed d
5
5
 
6
6
  # Inject a screenshot onto a photographed device
7
7
 
8
- **What ships (v0.59):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (recent Desktop/Downloads images, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
8
+ **What ships (v0.61):** a local browser UI (`scripts/ui.py`) that walks the designer through the whole job — pick the photo and the screen source, which may be an image **or a video** (the images you have used before, drag-drop, browse, path, or a **Figma frame link**), auto-detect the screen as a starting position — and when detection cannot tell which region is a screen, **Point at screen**: one click inside it and the detector uses that point — or, if this photograph has been fitted before, **the fit it was saved with comes back** as the starting position instead of a detection, recognised by the photo's own pixels so a rename or a drag-drop still match — and every save also writes a **portable `.fit.json` beside the mockup** that can be dropped back onto the page later, which is how a fit survives a re-export, another machine, or someone else's hands — then **match the four edges** (drag an edge's middle to slide it, near an end to pivot; corners still draggable) with canvas navigation that follows the usual conventions — **hold ⌘ and scroll to zoom to the pointer, hold space and drag to pan** — and a rectified strip loupe. The fit and the composite sit **side by side and always have** — the result pane re-renders as you drag, which is how a corner gets judged, so it is the layout rather than a mode you can switch off. Then an on-by-default realism pass that colour-matches the source to the photo's light, **Save** (or **Render**, for a video) into the project folder (`--out-dir`), and a **Send to Claude** button that reaches you through the plugin's own MCP server. The UI is a hand port of the project's Figma design file — dark only.
9
9
 
10
10
  The geometry is exact (`warp.py`); the detection is advisory (`detect.py`) and the human corrects it. **When a detection is wrong and you want to know why**, ask for the candidate list: `POST /api/detect {"trace": true}` writes `<session>/candidates.json`, or run `python3 scripts/detect.py --photo P --out-corners /tmp/c.json --trace /tmp/t.json` (add `--click X,Y`). Every candidate quad is in there with its score and whether it was accepted, rejected, never reached, or filtered out by the click — which is what separates "the screen was never proposed" from "it was proposed and something else won".
11
11
 
@@ -68,7 +68,7 @@ It prints one JSON line — `url`, `base`, `token`, `session`, `job`, `result`,
68
68
 
69
69
  > **What this does** — it computes the perspective between your photo and your screenshot, so the screenshot lands on the glass exactly. Geometry, not AI: nothing is invented and your pixels are unchanged.
70
70
  >
71
- > **1 · Choose a photo, then a screenshot — or a screen recording.** Recent images from Desktop and Downloads are listed for you — or drag a file in, browse, paste a path, or paste a Figma frame link and I'll export it. A video source (mp4/mov/webm) works the same way; you'll pick which frame to match the edges on.
71
+ > **1 · Choose a photo, then a screenshot — or a screen recording.** The images you have used before are listed for you — or drag a file in, browse, paste a path, or paste a Figma frame link and I'll export it. A video source (mp4/mov/webm) works the same way; you'll pick which frame to match the edges on.
72
72
  >
73
73
  > **2 · Match the four edges to the screen.** Drag an edge's middle to slide it, or near an end to pivot — only that edge moves. The magnified strip below shows the boundary straightened, so aligned reads as flat. Arrow keys nudge 1px, Shift+arrow 10px, Tab moves to the next edge.
74
74
  >
@@ -148,7 +148,8 @@ The server is a detached daemon with no window. **Stop it with `scripts/stop.sh`
148
148
  | `scripts/ui.py` | Local server + browser UI; calls the two below |
149
149
  | `scripts/detect.py` | Advisory screen-quad + corner-radius detection (no ML) |
150
150
  | `scripts/warp.py` | The engine: `compose()` and a CLI for scripted use |
151
- | `scripts/scan.py` | Recent images on Desktop/Downloads, thumbnails |
151
+ | `scripts/recents.py` | The images used before, per role (`~/.screengraft/recents.json`) |
152
+ | `scripts/scan.py` | Desktop/Downloads scan and thumbnails (CLI; the picker no longer uses the scan) |
152
153
  | `ui/index.html` | The page |
153
154
 
154
155
  Design rationale and the roadmap live with the project's own notes, not in the plugin.
package/ui/index.html CHANGED
@@ -362,7 +362,7 @@
362
362
  .stepper button:first-child{border-top-left-radius:var(--r-sm);border-bottom-left-radius:var(--r-sm)}
363
363
  .stepper button:last-child{border-top-right-radius:var(--r-sm);border-bottom-right-radius:var(--r-sm)}
364
364
  /* One border between neighbours rather than two stacked. */
365
- .stepper button + button{margin-left:-1px}
365
+ .stepper button + button{margin-left:calc(-1 * var(--bw))}
366
366
  .stepper button:hover,.stepper button:focus-visible{position:relative;z-index:1}
367
367
  /* The dock's stepper is Sm height but keeps 12px padding (Figma 8:31/8:33). */
368
368
  .dock-step button{padding:0 var(--s3)}
@@ -544,7 +544,7 @@
544
544
 
545
545
  /* ------------------------------------------------------------------
546
546
  Rails over the well (Figma 7:12, Button Group 145:372, Segment Button
547
- 138:255). A rail is a Button Group: a 28px-wide column of 28px segment
547
+ 138:255). A rail is a Button Group: a 32px-wide column of 32px segment
548
548
  buttons on the raised surface, one 0.5px translucent stroke and one
549
549
  shadow around the whole group — the stroke is the only translucent one
550
550
  in the system, because the group sits over a photograph of any tone.
@@ -555,8 +555,12 @@
555
555
  background:var(--raise);border:var(--bw) solid var(--group-edge);
556
556
  border-radius:var(--r-xs);box-shadow:var(--group-shadow);overflow:hidden}
557
557
  .segbtn,.segbtn:hover,.segbtn:active{border:0;border-radius:0;box-shadow:none;transform:none}
558
- .segbtn{height:28px;min-width:28px;padding:0 6px;font-size:12px;color:var(--ink);
558
+ /* Segment Button re-read 14 Sep 2026: 32px tall, 32px minimum, 8px side
559
+ padding; a text label sits in its own 6px-padded box, so a text button
560
+ pads 14px. Was 28 / 6. */
561
+ .segbtn{height:32px;min-width:32px;padding:0 8px;font-size:12px;color:var(--ink);
559
562
  background:var(--raise);display:inline-flex;align-items:center;justify-content:center;gap:4px}
563
+ .segbtn.txt{padding:0 14px}
560
564
  .segbtn:hover{background:var(--raise-mid)}
561
565
  .segbtn:active{background:var(--raise-low)}
562
566
  .segbtn.sel,.segbtn[aria-pressed="true"]{background:var(--raise-hi)}
@@ -565,8 +569,8 @@
565
569
  .segbtn svg{width:16px;height:16px;display:block;flex:none}
566
570
  .segbtn svg text{font:600 8px/1 -apple-system,BlinkMacSystemFont,"SF Pro",system-ui,sans-serif;fill:var(--ink)}
567
571
  .lrail{position:absolute;left:24px;top:48px;z-index:3;display:flex;flex-direction:column;gap:var(--s2)}
568
- .lrail .corners{margin-top:var(--s3)} /* 8 + 12 = the design's 20px */
569
- .brail{position:absolute;left:0;right:0;bottom:12px;z-index:3;display:flex;justify-content:center;
572
+ .lrail .corners{margin-top:var(--s6)} /* 8 + 24 = the design's 32px between rails */
573
+ .brail{position:absolute;left:0;right:0;bottom:8px;z-index:3;display:flex;justify-content:center;
570
574
  align-items:center;gap:4px;pointer-events:none;flex-wrap:wrap;padding:0 var(--s4)}
571
575
  .brail .bgroup{pointer-events:auto}
572
576
  /* The status pill (Status pill 5:7 / 5:10): floats top-centre, its own
@@ -1016,24 +1020,34 @@
1016
1020
  }
1017
1021
  .pop h3{margin:0 0 4px;font-size:14px;font-weight:600}
1018
1022
  .pop .sub{color:var(--mute);font-size:12px;margin:0 0 16px;line-height:1.5}
1019
- .picker{display:grid;grid-template-columns:180px minmax(0,1fr);gap:12px;align-items:start}
1020
- .rail2{display:flex;flex-direction:column;gap:4px;max-height:300px;overflow:auto;padding:2px}
1021
- .th{display:flex;align-items:center;gap:10px;padding:6px;border-radius:var(--r-md);cursor:pointer;
1022
- border:1px solid transparent;background:var(--sunk);
1023
+ /* The preview slot is as tall as the list beside it (stretch, not start),
1024
+ and the list has a floor so an empty or short list still leaves a slot
1025
+ worth previewing into. 14 Sep 2026, on request. */
1026
+ .picker{display:grid;grid-template-columns:200px minmax(0,1fr);gap:12px;align-items:stretch}
1027
+ .rail2{display:flex;flex-direction:column;gap:2px;min-height:240px;max-height:300px;overflow:auto;padding:2px}
1028
+ /* A list item at rest is the picture and its name on the popover's own
1029
+ surface -- no fill, no stroke. Hover raises it; the chosen one is the
1030
+ raised neutral every selected thing on this page uses (not the accent:
1031
+ the accent is for the next action). */
1032
+ .th{display:flex;align-items:center;gap:10px;padding:4px 6px;border-radius:var(--r-sm);cursor:pointer;
1033
+ border:var(--bw) solid transparent;background:transparent;min-width:0;
1023
1034
  transition:var(--t-all)}
1024
- .th:hover{border-color:var(--edge-hi);background:var(--raise)}
1025
- .th img{width:40px;height:40px;object-fit:cover;border-radius:6px;flex:none;background:var(--card)}
1026
- .th .cap{font-size:11.5px;color:var(--mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1027
- .th.sel{border-color:var(--acc);background:var(--acc-soft)}
1035
+ .th:hover{background:var(--raise);border-color:var(--edge)}
1036
+ .th img{width:40px;height:40px;object-fit:cover;border-radius:4px;flex:none;background:var(--sunk)}
1037
+ .th .cap{min-width:0;display:grid;gap:1px}
1038
+ .th .nm{font-size:12px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1039
+ .th .when{font-size:11px;color:var(--mute);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
1040
+ .th.sel{background:var(--raise-hi);border-color:var(--edge-hi)}
1028
1041
  /* The preview frame sits INSIDE the popover, so --well (#0a0a0b, the canvas
1029
1042
  tone) read as a hole punched in it. --sunk is the system's recessed surface:
1030
1043
  lighter than the well, still clearly below --float, and it matches the
1031
1044
  thumbnail rail beside it. The stroke moves to --edge so the frame still has
1032
1045
  an edge now that its fill is closer to the surface behind it. */
1033
- .pv{border-radius:var(--r-md);background:var(--sunk);min-height:210px;
1034
- display:grid;place-items:center;overflow:hidden;box-shadow:inset 0 0 0 1px var(--edge)}
1046
+ /* No stroke (14 Sep 2026): the recessed surface alone marks the slot. */
1047
+ .pv{border-radius:var(--r-md);background:var(--sunk);min-height:0;
1048
+ display:grid;place-items:center;overflow:hidden}
1035
1049
  .pv img{max-width:100%;max-height:300px;object-fit:contain;display:block}
1036
- .drop{border:1px dashed var(--edge);border-radius:var(--r-md);padding:14px;text-align:center;
1050
+ .drop{border:var(--bw) dashed var(--edge);border-radius:var(--r-md);padding:30px 14px;text-align:center;
1037
1051
  color:var(--mute);cursor:pointer;margin-top:12px;font-size:12px;
1038
1052
  transition:var(--t-all)}
1039
1053
  .drop:hover{border-color:var(--edge-hi);color:var(--ink)}
@@ -1122,10 +1136,10 @@
1122
1136
  rail — it is navigation, not an action on the fit. Rotate is not in
1123
1137
  the design frame and is kept until it is ruled on. -->
1124
1138
  <div class="brail" id="cvbar">
1125
- <div class="bgroup"><button class="segbtn" id="redetect">Re-detect</button></div>
1126
- <div class="bgroup"><button class="segbtn" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button></div>
1127
- <div class="bgroup"><button class="segbtn" id="rotatequad" title="Turn the screenshot inside the same four edges. The edges stay where they are; only which one is the top changes. A portrait screenshot fits a phone only two ways, so on a phone this is a half turn; a quarter turn where the screen is near-square or the screenshot fits the other way.">Rotate</button></div>
1128
- <div class="bgroup"><button class="segbtn" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button></div>
1139
+ <div class="bgroup"><button class="segbtn txt" id="redetect">Re-detect</button></div>
1140
+ <div class="bgroup"><button class="segbtn txt" id="pointat" title="Click once inside the screen and the detector will use that point. The detectors usually do find the screen — they just cannot tell which region IS one, and that is the part you can answer instantly.">Point at screen</button></div>
1141
+ <div class="bgroup"><button class="segbtn txt" id="rotatequad" title="Turn the screenshot inside the same four edges. The edges stay where they are; only which one is the top changes. A portrait screenshot fits a phone only two ways, so on a phone this is a half turn; a quarter turn where the screen is near-square or the screenshot fits the other way.">Rotate</button></div>
1142
+ <div class="bgroup"><button class="segbtn txt" id="resetquad" title="Put the four edges back to a rectangle in the middle of the photo. Use this if a corner has ended up off the picture where you cannot grab it.">Reset</button></div>
1129
1143
  </div>
1130
1144
  </section>
1131
1145
 
@@ -1279,7 +1293,7 @@
1279
1293
  <footer class="dock" id="dock">
1280
1294
  <div class="striphead">
1281
1295
  <span class="lbl">Edge view</span>
1282
- <span class="stepper dock-step"><button class="sm" id="stripOut">&minus;</button><button class="sm" id="stripIn">+</button></span>
1296
+ <span class="stepper dock-step"><button id="stripOut" aria-label="Zoom the edge view out">&minus;</button><button id="stripIn" aria-label="Zoom the edge view in">+</button></span>
1283
1297
  <span class="sm" id="stripZ" style="color:var(--mute);font-variant-numeric:tabular-nums"></span>
1284
1298
  <button class="sm" id="stripHC" aria-pressed="false" title="Stretch the strip's own tonal range to full contrast. For a dark screen on a dark frame, where the boundary is a few levels apart. The strip only — the composite is untouched.">Contrast</button>
1285
1299
  <span class="spacer"></span>
@@ -1309,7 +1323,7 @@
1309
1323
 
1310
1324
  <div class="pop" id="pop1">
1311
1325
  <h3>Photo of the device</h3>
1312
- <p class="sub">Recent images from Desktop and Downloads — click one. Or browse, drop a file, or paste a path.</p>
1326
+ <p class="sub">Photos you have used before — click one. Or browse, drop a file, or paste a path.</p>
1313
1327
  <div class="picker">
1314
1328
  <div class="rail2" id="recent1"></div>
1315
1329
  <div class="pv" id="pv1"><span class="empty">Select a recent image</span></div>
@@ -1689,17 +1703,29 @@ addEventListener('drop', e => {
1689
1703
  }, true);
1690
1704
 
1691
1705
  /* ===== pickers (popovers over the canvas) ===== */
1706
+ // "Used 3 h ago", "used yesterday" -- when a source was last chosen.
1707
+ function agoText(t){
1708
+ const s = Math.max(0, Date.now()/1000 - t);
1709
+ if (s < 90) return 'just now';
1710
+ if (s < 3600) return Math.round(s/60) + ' min ago';
1711
+ if (s < 86400) return Math.round(s/3600) + ' h ago';
1712
+ if (s < 172800) return 'yesterday';
1713
+ return Math.round(s/86400) + ' days ago';
1714
+ }
1715
+ const esc = t => String(t).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]));
1692
1716
  async function loadRecent(){
1693
- const {items} = await api('/api/recent?days=14&limit=40');
1694
- for (const id of ['recent1','recent2']){
1717
+ // What was USED before, per role -- the photo list and the screenshot list
1718
+ // are different populations (a device photo is never a screenshot).
1719
+ for (const [id, role] of [['recent1','photo'],['recent2','screenshot']]){
1720
+ const {items} = await api('/api/recent?role=' + role + '&limit=40');
1695
1721
  const g = $('#'+id); g.innerHTML='';
1696
1722
  for (const it of items){
1697
1723
  const d = document.createElement('div'); d.className='th'; d.title = it.path;
1698
- d.innerHTML = `<img src="${it.thumb ? fileURL(it.thumb) : ''}" alt=""><div class="cap">${it.name}</div>`;
1699
- d.onclick = () => choose(id==='recent1'?'photo':'screenshot', it.path, d);
1724
+ d.innerHTML = `<img src="${it.thumb ? fileURL(it.thumb) : ''}" alt=""><div class="cap"><div class="nm">${esc(it.name)}</div><div class="when">${agoText(it.used)} · ${esc(it.folder)}</div></div>`;
1725
+ d.onclick = () => choose(role, it.path, d);
1700
1726
  g.appendChild(d);
1701
1727
  }
1702
- if (!items.length) g.innerHTML = '<span class="sm" style="color:var(--mute)">No recent images on Desktop/Downloads (last 14 days).</span>';
1728
+ if (!items.length) g.innerHTML = '<span class="sm" style="color:var(--mute);padding:6px">Nothing used yet — drop a file below, browse, or paste a path.</span>';
1703
1729
  }
1704
1730
  }
1705
1731
  /* Un-choose a source. The mirror of setChosen(): every visual the fill put up