);
}
@@ -270,58 +385,86 @@ function TextPrompt(props: { x: number; y: number; onCommit: (s: string) => void
);
}
-async function composite(screenDataUrl: string, annotationCanvas: HTMLCanvasElement): Promise {
- const img = await loadImage(screenDataUrl);
+// Composite the full-screen capture with the overlay's annotations,
+// returning a PNG buffer. Uses createImageBitmap + canvas.toBlob —
+// both run off-thread where the browser supports it, and avoid the
+// expensive HTMLImageElement.src = dataURL round-trip that the
+// previous string-based path used.
+async function composite(
+ screenPng: Uint8Array,
+ annotationCanvas: HTMLCanvasElement,
+): Promise {
+ const bitmap = await pngToBitmap(screenPng);
+ if (!bitmap) return new Uint8Array();
const off = document.createElement('canvas');
- off.width = img.naturalWidth;
- off.height = img.naturalHeight;
+ off.width = bitmap.width;
+ off.height = bitmap.height;
const ctx = off.getContext('2d');
- if (!ctx) return '';
- ctx.drawImage(img, 0, 0);
+ if (!ctx) return new Uint8Array();
+ ctx.drawImage(bitmap, 0, 0);
ctx.drawImage(annotationCanvas, 0, 0, off.width, off.height);
- const dataUrl = off.toDataURL('image/png');
- return dataUrl.replace(/^data:image\/png;base64,/, '');
+ bitmap.close();
+ return canvasToPng(off);
}
async function compositeAndCrop(
- screenDataUrl: string,
+ screenPng: Uint8Array,
annotationCanvas: HTMLCanvasElement,
rect: { x: number; y: number; w: number; h: number },
scaleFactor: number,
-): Promise {
- const img = await loadImage(screenDataUrl);
+): Promise {
+ const bitmap = await pngToBitmap(screenPng);
+ if (!bitmap) return new Uint8Array();
- // First composite full display: screen + annotations scaled to screen pixels.
+ // Composite the full display first so the annotation canvas (which
+ // is sized to the overlay window, not to the screen capture) draws
+ // at the same scale as the underlying pixels.
const full = document.createElement('canvas');
- full.width = img.naturalWidth;
- full.height = img.naturalHeight;
+ full.width = bitmap.width;
+ full.height = bitmap.height;
const fctx = full.getContext('2d');
- if (!fctx) return '';
- fctx.drawImage(img, 0, 0);
+ if (!fctx) {
+ bitmap.close();
+ return new Uint8Array();
+ }
+ fctx.drawImage(bitmap, 0, 0);
fctx.drawImage(annotationCanvas, 0, 0, full.width, full.height);
+ bitmap.close();
// Then crop to the user's CSS-px rect, scaled to display pixels.
const sx = Math.max(0, Math.round(rect.x * scaleFactor));
const sy = Math.max(0, Math.round(rect.y * scaleFactor));
const sw = Math.min(Math.round(rect.w * scaleFactor), full.width - sx);
const sh = Math.min(Math.round(rect.h * scaleFactor), full.height - sy);
- if (sw <= 0 || sh <= 0) return '';
+ if (sw <= 0 || sh <= 0) return new Uint8Array();
const off = document.createElement('canvas');
off.width = sw;
off.height = sh;
const ctx = off.getContext('2d');
- if (!ctx) return '';
+ if (!ctx) return new Uint8Array();
ctx.drawImage(full, sx, sy, sw, sh, 0, 0, sw, sh);
- const dataUrl = off.toDataURL('image/png');
- return dataUrl.replace(/^data:image\/png;base64,/, '');
+ return canvasToPng(off);
}
-function loadImage(src: string): Promise {
- return new Promise((resolve, reject) => {
- const img = new Image();
- img.onload = () => resolve(img);
- img.onerror = reject;
- img.src = src;
- });
+async function pngToBitmap(png: Uint8Array): Promise {
+ try {
+ // Cast through BlobPart — Uint8Array satisfies the structural
+ // requirement at runtime, but TS's stricter ArrayBufferLike vs
+ // ArrayBuffer split (post-5.7) complains without help.
+ const blob = new Blob([png as BlobPart], { type: 'image/png' });
+ return await createImageBitmap(blob);
+ } catch (err) {
+ console.warn('[pen] pngToBitmap failed', err);
+ return null;
+ }
+}
+
+async function canvasToPng(canvas: HTMLCanvasElement): Promise {
+ const blob = await new Promise((resolve) =>
+ canvas.toBlob(resolve, 'image/png'),
+ );
+ if (!blob) return new Uint8Array();
+ const buf = await blob.arrayBuffer();
+ return new Uint8Array(buf);
}
diff --git a/src/renderer/overlay/canvas/CommittedLayer.ts b/src/renderer/overlay/canvas/CommittedLayer.ts
index 24112bb..e1d0a56 100644
--- a/src/renderer/overlay/canvas/CommittedLayer.ts
+++ b/src/renderer/overlay/canvas/CommittedLayer.ts
@@ -13,13 +13,13 @@ export class CommittedLayer {
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) throw new Error('CommittedLayer: 2D context unavailable');
this.ctx = ctx;
- this.dpr = window.devicePixelRatio || 1;
+ this.dpr = Math.max(window.devicePixelRatio || 1, 2);
this.resize();
}
resize(): void {
const { innerWidth: w, innerHeight: h } = window;
- this.dpr = window.devicePixelRatio || 1;
+ this.dpr = Math.max(window.devicePixelRatio || 1, 2);
this.canvas.width = Math.floor(w * this.dpr);
this.canvas.height = Math.floor(h * this.dpr);
this.canvas.style.width = `${w}px`;
diff --git a/src/renderer/overlay/canvas/LiveLayer.ts b/src/renderer/overlay/canvas/LiveLayer.ts
index 029f379..a692ae1 100644
--- a/src/renderer/overlay/canvas/LiveLayer.ts
+++ b/src/renderer/overlay/canvas/LiveLayer.ts
@@ -13,13 +13,21 @@ export class LiveLayer {
const ctx = canvas.getContext('2d', { alpha: true });
if (!ctx) throw new Error('LiveLayer: 2D context unavailable');
this.ctx = ctx;
- this.dpr = window.devicePixelRatio || 1;
+ // Floor at 2× so strokes stay crisp on standard-DPI external monitors
+ // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling
+ // a true Retina or higher display. ~4× canvas memory vs DPR=1, which
+ // is fine for one full-screen overlay.
+ this.dpr = Math.max(window.devicePixelRatio || 1, 2);
this.resize();
}
resize(): void {
const { innerWidth: w, innerHeight: h } = window;
- this.dpr = window.devicePixelRatio || 1;
+ // Floor at 2× so strokes stay crisp on standard-DPI external monitors
+ // (classroom IFPs are commonly 96 DPI / DPR 1) without ever downscaling
+ // a true Retina or higher display. ~4× canvas memory vs DPR=1, which
+ // is fine for one full-screen overlay.
+ this.dpr = Math.max(window.devicePixelRatio || 1, 2);
this.canvas.width = Math.floor(w * this.dpr);
this.canvas.height = Math.floor(h * this.dpr);
this.canvas.style.width = `${w}px`;
diff --git a/src/renderer/overlay/canvas/drawItem.ts b/src/renderer/overlay/canvas/drawItem.ts
index 30ca52d..a589ed4 100644
--- a/src/renderer/overlay/canvas/drawItem.ts
+++ b/src/renderer/overlay/canvas/drawItem.ts
@@ -130,14 +130,20 @@ function drawStroke(
end: { taper: 0, cap: true },
};
} else if (isPencil) {
+ // Tuned for fine-handwriting at sub-pixel widths: lower thinning so
+ // a 0.5–1px pencil doesn't get pinched into invisibility by
+ // perfect-freehand's outline algorithm, and lower streamline so the
+ // line actually follows the writer's wrist instead of being eaten
+ // by post-hoc smoothing. Tapers shrink proportionally so very fine
+ // strokes still end cleanly.
opts = {
- thinning: 0.08,
- smoothing: 0.32,
- streamline: 0.26,
+ thinning: 0.04,
+ smoothing: 0.28,
+ streamline: 0.18,
easing: (t: number) => t,
simulatePressure: false,
- start: { taper: Math.min(effectiveWidth * 0.6, 6), cap: true },
- end: { taper: Math.min(effectiveWidth * 0.9, 10), cap: true },
+ start: { taper: Math.min(effectiveWidth * 0.5, 4), cap: true },
+ end: { taper: Math.min(effectiveWidth * 0.7, 7), cap: true },
};
} else {
// pen
@@ -256,14 +262,32 @@ function drawRegion(
const w = Math.abs(item.p2.x - item.p1.x);
const h = Math.abs(item.p2.y - item.p1.y);
ctx.save();
- ctx.globalAlpha = item.opacity * 0.18;
- ctx.fillStyle = item.color;
- ctx.fillRect(x, y, w, h);
- ctx.globalAlpha = item.opacity;
- ctx.strokeStyle = item.color;
- ctx.setLineDash([4, 4]);
- ctx.lineWidth = 1;
- ctx.strokeRect(x, y, w, h);
+ if (item.marchingAnts) {
+ // Snip preview: two-pass marching ants — black underneath, white on
+ // top with a dash-offset so the alternation is visible on light
+ // AND dark surfaces. Half-pixel offset for crisp 1px lines. A faint
+ // dim wash inside indicates the captured region.
+ ctx.globalAlpha = 0.18;
+ ctx.fillStyle = '#000000';
+ ctx.fillRect(x, y, w, h);
+ ctx.globalAlpha = 1;
+ ctx.lineWidth = 1;
+ ctx.setLineDash([6, 4]);
+ ctx.strokeStyle = 'rgba(0, 0, 0, 0.85)';
+ ctx.strokeRect(x + 0.5, y + 0.5, w, h);
+ ctx.strokeStyle = 'rgba(255, 255, 255, 0.95)';
+ ctx.lineDashOffset = 5;
+ ctx.strokeRect(x + 0.5, y + 0.5, w, h);
+ } else {
+ ctx.globalAlpha = item.opacity * 0.18;
+ ctx.fillStyle = item.color;
+ ctx.fillRect(x, y, w, h);
+ ctx.globalAlpha = item.opacity;
+ ctx.strokeStyle = item.color;
+ ctx.setLineDash([4, 4]);
+ ctx.lineWidth = 1;
+ ctx.strokeRect(x, y, w, h);
+ }
ctx.restore();
}
@@ -292,21 +316,59 @@ function drawEllipse(
function drawArrow(ctx: CanvasRenderingContext2D, item: Extract): void {
const dx = item.p2.x - item.p1.x;
const dy = item.p2.y - item.p1.y;
+ const length = Math.hypot(dx, dy);
+ if (length < 1) return;
const angle = Math.atan2(dy, dx);
- const head = Math.max(8, item.width * 3);
+
+ // Head geometry: parametric in both length and width, capped at 45%
+ // of total length so very short arrows don't become all head, and
+ // floored so very thin arrows still read as arrows. The 0.72 aspect
+ // ratio (width as fraction of length) gives a slender, "designed"
+ // silhouette rather than the chunky 90° triangle a fixed-angle head
+ // produces. Notch at 0.22 of head length pulls the back inward so
+ // the head reads as a swept chevron, not a flat-based pyramid.
+ const widthBoost = 1 + item.width / 30;
+ const headLen = Math.max(12, Math.min(length * 0.22 * widthBoost, length * 0.45));
+ const headHalfW = headLen * 0.36;
+ const notchDepth = headLen * 0.22;
+
+ const cosA = Math.cos(angle);
+ const sinA = Math.sin(angle);
+ const perpX = -sinA;
+ const perpY = cosA;
+
+ const tipX = item.p2.x;
+ const tipY = item.p2.y;
+ const backX = item.p2.x - headLen * cosA;
+ const backY = item.p2.y - headLen * sinA;
+ const notchX = backX + notchDepth * cosA;
+ const notchY = backY + notchDepth * sinA;
+ const wingLX = backX + headHalfW * perpX;
+ const wingLY = backY + headHalfW * perpY;
+ const wingRX = backX - headHalfW * perpX;
+ const wingRY = backY - headHalfW * perpY;
+
ctx.save();
ctx.strokeStyle = item.color;
ctx.fillStyle = item.color;
ctx.lineWidth = item.width;
ctx.lineCap = 'round';
+ ctx.lineJoin = 'round';
+
+ // Shaft stops at the notch — if we drew through to p2 the head fill
+ // would overlap the shaft's round cap and give small arrows a visible
+ // blob at the join. Ending at the notch makes the silhouette one
+ // continuous shape.
ctx.beginPath();
ctx.moveTo(item.p1.x, item.p1.y);
- ctx.lineTo(item.p2.x, item.p2.y);
+ ctx.lineTo(notchX, notchY);
ctx.stroke();
+
ctx.beginPath();
- ctx.moveTo(item.p2.x, item.p2.y);
- ctx.lineTo(item.p2.x - head * Math.cos(angle - Math.PI / 7), item.p2.y - head * Math.sin(angle - Math.PI / 7));
- ctx.lineTo(item.p2.x - head * Math.cos(angle + Math.PI / 7), item.p2.y - head * Math.sin(angle + Math.PI / 7));
+ ctx.moveTo(tipX, tipY);
+ ctx.lineTo(wingLX, wingLY);
+ ctx.lineTo(notchX, notchY);
+ ctx.lineTo(wingRX, wingRY);
ctx.closePath();
ctx.fill();
ctx.restore();
diff --git a/src/renderer/overlay/index.html b/src/renderer/overlay/index.html
index 8a3c9ef..bc56f06 100644
--- a/src/renderer/overlay/index.html
+++ b/src/renderer/overlay/index.html
@@ -58,6 +58,59 @@
min-width: 160px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
}
+ /* Snip action menu — Copy / Save / Cancel pinned to the
+ bottom-right of a completed snip selection. Sits ABOVE the
+ capture-surface in DOM order so clicks land on buttons rather
+ than the underlying drawing surface; we also stopPropagation
+ on pointerdown defensively. */
+ .snip-actions {
+ position: absolute;
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
+ padding: 3px;
+ background: rgba(20, 20, 22, 0.94);
+ border: 1px solid rgba(255, 255, 255, 0.12);
+ border-radius: 8px;
+ box-shadow: 0 10px 28px rgba(0, 0, 0, 0.45);
+ font-size: 12px;
+ line-height: 1;
+ z-index: 50;
+ animation: snipActionsIn 0.14s cubic-bezier(0.2, 0.7, 0.2, 1);
+ }
+ @keyframes snipActionsIn {
+ from { opacity: 0; transform: translateY(-2px) scale(0.97); }
+ to { opacity: 1; transform: translateY(0) scale(1); }
+ }
+ .snip-action {
+ appearance: none;
+ background: transparent;
+ color: #e4e4e6;
+ border: 1px solid transparent;
+ border-radius: 5px;
+ padding: 6px 12px;
+ font-size: 12px;
+ font-weight: 500;
+ cursor: pointer;
+ transition: background 0.12s, color 0.12s;
+ }
+ .snip-action:hover { background: rgba(255, 255, 255, 0.08); }
+ .snip-action:active { background: rgba(255, 255, 255, 0.14); }
+ .snip-action[disabled] { opacity: 0.55; cursor: progress; }
+ .snip-action[disabled]:hover { background: transparent; }
+ .snip-action-primary[disabled]:hover { filter: none; }
+ .snip-action-primary {
+ background: linear-gradient(180deg, #d8b572 0%, #b89150 100%);
+ color: #1c1c1e;
+ font-weight: 600;
+ }
+ .snip-action-primary:hover { filter: brightness(1.05); }
+ .snip-action-quiet {
+ color: rgba(228, 228, 230, 0.65);
+ padding: 6px 8px;
+ font-size: 13px;
+ }
+ .snip-action-quiet:hover { color: #e4e4e6; }
diff --git a/src/renderer/overlay/tools/snip.ts b/src/renderer/overlay/tools/snip.ts
index b382c1a..043df29 100644
--- a/src/renderer/overlay/tools/snip.ts
+++ b/src/renderer/overlay/tools/snip.ts
@@ -2,9 +2,6 @@ import type { RegionShape } from '../../../shared/types';
import type { Tool } from './types';
import { nextId } from './types';
-const PREVIEW_COLOR = '#ffffff';
-const PREVIEW_OPACITY = 0.9;
-
export const snip: Tool = (() => {
let draft: RegionShape | null = null;
let anchor: { x: number; y: number } | null = null;
@@ -21,8 +18,12 @@ export const snip: Tool = (() => {
id: nextId('snip-preview'),
p1: anchor,
p2: anchor,
- color: PREVIEW_COLOR,
- opacity: PREVIEW_OPACITY,
+ // Color/opacity are ignored when marchingAnts is set — the
+ // renderer paints its own high-contrast B/W pattern. Kept here
+ // for type-shape compatibility with RegionShape.
+ color: '#ffffff',
+ opacity: 1,
+ marchingAnts: true,
};
ctx.setDraft(draft);
},
diff --git a/src/renderer/penApi.d.ts b/src/renderer/penApi.d.ts
index 0c53868..5665557 100644
--- a/src/renderer/penApi.d.ts
+++ b/src/renderer/penApi.d.ts
@@ -1,4 +1,4 @@
-import type { HubStateUpdate } from '../shared/types';
+import type { HubStateUpdate, ScreenPermissionStatus } from '../shared/types';
declare global {
interface Window {
@@ -12,10 +12,10 @@ declare global {
onUndo(cb: () => void): () => void;
onRedo(cb: () => void): () => void;
onClear(cb: () => void): () => void;
- onScreenshot(cb: (payload: { dataUrl: string }) => void): () => void;
+ onScreenshot(cb: (payload: { png: Uint8Array }) => void): () => void;
onSnip(
cb: (payload: {
- dataUrl: string;
+ png: Uint8Array;
rect: { x: number; y: number; w: number; h: number };
scaleFactor: number;
}) => void,
@@ -25,8 +25,8 @@ declare global {
): () => void;
requestFocus(): Promise;
releaseFocus(): Promise;
- sendScreenshotResult(pngBase64: string): Promise;
- sendSnipResult(pngBase64: string): Promise;
+ sendScreenshotResult(png: Uint8Array): Promise;
+ sendSnipResult(png: Uint8Array): Promise;
};
snip: {
set(payload: {
@@ -50,11 +50,29 @@ declare global {
setContentSize(payload: { axis: 'h' | 'v'; size: number }): Promise;
};
permissions: {
- check(): Promise<{ screen: string; accessibility: boolean }>;
+ check(): Promise<{ screen: ScreenPermissionStatus; accessibility: boolean }>;
+ deepCheck(): Promise<{ screen: ScreenPermissionStatus; probeError: boolean }>;
open(which: 'screen' | 'accessibility'): Promise;
+ onNeeded(cb: (payload: { reason: 'screen' }) => void): () => void;
+ onStatus(
+ cb: (payload: { screen: ScreenPermissionStatus; probeError?: boolean }) => void,
+ ): () => void;
+ };
+ capture: {
+ onSaved(cb: (payload: { path: string }) => void): () => void;
+ onError(
+ cb: (payload: { message: string; recoverable: boolean }) => void,
+ ): () => void;
+ };
+ settings: {
+ pickSaveDir(): Promise;
+ };
+ shell: {
+ openPath(p: string): Promise;
};
app: {
- info(): Promise<{ name: string; version: string }>;
+ info(): Promise<{ name: string; version: string; packaged: boolean }>;
+ relaunch(): Promise;
};
env: {
displayId(): number;
diff --git a/src/renderer/toolbar/App.tsx b/src/renderer/toolbar/App.tsx
index 40fe4f3..403c099 100644
--- a/src/renderer/toolbar/App.tsx
+++ b/src/renderer/toolbar/App.tsx
@@ -11,6 +11,11 @@ import type {
} from '../../shared/types';
import { Icons, Logo } from './icons';
+// Status-panel discriminator. Both kinds reuse the existing
+// .settings-panel layout slot so they feel native to the toolbar
+// instead of floating as an out-of-place modal.
+type PanelKind = 'permission' | 'error';
+
interface HubSnapshot {
activeTool: ToolId;
drawMode: boolean;
@@ -23,6 +28,9 @@ interface HubSnapshot {
settingsOpen: boolean;
thicknessFlyoutOpen: boolean;
perToolWidth: { pencil: number; pen: number; eraser: number; highlighter: number };
+ saveDir: string | null;
+ alwaysAskSavePath: boolean;
+ statusPanelOpen: boolean;
}
type FlyoutTool = 'pencil' | 'pen' | 'eraser' | 'highlighter';
@@ -60,6 +68,36 @@ const TOOL_BY_ID: Record = ALL_TOOLS.reduce(
{} as Record,
);
+// Permission-panel hint copy. When probeError=true, desktopCapturer
+// outright threw on the recheck attempt — the process can't pick the
+// new TCC state up without a relaunch.
+function stuckHint(probeError: boolean): string {
+ if (probeError) {
+ return (
+ "macOS can't refresh the permission for a running process — " +
+ 'Click Relaunch to restart Lekhini and pick up the change.'
+ );
+ }
+ return (
+ "Still off. Make sure Lekhini is toggled on under Privacy & Security " +
+ '→ Screen Recording, then click Recheck.'
+ );
+}
+
+// Display-friendly path: replace the home dir with `~` and ellipsize
+// the middle if the result is still long. Pure cosmetic — the toast
+// is narrow and a full POSIX path overflows.
+function shortenPath(p: string, max = 56): string {
+ let s = p;
+ // Best-effort home detection — `process.env.HOME` is not available
+ // in the renderer; fall back to the common macOS / Linux prefix.
+ const home = /^\/Users\/[^/]+/.exec(s)?.[0] ?? /^\/home\/[^/]+/.exec(s)?.[0];
+ if (home && s.startsWith(home)) s = '~' + s.slice(home.length);
+ if (s.length <= max) return s;
+ const tail = s.slice(-(max - 3));
+ return '…' + tail;
+}
+
export function ToolbarApp() {
const [hub, setHub] = createSignal({
activeTool: 'pencil',
@@ -73,13 +111,38 @@ export function ToolbarApp() {
settingsOpen: false,
thicknessFlyoutOpen: false,
perToolWidth: { pencil: 3, pen: 4, eraser: 20, highlighter: 18 },
+ saveDir: null,
+ alwaysAskSavePath: false,
+ statusPanelOpen: false,
});
+ // Status-panel state. Mutually exclusive with the settings panel —
+ // when one opens, the layout slot belongs to it. `panelError` holds
+ // the message body when panelKind === 'error'; `panelHint` is a
+ // small inline note shown under the body after a manual Recheck
+ // returns the same denied status.
+ const [panelKind, setPanelKind] = createSignal(null);
+ const [panelError, setPanelError] = createSignal(null);
+ const [panelHint, setPanelHint] = createSignal(null);
+ // True after a recheck attempt where desktopCapturer.getSources()
+ // outright threw — process is stuck until relaunch. Drives the
+ // panel to promote the Relaunch button over Recheck.
+ const [permStuck, setPermStuck] = createSignal(false);
+ // Set by capture:saved so the titlebar hint becomes a clickable
+ // 'Reveal' that opens the file's folder. Cleared on next hover hint
+ // or after revealMs.
+ const [revealPath, setRevealPath] = createSignal(null);
+ let revealTimer: number | null = null;
const [platform, setPlatform] = createSignal('darwin');
const [hint, setHint] = createSignal('');
const [settingsOnLeft, setSettingsOnLeft] = createSignal(false);
- const [appInfo, setAppInfo] = createSignal<{ name: string; version: string }>({
+ const [appInfo, setAppInfo] = createSignal<{
+ name: string;
+ version: string;
+ packaged: boolean;
+ }>({
name: 'Lekhini',
version: '1.0.0',
+ packaged: true,
});
let scrollRef: HTMLDivElement | undefined;
let barMainRef: HTMLDivElement | undefined;
@@ -101,6 +164,60 @@ export function ToolbarApp() {
});
onCleanup(off);
+ // ── Permission + capture event wiring ────────────────────────
+ // The main process emits 'permissions:needed' when a capture can't
+ // proceed, and 'capture:saved' / 'capture:error' after each
+ // attempt. We surface permission + error states as side panels
+ // (same slot as Settings), and successful saves as a brief
+ // clickable hint in the titlebar — much calmer than a floating
+ // toast inside a tiny toolbar window.
+ const offNeeded = window.pen.permissions.onNeeded(() => {
+ setPanelHint(null);
+ setPermStuck(false);
+ setPanelKind('permission');
+ // Close settings if it was occupying the slot.
+ if (hub().settingsOpen) void window.pen.hub.update({ settingsOpen: false });
+ });
+ const offStatus = window.pen.permissions.onStatus((p) => {
+ if (p.screen === 'granted') {
+ setPanelKind((k) => (k === 'permission' ? null : k));
+ setPanelHint(null);
+ setPermStuck(false);
+ } else if (panelKind() === 'permission') {
+ setPermStuck(!!p.probeError);
+ setPanelHint(stuckHint(!!p.probeError));
+ }
+ });
+ const offSaved = window.pen.capture.onSaved((p) => {
+ // If we were showing an error panel, the user just successfully
+ // saved (e.g. via 'Pick new folder') — close the panel.
+ setPanelKind((k) => (k === 'error' ? null : k));
+ setPanelError(null);
+ // Inline confirmation in the titlebar hint, auto-clears at 4s.
+ setRevealPath(p.path);
+ setHint(`Saved · ${shortenPath(p.path)}`);
+ if (revealTimer !== null) window.clearTimeout(revealTimer);
+ revealTimer = window.setTimeout(() => {
+ setRevealPath(null);
+ setHint('');
+ revealTimer = null;
+ }, 4000);
+ });
+ const offError = window.pen.capture.onError((p) => {
+ setPanelError(p.message);
+ setPanelKind('error');
+ if (hub().settingsOpen) void window.pen.hub.update({ settingsOpen: false });
+ });
+ window.addEventListener('focus', onWindowFocus);
+ onCleanup(() => {
+ offNeeded();
+ offStatus();
+ offSaved();
+ offError();
+ window.removeEventListener('focus', onWindowFocus);
+ if (revealTimer !== null) window.clearTimeout(revealTimer);
+ });
+
const el = scrollRef;
if (!el) return;
@@ -182,16 +299,18 @@ export function ToolbarApp() {
}
let target = barMainHeight;
- if (s.settingsOpen) {
- const settingsPanel = barMainRef.parentElement?.querySelector(
+ // Settings panel and status panel both render with class
+ // .settings-panel; whichever is open occupies the dock slot.
+ if (s.settingsOpen || s.statusPanelOpen) {
+ const sidePanel = barMainRef.parentElement?.querySelector(
'.settings-panel',
) as HTMLElement | null;
- if (settingsPanel) {
- const settingsHeight = settingsPanel.scrollHeight;
+ if (sidePanel) {
+ const sideHeight = sidePanel.scrollHeight;
target =
s.orientation === 'h'
- ? barMainHeight + settingsHeight
- : Math.max(barMainHeight, settingsHeight);
+ ? barMainHeight + sideHeight
+ : Math.max(barMainHeight, sideHeight);
}
}
// 2px for the bar's 1px border on each side.
@@ -209,10 +328,38 @@ export function ToolbarApp() {
void s.orientation;
void s.minimized;
void s.settingsOpen;
+ void s.statusPanelOpen;
void s.thicknessFlyoutOpen;
void s.profile;
void s.activeTool;
- requestAnimationFrame(reportContentSize);
+ void panelKind();
+ // First RAF catches the common case (single-frame layout). A
+ // second RAF after it covers transitions where the bar-main was
+ // just unmounted-then-remounted (notably restore from
+ // minimized) — children sometimes need an extra frame to lay out
+ // their final size, and without this the footer would render
+ // clipped below the window's content-size until the next
+ // unrelated re-measure.
+ requestAnimationFrame(() => {
+ reportContentSize();
+ requestAnimationFrame(reportContentSize);
+ });
+ });
+
+ // Refresh which side the panel sits on whenever a status panel opens
+ // (mirrors the same logic the settings-open broadcast uses).
+ createEffect(() => {
+ if (panelKind() !== null) refreshSide();
+ });
+
+ // Push panelKind open/close into the hub so main resizes the
+ // toolbar window to fit. Avoid an immediate redundant patch on
+ // first mount where both sides are already false.
+ createEffect(() => {
+ const open = panelKind() !== null;
+ if (open !== hub().statusPanelOpen) {
+ void window.pen.hub.update({ statusPanelOpen: open });
+ }
});
// Whenever a side panel flips open, ask main which side of the screen
@@ -263,6 +410,12 @@ export function ToolbarApp() {
void window.pen.hub.update({ theme: hub().theme === 'dark' ? 'light' : 'dark' });
};
const setProfile = (p: ProfileId) => void window.pen.hub.update({ profile: p });
+ const toggleAlwaysAsk = () =>
+ void window.pen.hub.update({ alwaysAskSavePath: !hub().alwaysAskSavePath });
+ const pickSaveDir = async () => {
+ const dir = (await window.pen.settings.pickSaveDir()) as string | null;
+ if (dir) void window.pen.hub.update({ saveDir: dir });
+ };
const toggleSettings = () => {
const next = !hub().settingsOpen;
if (next) refreshSide();
@@ -270,6 +423,60 @@ export function ToolbarApp() {
};
const closeSettings = () => void window.pen.hub.update({ settingsOpen: false });
+ // Status-panel actions. panelKind drives the rendered content;
+ // hub.statusPanelOpen is the open/close flag main watches so it
+ // can grow/shrink the toolbar window to fit the panel (in v-mode
+ // the dock slot's width comes from main's resizeToolbar, not CSS).
+ // A createEffect below keeps them in sync — without this mirror
+ // the panel would render inside the 88px-wide v-mode bar and be
+ // effectively invisible.
+ const closePanel = () => {
+ setPanelKind(null);
+ setPanelError(null);
+ setPanelHint(null);
+ };
+ const recheckPermission = async () => {
+ // Use the deep probe — macOS's getMediaAccessStatus caches the
+ // result per-process and a plain check() can keep reporting
+ // 'denied' for the whole session even after the user toggled the
+ // permission on in System Settings. The deep probe actually hits
+ // desktopCapturer and forces a TCC refresh.
+ setPanelHint('Checking…');
+ const result = (await window.pen.permissions.deepCheck()) as {
+ screen: 'granted' | 'denied' | 'not-determined' | 'restricted' | 'unknown';
+ probeError: boolean;
+ };
+ if (result.screen === 'granted') {
+ closePanel();
+ return;
+ }
+ setPermStuck(result.probeError);
+ setPanelHint(stuckHint(result.probeError));
+ };
+ const openScreenPrefs = () => void window.pen.permissions.open('screen');
+ const relaunchApp = () => void window.pen.app.relaunch();
+ const pickFolderFromError = async () => {
+ const dir = (await window.pen.settings.pickSaveDir()) as string | null;
+ if (dir) {
+ void window.pen.hub.update({ saveDir: dir });
+ closePanel();
+ }
+ };
+ // Auto-recheck when the toolbar window regains focus — typical
+ // path is user opens System Settings, toggles Lekhini on, comes
+ // back. We only fire this while the permission panel is up so we
+ // don't badger the user otherwise.
+ const onWindowFocus = () => {
+ if (panelKind() === 'permission') void recheckPermission();
+ };
+
+ // Mirror the side-panel state into a CSS-friendly attribute so the
+ // existing layout rules (flex-direction switch in v-mode, etc.)
+ // apply uniformly whether settings or a status panel is open.
+ const sidePanelOpen = createMemo(() =>
+ hub().settingsOpen || panelKind() !== null,
+ );
+
const showHint = (text: string) => setHint(text);
const clearHint = () => setHint('');
const isMac = createMemo(() => platform() === 'darwin');
@@ -278,16 +485,15 @@ export function ToolbarApp() {
const allowed = new Set(PROFILES[hub().profile].tools);
return ALL_TOOLS.filter((t) => allowed.has(t.id));
});
- const brandLine = () => {
- if (hint()) return hint();
- if (hub().drawMode) return 'Draw mode active';
- return 'Lekhini';
- };
-
- const vertHintLine = () => {
+ // Hint line for the footer. Hover-hint takes priority; otherwise we
+ // show the active tool's name so the footer is never empty in
+ // either orientation. brandLine / vertHintLine retained for any
+ // future use but the footer is the new home for hover text.
+ const footerHintLine = (): string => {
if (hint()) return hint();
const active = TOOL_BY_ID[hub().activeTool];
- return active ? active.label : 'Lekhini';
+ if (active) return active.label;
+ return hub().drawMode ? 'Drawing' : 'Idle';
};
return (
@@ -297,14 +503,22 @@ export function ToolbarApp() {
data-min={hub().minimized ? 'true' : 'false'}
data-platform={isMac() ? 'mac' : 'win'}
data-theme={hub().theme}
- data-settings-open={hub().settingsOpen ? 'true' : 'false'}
+ data-settings-open={sidePanelOpen() ? 'true' : 'false'}
data-settings-side={settingsOnLeft() ? 'left' : 'right'}
>
- {Logo()}
+
+ {/* Click target is the inner span (no-drag region). The
+ outer .mini is a thin drag border so the pill can
+ still be moved without expanding. */}
+
}
>
@@ -332,20 +546,7 @@ export function ToolbarApp() {
@@ -631,6 +802,47 @@ export function ToolbarApp() {
+
+ {/* ─── FOOTER ─── hover-hint on the left, plus the
+ rarely-touched status-dot + settings on the right. Lives
+ at the bottom of bar-main so it doesn't take attention
+ away from the tools. The hint area is also where the
+ 'Saved · …/lekhini-…png' reveal link surfaces. */}
+
{/* ─── SETTINGS DROPDOWN ─── */}
@@ -692,6 +904,31 @@ export function ToolbarApp() {
+
+
File save
+
+ Always ask where to save
+
+
+
+ Save folder
+
+
+
+
About
@@ -711,6 +948,119 @@ export function ToolbarApp() {
+
+ {/* ─── STATUS PANEL (permission / save error) ──────────────
+ Reuses the .settings-panel layout slot so it docks like
+ the Settings panel and grows the toolbar window the same
+ way. Settings has render priority — we only show this
+ when the settings panel is closed. */}
+
+
+ Lekhini needs Screen Recording permission to capture annotated
+ screenshots.{' '}
+
+ macOS controls this — toggle Lekhini on under Privacy &
+ Security → Screen Recording, then return here. Lekhini
+ retries automatically when you come back.
+
+
+ You denied the system prompt last time. Try the screenshot
+ button again to be asked once more.
+
+
+
+
+
+ {panelHint()}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Dev mode — TCC quirks are normal here. The packaged
+ Lekhini build doesn't have this caching issue.
+
+
+
+
+
+
+
+
+ {Icons.clear()}
+
{panelError() ?? 'Unknown error.'}
+
+
+
+
+
+
+
+
+
);
diff --git a/src/renderer/toolbar/icons.tsx b/src/renderer/toolbar/icons.tsx
index dda1fce..1ac0574 100644
--- a/src/renderer/toolbar/icons.tsx
+++ b/src/renderer/toolbar/icons.tsx
@@ -1,109 +1,149 @@
import type { JSX } from 'solid-js';
+// Toolbar icon set, redrawn in the Phosphor Icons visual language
+// (https://phosphoricons.com — MIT). Each glyph is a fresh
+// implementation tailored to Lekhini's 22×22 toolbar slot, not a
+// verbatim copy of any Phosphor SVG. Stroke = 1.4 to match Phosphor's
+// "regular" weight at 24px equivalent. Pure currentColor; the toolbar
+// theme decides the actual hue via CSS.
+
const SVG = (children: JSX.Element): JSX.Element => (
-