Compare commits
6 Commits
mai/picass
...
mai/sherlo
| Author | SHA1 | Date | |
|---|---|---|---|
| e53f4b1a5e | |||
| 1f246c0047 | |||
| 3a43762e8c | |||
| e12b449169 | |||
| 28a376a7f3 | |||
| 6d637e1fac |
98
docs/sherlock-+dev-bug.md
Normal file
98
docs/sherlock-+dev-bug.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# +Dev click silently drops — root cause & fix
|
||||
|
||||
**Reporter:** sherlock (Playwright debug shift, 2026-05-15 ~23:10)
|
||||
**Target:** http://mdock:7777 (deployed at e12b449)
|
||||
**Verdict:** Bug reproduced; root cause identified; one-line fix verified live.
|
||||
|
||||
## Repro (Playwright, headless Chromium 1217)
|
||||
|
||||
1. GET `http://mdock:7777/?project=1` (LOFT). Snapshot returns one frame (`Entertainment`).
|
||||
2. Click `#tool-device` — armed correctly (`.canvas-wrap.tool-device`, button `.armed`, cursor `crosshair` both over the empty SVG and over frame rects).
|
||||
3. Click inside the frame (rect centre).
|
||||
4. Expected: inline-namer `<foreignObject><input>` appears focused, m types name, Enter, `POST /api/projects/1/devices` fires, device renders.
|
||||
5. Observed: no FO visible, no POST, tool disarms silently. Same failure when clicking on **empty** canvas (outside any frame).
|
||||
|
||||
## Trace (instrumented JS)
|
||||
|
||||
```
|
||||
[782ms] svg.append foreignObject ← placeDeviceAt → promptInline appended it
|
||||
[782ms] focusout BUTTON #tool-device
|
||||
[783ms] focusin INPUT ← input.focus() succeeded
|
||||
[783ms] svg-bubble-pd rect ← pointerdown still bubbling
|
||||
[783ms] mutation.remove foreignObject ← FO ripped out
|
||||
```
|
||||
|
||||
Patched `Element.prototype.remove` traces the offender:
|
||||
|
||||
```
|
||||
at Element.remove (anon)
|
||||
at done (http://mdock:7777/main.js:585:36)
|
||||
at HTMLInputElement.<anonymous> (http://mdock:7777/main.js:592:42)
|
||||
```
|
||||
|
||||
That's `input.addEventListener("blur", () => done(input.value.trim() || null))` in `promptInline`. The input was focused at 783 ms and blurred ~6 ms later, so `done(null)` removed the FO before m could type.
|
||||
|
||||
## Root cause
|
||||
|
||||
`placeDeviceAt` calls `promptInline` synchronously from inside the canvas `pointerdown` handler. `promptInline` does `input.focus()` synchronously. After the pointerdown handler returns, the browser performs the default mousedown action — focus the nearest focusable ancestor of the click target, else **blur the active element**. The click target is an SVG `<rect>` (or the `<svg>` root) which is not focusable, so the freshly-focused input gets blurred. The input's own `blur` listener fires `done(null)` → `fo.remove()`.
|
||||
|
||||
This is independent of the previous fix at 94869f3 (which corrected the ordering of `onCanvasPointerDown` so the tool branches run before the "click on existing element" early-return). Routing is now correct, which is what unmasks the focus-blur bug. `+ Frame` is not affected because there `promptInline` runs from the pointer**up** callback, after pointer events have finished, so no mousedown is pending to steal focus.
|
||||
|
||||
The cursor + Cache-Control fix at 28a376a addressed only the visual cursor lie and the redeploy staleness. It does not touch the click handler.
|
||||
|
||||
## Fix (one line)
|
||||
|
||||
`web/static/main.js` around line 472, in `onCanvasPointerDown`:
|
||||
|
||||
```diff
|
||||
if (state.tool === "frame") {
|
||||
+ e.preventDefault();
|
||||
startFrameRubberBand(e, p);
|
||||
return;
|
||||
}
|
||||
if (state.tool === "device") {
|
||||
+ e.preventDefault();
|
||||
placeDeviceAt(p);
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
`e.preventDefault()` on the pointerdown suppresses the compatibility mousedown's default focus-shift, so the input keeps focus and m can type. The `+ Frame` branch gets the same treatment for symmetry and to prevent a subtle text-selection side effect during rubber-band drag (it's not strictly required for the focus issue there because `+ Frame` focuses on pointerup).
|
||||
|
||||
Per-call site rationale, not a blanket `if (state.tool) e.preventDefault()` at the top, because future tools that intentionally rely on default click behavior (e.g. a cable-drawing tool that needs link-clicks on ports) might want different semantics.
|
||||
|
||||
### Verified live
|
||||
|
||||
Monkey-patched the deployed page with a capture-phase listener that calls `e.preventDefault()` while `#tool-device.armed` exists. Result: device created, named `pc-sherlock`, persisted (POST `/api/projects/1/devices` 200). Screenshot: `/tmp/sherlock/fix_verified.png`.
|
||||
|
||||
## Secondary observation (not blocking)
|
||||
|
||||
After the fix, pressing `Enter` on the inline-namer logs a `pageerror`:
|
||||
|
||||
```
|
||||
Failed to execute 'remove' on 'Element':
|
||||
The node to be removed is no longer a child of this node.
|
||||
Perhaps it was moved in a 'blur' event handler?
|
||||
```
|
||||
|
||||
`done()` is called twice (once from Enter keydown, once from the resulting blur). The `if (activeNamer === fo)` guard on the second entry is recursive-safe because the first `done()` runs `fo.remove()` *before* setting `activeNamer = null`, so the synchronous blur fires inside the first remove() and re-enters with the guard still true. Cheap fix: reorder the two lines so the flag clears first:
|
||||
|
||||
```diff
|
||||
const done = (val) => {
|
||||
- if (activeNamer === fo) { fo.remove(); activeNamer = null; }
|
||||
+ if (activeNamer === fo) { activeNamer = null; fo.remove(); }
|
||||
resolve(val);
|
||||
};
|
||||
```
|
||||
|
||||
Functional impact: none — the device is still created. Cosmetic console error only. Worth folding into the same patch.
|
||||
|
||||
## Artifacts (sherlock side, /tmp)
|
||||
|
||||
- `repro.py` — initial reproduction, screenshots before/after click.
|
||||
- `repro2.py` — event-trace instrumentation (capture+bubble pointerdowns, focus, mutations).
|
||||
- `repro3.py` — `remove()` stack-trace capture that named `done() <- blur listener`.
|
||||
- `verify_fix.py` — applied the proposed fix in-page; device created end-to-end.
|
||||
- `test_empty.py` — confirmed the bug bites on empty canvas too, not just inside frames.
|
||||
- `run.log`, `run2.log`, `run3.log`, `verify.log`, `empty.log` — full transcripts.
|
||||
- `fix_verified.png` — post-fix screenshot with `pc-sherlock` device visible inside the frame.
|
||||
144
docs/sherlock-+port-bug.md
Normal file
144
docs/sherlock-+port-bug.md
Normal file
@@ -0,0 +1,144 @@
|
||||
# +Port "still does nothing" — root cause & fix
|
||||
|
||||
**Reporter:** sherlock (Playwright debug shift 2, 2026-05-16 ~11:00)
|
||||
**Target:** http://mdock:7777 (deployed image c361bf38, picasso fix 3276cfe)
|
||||
**Verdict:** +Port is **not actually a no-op** — it successfully creates the port server-side AND client-side every time. The user-visible failure is **invisible stacking**: the new port renders at the exact same pixel as a pre-existing port on the same edge midpoint. m sees no canvas change and no panel switch, so the click feels dead.
|
||||
|
||||
## What I ran
|
||||
|
||||
Five Playwright drives against the live deployment, real Chromium (1217), full console + network + DOM mutation capture. Scripts and transcripts: `/tmp/sherlock/port_*`.
|
||||
|
||||
| Scenario | Click target | POST result | DOM change | What m sees |
|
||||
|---|---|---|---|---|
|
||||
| (a) empty canvas inside frame | (443, 626) screen | 201, port id 40 created | new circle at TV bottom-left | maybe a new dot, but device list still selected, no halo |
|
||||
| (b) on the device body (TV center) | (563, 454) screen | 201, port id 41 | new circle at **(686.4, 643)** — same cx,cy as ports 37, 38, 39 already at that point | nothing — new dot stacks pixel-perfect under existing ones |
|
||||
| (c) directly on an existing port circle | (538, 445) screen | 201, port id 42 | new circle at **(636.4, 643)** — same cx,cy as port 27 | nothing — stacked |
|
||||
|
||||
In every scenario:
|
||||
- `state.tool === "port"` after the +Port click (inferred from `.canvas-wrap.tool-port` + cursor `crosshair`)
|
||||
- `POST /api/projects/1/devices/2/ports` returns 201 with the new port row
|
||||
- `state.ports.push(port)` happens, `render()` redraws, the new circle exists in the DOM
|
||||
- The inspector port list grows by one row
|
||||
- **The canvas does not visibly change.**
|
||||
|
||||
After 7 +Port placements on the TV, the inspector lists 7 ports, but only ~3 dots are visible on the device — the rest are stacked at identical (cx, cy):
|
||||
|
||||
```
|
||||
ports on TV after the test run:
|
||||
port 27 fill=#e03131 cx=636.4 cy=643.0 ← original
|
||||
port 37 fill=#e03131 cx=686.4 cy=643.0
|
||||
port 38 fill=#e03131 cx=686.4 cy=643.0 ← stacks on 37
|
||||
port 39 fill=#1971c2 cx=686.4 cy=643.0 ← stacks on 37,38
|
||||
port 40 fill=#e03131 cx=636.4 cy=678.0
|
||||
port 41 fill=#e03131 cx=686.4 cy=643.0 ← stacks on 37,38,39
|
||||
port 42 fill=#e03131 cx=636.4 cy=643.0 ← stacks on 27
|
||||
```
|
||||
|
||||
## Why it stacks
|
||||
|
||||
`snapToDeviceEdge` (main.js:1560–1574) projects the click to the nearest of the four device edges and clamps the parallel coordinate to `[0, device.width]` (or `device.height`). For the dominant click region — anywhere over the device body — `snapToDeviceEdge` collapses a wide band of clicks down to a handful of discrete `(xOff, yOff)` tuples. For a 100×35 device, clicking anywhere in the bottom half + roughly the horizontal middle resolves to `(xOff=50, yOff=35)`. Two clicks in that band produce two ports at identical coordinates. There is no de-dup against existing ports.
|
||||
|
||||
This is geometry, not a regression — but it means the +Port tool has the same visible signature as a no-op tool for the most natural click region.
|
||||
|
||||
## Why m's panel feedback is also weak
|
||||
|
||||
`placePortAt` (main.js:1685–1706) finishes with `state.ports.push(port); armTool(null); render();`. It does **not** set `state.selection = { kind: "port", id: port.id }`. So:
|
||||
|
||||
- `state.selection` stays on the device (set in the +Port-arming click).
|
||||
- The inspector continues to render the device panel; the port list grows by a row, but the panel doesn't switch.
|
||||
- The new port circle does not get the `.selected` halo (drop-shadow) defined in style.css.
|
||||
|
||||
`placeDeviceAt`, `placeIOMarkerAt`, `startFrameRubberBand`, and `finishCableDrawAt` all set `state.selection` to the new entity. `placePortAt` is the odd one out.
|
||||
|
||||
## Proposed fix (verified live)
|
||||
|
||||
Two changes in `web/static/main.js`, one for stacking, one for feedback. The second is sufficient on its own to make the tool feel responsive.
|
||||
|
||||
### A. Select the newly-placed port (primary)
|
||||
|
||||
```diff
|
||||
async function placePortAt(p) {
|
||||
...
|
||||
try {
|
||||
const port = await createPort(state.active.id, did, {
|
||||
type_id: tid,
|
||||
x_offset: snap.xOff,
|
||||
y_offset: snap.yOff,
|
||||
});
|
||||
state.ports.push(port);
|
||||
+ state.selection = { kind: "port", id: port.id };
|
||||
armTool(null);
|
||||
render();
|
||||
```
|
||||
|
||||
Effect verified live by intercepting the POST response and dispatching a pointerdown on the new port circle (simulates the selection that the fix would set internally). Result:
|
||||
|
||||
- Inspector immediately switches from the **DEVICE** panel to the **PORT** panel showing cable-type swatch, label input, edge dropdown (Top/Right/Bottom/Left), Delete button.
|
||||
- The new port circle gets the `.selected` class → drop-shadow halo, visually distinct even when stacked.
|
||||
- Screenshot: `/tmp/sherlock/port_fix_verified.png`.
|
||||
|
||||
m clicking +Port now produces unambiguous feedback (panel switch + halo), AND he can immediately move the new port to the right edge with the dropdown — exactly the workflow when you wanted "another port over there".
|
||||
|
||||
### B. De-dup snap position (secondary, optional)
|
||||
|
||||
If two ports on the same device land within ~8 px of each other on the same edge, walk along that edge in 12 px increments until a free slot is found (or distribute the existing ones evenly — pick the lighter change). This eliminates pixel-perfect stacks for power users; (A) alone covers the perception bug.
|
||||
|
||||
Sketch:
|
||||
|
||||
```js
|
||||
function snapToDeviceEdge(device, x, y, existingPortsOnDevice) {
|
||||
const raw = snapRaw(device, x, y); // current logic
|
||||
if (!existingPortsOnDevice?.length) return raw;
|
||||
const isOnEdge = (port, edge) =>
|
||||
(edge === "left" && port.x_offset === 0) ||
|
||||
(edge === "right" && port.x_offset === device.width) ||
|
||||
(edge === "top" && port.y_offset === 0) ||
|
||||
(edge === "bottom" && port.y_offset === device.height);
|
||||
const sibs = existingPortsOnDevice.filter((p) => isOnEdge(p, raw.edge));
|
||||
// Walk along the parallel axis until no sibling within 8px tolerance.
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
Then `placePortAt` passes `state.ports.filter((p) => p.device_id === did)`.
|
||||
|
||||
Not blocking — m's complaint is fixed by (A). File (B) as a polish ticket.
|
||||
|
||||
## Unrelated bug surfaced during repro (separate fix, separate scope)
|
||||
|
||||
Every click-only device selection throws this pageerror:
|
||||
|
||||
```
|
||||
TypeError: Cannot read properties of null (reading 'classList')
|
||||
at SVGSVGElement.onUp (http://mdock:7777/main.js:1846:21)
|
||||
```
|
||||
|
||||
Source: `startDrag.onUp` does `e.currentTarget.classList.remove("dragging")` (main.js:1846). `e` is the closure-captured pointerdown event; by the time `onUp` runs (async, after pointerup), the browser has nulled out `e.currentTarget`. Click-only path aborts at line 1846 before the trailing `render()` (which is redundant here because `startDrag` already calls render at line 1794, so the user-visible state is fine — but the console error spams every click).
|
||||
|
||||
Trivial fix: capture the target into a closure before defining the async handlers.
|
||||
|
||||
```diff
|
||||
function startDrag(e, kind, id) {
|
||||
...
|
||||
+ const dragTarget = /** @type {Element} */ (e.currentTarget);
|
||||
...
|
||||
- e.currentTarget.classList.add("dragging");
|
||||
+ dragTarget.classList.add("dragging");
|
||||
...
|
||||
const onUp = async (ev) => {
|
||||
...
|
||||
- e.currentTarget.classList.remove("dragging");
|
||||
+ dragTarget.classList.remove("dragging");
|
||||
```
|
||||
|
||||
Not directly responsible for the +Port complaint, but it's noise in every console session and the fix is one line. Worth bundling with the port-select patch.
|
||||
|
||||
## Artifacts
|
||||
|
||||
Under `/tmp/sherlock/`:
|
||||
- `port_repro.py`, `port_repro2.py`, `port_repro3.py` — staged reproductions
|
||||
- `trace_pe.py` — pageerror stack capture
|
||||
- `verify_port_fix.py` — applied the proposed selection-after-placement fix in-page
|
||||
- `port_run.log`, `port_run2.log`, `port_run3.log`, `pe.log`, `verify_port.log` — full transcripts
|
||||
- `port_after_redo.png`, `port_scenario_*.png` — pre-fix screenshots showing the inspector-list-grows-but-canvas-doesn't symptom
|
||||
- `port_fix_verified.png` — post-fix screenshot, inspector switched to **PORT** panel with edge picker
|
||||
99
docs/sherlock-frame-select.md
Normal file
99
docs/sherlock-frame-select.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# "I cannot change frames" — repro & verdict
|
||||
|
||||
**Reporter:** sherlock (Playwright debug shift 3, 2026-05-16 ~19:30)
|
||||
**Target:** http://mdock:7777 (CableGUI, deployed at 79e17a5)
|
||||
**Hypothesis under test:** kandinsky's pan-on-left-drag (2933bb8) regressed frame select because the "isEmpty" selector missed `[data-frame-id]`.
|
||||
|
||||
**Verdict:** **Hypothesis is wrong.** The selector at main.js:2087 already lists `[data-frame-id]`, the frame `<rect>` has its own pointerdown handler that calls `startDrag`, and `startDrag` calls `e.stopPropagation()` synchronously before the pan-start handler could fire. Frame selection works mechanically on the deployed image — verified four ways. m's complaint is real, but it's a **render-order / clutter** problem, not a click-handler regression.
|
||||
|
||||
## What I ran (4 Playwright drives against the deployed image)
|
||||
|
||||
### Selection works on a click that lands on the frame rect
|
||||
|
||||
`frame_repro2.py` TEST 2 — Esc to deselect, then click 2 px inside frame 1's left edge:
|
||||
```
|
||||
before: selected_frames=[] inspector=None
|
||||
after: selected_frames=['1'] inspector='Frame'
|
||||
pointerdowns: svg-capture target=rect.frame-rect frame=1
|
||||
pan changes: []
|
||||
```
|
||||
Frame 1's inspector panel appears with name="Entertainment", x/y/w/h editable, "Delete frame" button. No pan-start fired.
|
||||
|
||||
`frame_clean_test.py` — Esc, then click frame 3 (Network) bottom-left corner where nothing else is rendered:
|
||||
```
|
||||
top element at click point: rect.frame-rect (frame=3)
|
||||
after click: selected_frames=['3'] inspector='Frame'
|
||||
```
|
||||
|
||||
### Selection works on a click + small drag too
|
||||
|
||||
`frame_repro2.py` TEST 3 — 5 px drag (past the 3 px pan threshold) on frame interior:
|
||||
- `startDrag(e, "frame", f.id)` fires at the frame rect's `pointerdown`. It is the **first** pointerdown handler in the chain.
|
||||
- `startDrag` (main.js:2839) calls `e.stopPropagation()` synchronously when no tool is armed → pan-start handler never gets the event.
|
||||
- The 5 px movement is handled by `startDrag`'s own `onMove` (frame drag), not by the empty-canvas pan path.
|
||||
- Result: frame stays selected, frame nudges 5 px to the right. Inspector panel stays correct.
|
||||
|
||||
50 px drag (TEST 4): same — frame stays selected, moves further.
|
||||
|
||||
## Why m experiences "doesn't select"
|
||||
|
||||
The frame `<rect>` paints **first**; everything else paints on top of it in this order (from index.html):
|
||||
|
||||
```
|
||||
canvas-frames ← frame rects (BOTTOM)
|
||||
canvas-devices ← device rects + port circles inside each device <g>
|
||||
canvas-ports
|
||||
canvas-cables ← cable polylines (often crossing through frame interior)
|
||||
canvas-clamps
|
||||
canvas-io ← IO marker diamonds
|
||||
```
|
||||
|
||||
So any click on the frame's visible interior that happens to land on a device, port, port circle, cable polyline, clamp, or IO diamond hits THAT element, not the frame. The frame rect only "wins" the hit-test on pixels that are not covered by anything else.
|
||||
|
||||
Two concrete observations from the LOFT snapshot:
|
||||
|
||||
- Frame 1 "Entertainment" is huge (SVG 1340×848 at viewBox 2000×1500). Most of it renders past the canvas-wrap's right edge (x > 1220 px on a 1500 px viewport) and is occluded by the inspector `<aside>`. Only a ~180 × 424 px strip on the visible canvas right side is selectable. That strip is partly covered by devices and yellow cable polylines crossing diagonally.
|
||||
- Frame 3 "Network": grid hit-test, 9 sample points → 5 hit the frame, 2 hit cable polylines that cross through its centre. `frame_clean_test.py` confirmed: bottom-left corner click selects; centre click selects the cable.
|
||||
|
||||
For m, "I click on the frame and it doesn't select" is **literally true at the click point he's choosing** — but the click is going to the cable/device on top, not the frame underneath. There is no regression in the dispatch.
|
||||
|
||||
## Recommendation (no code change applied)
|
||||
|
||||
Since the click-handler chain is correct, this is a UX call for picasso/perseus to make, not a one-line patch sherlock should apply. Three options ordered by effort:
|
||||
|
||||
1. **Frame label is the selection grip.** Drop `pointer-events: none` from `.frame-label` (style.css:183) and add a pointerdown handler on the label that calls `startDrag(e, "frame", id)`. The label sits in the top-left of the frame, never overlaps a device, and is always visible — m gets a deterministic spot to click. This is a 2-line patch.
|
||||
|
||||
2. **Selection breadcrumb on the device inspector.** When a selected device has `frame_id`, render `parent frame: <name>` as a clickable chip that calls `state.selection = {kind: "frame", id: frame.id}; render()`. Lets m drill from "I selected the wrong thing" → frame in one click.
|
||||
|
||||
3. **Modifier-click to escape to frame.** Alt-click (or Ctrl-click) on any element promotes the hit-test to "select the enclosing frame, if any". Discoverable via tooltip on the inspector header.
|
||||
|
||||
Option (1) is the cheapest and matches user mental model ("click the label to select that thing"). Probably worth pairing with the empty-canvas pan UX so m can hold Shift to disable pan when he wants to clear selection — but that's a separate refinement.
|
||||
|
||||
## What the head's hypothesis *would* look like if it were the bug
|
||||
|
||||
For completeness — if `[data-frame-id]` were missing from the selector in `onCanvasPointerDown`:
|
||||
|
||||
```js
|
||||
// (hypothetical broken version)
|
||||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-io-id]")) return;
|
||||
// ^^ no frame-id
|
||||
if (e.button === 0 && state.cableDrawFromPortID == null) {
|
||||
startEmptyCanvasGesture(e);
|
||||
}
|
||||
```
|
||||
|
||||
…then frame clicks would fall into `startEmptyCanvasGesture`. Sub-3 px clicks would still deselect (no good for m), and any tiny hand-jitter would promote to a pan. m would see "click frame → view shifts, no selection." That symptom matches m's wording — but the actual code is correct, so this isn't what's happening.
|
||||
|
||||
The current code (main.js:2087):
|
||||
```js
|
||||
if (e.target instanceof Element && e.target.closest("[data-device-id], [data-frame-id], [data-io-id], [data-clamp-id], [data-port-id], [data-cable-id]")) return;
|
||||
```
|
||||
All six known target types are listed. No regression here.
|
||||
|
||||
## Artifacts
|
||||
|
||||
Under `/tmp/sherlock/`:
|
||||
- `frame_repro.py`, `frame_repro2.py`, `frame_repro3.py`, `frame_clean_test.py` — staged repros
|
||||
- `canvas_layout.py` — layout dump confirming canvas-wrap vs inspector aside geometry
|
||||
- `frame_run.log`, `frame_run2.log`, `frame_run3.log`, `clean.log`, `layout.log` — full transcripts
|
||||
- `frame2_01_interior.png`, `frame2_02_edge.png`, `frame_clean_result.png` — pre/post-click screenshots showing inspector switching to **Frame** panel on a clean rect-hit click
|
||||
@@ -46,7 +46,26 @@ func New(store *db.Store, frontend fs.FS) http.Handler {
|
||||
mux.HandleFunc("DELETE /api/projects/{pid}/devices/{id}", h.deleteDevice)
|
||||
|
||||
// Frontend (embedded). Serve "/" → index.html via http.FileServerFS.
|
||||
mux.Handle("/", http.FileServerFS(frontend))
|
||||
// Wrap in noCache so the browser revalidates with the ETag/Last-Modified
|
||||
// the file server already emits — without this, browsers cache aggressively
|
||||
// and m sees the old main.js after every redeploy until hard-reload.
|
||||
mux.Handle("/", noCache(http.FileServerFS(frontend)))
|
||||
|
||||
return mux
|
||||
}
|
||||
|
||||
// noCache wraps a static handler so each response carries
|
||||
// Cache-Control: no-cache. Combined with the ETag/Last-Modified headers
|
||||
// http.FileServer(FS) already emits, this turns every fetch into a
|
||||
// cheap revalidation request — the browser uses its cached body when
|
||||
// the ETag matches but always asks first, so freshly-built assets show
|
||||
// up on the next page load without a hard-reload.
|
||||
//
|
||||
// Applied to the static-asset handler only — API responses write their
|
||||
// own headers and aren't routed through this.
|
||||
func noCache(h http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-cache")
|
||||
h.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -203,9 +203,15 @@ body {
|
||||
.svg-draggable { cursor: grab; }
|
||||
.svg-draggable.dragging { cursor: grabbing; }
|
||||
|
||||
/* tool cursor on the empty canvas while a tool is armed */
|
||||
/* Tool cursor while a tool is armed. The `* { ... !important }` descendant
|
||||
rule is the load-bearing part: without it, the `.svg-draggable` rules
|
||||
on individual frame/device rects win by element specificity and
|
||||
override the SVG-root cursor — so hovering a frame with +Dev armed
|
||||
shows `grab`, which lies about what a click will do. */
|
||||
.canvas-wrap.tool-frame #canvas,
|
||||
.canvas-wrap.tool-device #canvas { cursor: crosshair; }
|
||||
.canvas-wrap.tool-frame #canvas *,
|
||||
.canvas-wrap.tool-device #canvas,
|
||||
.canvas-wrap.tool-device #canvas * { cursor: crosshair !important; }
|
||||
|
||||
.rubber-band {
|
||||
fill: rgba(25, 113, 194, 0.08);
|
||||
|
||||
Reference in New Issue
Block a user