fix(dictation): stop clipping the widget pill's drop shadow into a rectangle
The `widget` Tauri window is exactly 300x64 with an 8px body padding on every edge around the 48px pill, leaving only an 8px gutter before `overflow: hidden` clips anything painted outside it. `.capture-pill`'s shared shadow (`0 8px 32px` + `0 2px 8px`, ~40px of needed clearance) had nowhere to go there, so it hard-clipped into a straight edge at the window boundary — a rounded capsule sitting inside a hard-edged dark rectangle instead of floating free over the desktop. The recording/ transcribing state shadows had the same problem and fully override the base shadow, so the clip would reappear the instant dictation started. Scope a tighter shadow to `html[data-window='widget']` for the base pill and both state variants, each layer verified to keep `|y-offset| + blur + spread <= 8` (the gutter), and cap `max-width` to the window's 284px content box (the shared 340px value is wider than the window itself). The main-window `.capture-pill-host` path is untouched — it has real clearance and its shadow already renders correctly there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
bd84169ff2
commit
12b398b662
@@ -3668,6 +3668,49 @@ html[data-ui-scale-engine='native'] .app-bootstrap-scale {
|
||||
border-color: rgba(245, 158, 11, 0.35);
|
||||
}
|
||||
|
||||
/* ── Widget-window shadow (standalone Tauri window) ───────────────────
|
||||
The `widget` window (tauri.conf.json, label `widget`) is exactly
|
||||
300x64, `transparent: true`, `decorations: false`. Its body gets a
|
||||
flush `padding: 8px` on every edge (see `body:has(.capture-pill)`
|
||||
below) around the 48px-tall pill, plus `overflow: hidden` — so there
|
||||
is only an 8px gutter on every side for anything painted outside the
|
||||
pill's box before the clip kicks in.
|
||||
|
||||
The shadows above need real clearance (`0 8px 32px` alone wants ~40px)
|
||||
that only the main window (`.capture-pill-host`, ~line 2236) has. Here
|
||||
they were hard-clipped into a straight edge at the window boundary —
|
||||
a rounded capsule sitting inside a hard-edged dark rectangle instead of
|
||||
floating free. The shadow is decorative, so shrink it to actually fit
|
||||
rather than let it clip: every non-inset layer must keep
|
||||
`|y-offset| + blur + spread <= 8` (the gutter), which bounds every
|
||||
side since padding is symmetric and every offset-x here is 0. Applies
|
||||
to every state that sets its own box-shadow, not just the base pill,
|
||||
otherwise the clip reappears the moment recording/transcribing starts.
|
||||
Also caps `max-width` to the 300px window's 284px content box
|
||||
(300 - 8px padding * 2) — the shared 340px value is wider than the
|
||||
window itself. */
|
||||
html[data-window='widget'] .capture-pill {
|
||||
max-width: 284px;
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.35), /* 1 + 3 + 0 = 4 <= 8 */
|
||||
0 1px 2px rgba(0, 0, 0, 0.25), /* 1 + 2 + 0 = 3 <= 8 */
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
html[data-window='widget'] .capture-pill--recording {
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.35), /* 1 + 3 + 0 = 4 <= 8 */
|
||||
0 0 6px rgba(239, 68, 68, 0.1), /* 0 + 6 + 0 = 6 <= 8 */
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
html[data-window='widget'] .capture-pill--transcribing {
|
||||
box-shadow:
|
||||
0 1px 3px rgba(0, 0, 0, 0.35), /* 1 + 3 + 0 = 4 <= 8 */
|
||||
0 0 6px rgba(99, 102, 241, 0.1), /* 0 + 6 + 0 = 6 <= 8 */
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* ── Animations ─────────────────────────────────────────────────────── */
|
||||
|
||||
@keyframes pill-slide-in {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
/**
|
||||
* The widget window's dictation pill must not have its drop shadow clipped
|
||||
* into a hard-edged rectangle.
|
||||
*
|
||||
* The `widget` window (tauri.conf.json, label `widget`) is exactly 300x64.
|
||||
* `html[data-window='widget'] body:has(.capture-pill)` (index.css ~3766)
|
||||
* gives that body a flush `padding: 8px` on every edge and `overflow:
|
||||
* hidden`. That leaves only an 8px gutter around the 48px-tall pill for
|
||||
* anything painted outside its box before the clip kicks in.
|
||||
*
|
||||
* `.capture-pill`'s shared (main-window) shadow — `0 8px 32px` + `0 2px 8px`
|
||||
* — needs roughly 40px of clearance. In the widget window it has 8px, so the
|
||||
* blur is cut off in a straight line at the window edge: a rounded capsule
|
||||
* sitting inside a hard-edged dark rectangle instead of floating free.
|
||||
*
|
||||
* The fix scopes a tighter shadow (and a matching max-width) to
|
||||
* `html[data-window='widget'] .capture-pill` that actually fits the gutter.
|
||||
* This test pins that budget directly against index.css so a future edit
|
||||
* that widens the shadow — or the window without widening the padding —
|
||||
* gets caught immediately instead of shipping another visible clip.
|
||||
*/
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
const css = fs.readFileSync(path.join(import.meta.dirname, '..', 'index.css'), 'utf8');
|
||||
|
||||
// Window content box: 300px window width - 8px padding * 2 sides.
|
||||
const WINDOW_WIDTH = 300;
|
||||
const GUTTER = 8; // body padding on every edge; also `overflow: hidden`'s clip boundary.
|
||||
|
||||
/** Extracts a `selector { ... }` rule's body (comments stripped), or null if not found. */
|
||||
function extractRule(selector) {
|
||||
const start = css.indexOf(`${selector} {`);
|
||||
if (start === -1) return null;
|
||||
const braceStart = css.indexOf('{', start);
|
||||
const braceEnd = css.indexOf('}', braceStart);
|
||||
return css.slice(braceStart + 1, braceEnd).replace(/\/\*[\s\S]*?\*\//g, '');
|
||||
}
|
||||
|
||||
/** Asserts every non-inset box-shadow layer in `rule` fits the 8px gutter. */
|
||||
function expectShadowFitsGutter(rule) {
|
||||
const shadowMatch = rule.match(/box-shadow:\s*([^;]+);/);
|
||||
expect(shadowMatch).not.toBeNull();
|
||||
|
||||
// Split shadow layers on top-level commas only (not the ones inside rgba(...)).
|
||||
const layers = shadowMatch[1]
|
||||
.split(/,(?![^(]*\))/)
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const outerLayers = layers.filter((l) => !l.startsWith('inset'));
|
||||
expect(outerLayers.length).toBeGreaterThan(0);
|
||||
|
||||
for (const layer of outerLayers) {
|
||||
const lengths = layer
|
||||
.replace(/rgba?\([^)]*\)/, '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.map(parseFloat);
|
||||
const [, y, blur, spread = 0] = lengths;
|
||||
// Offset-x is assumed 0 (true for every layer here); blur/spread extend
|
||||
// symmetrically in every direction from the offset box, so this same
|
||||
// budget bounds top/left/right too whenever offset-y >= 0.
|
||||
const outsideExtent = Math.abs(y) + blur + spread;
|
||||
expect(outsideExtent).toBeLessThanOrEqual(GUTTER);
|
||||
}
|
||||
}
|
||||
|
||||
describe('index.css: widget-scoped .capture-pill shadow fits the 8px gutter', () => {
|
||||
it('defines an override under html[data-window=\'widget\'] whose outer shadow layers and max-width both fit inside the padding gutter', () => {
|
||||
// No such rule exists pre-fix: the widget window inherits the unscoped
|
||||
// .capture-pill shadow verbatim, which is what gets clipped.
|
||||
const rule = extractRule("html[data-window='widget'] .capture-pill");
|
||||
expect(rule).not.toBeNull();
|
||||
|
||||
expectShadowFitsGutter(rule);
|
||||
|
||||
const maxWidthMatch = rule.match(/max-width:\s*(\d+(?:\.\d+)?)px/);
|
||||
expect(maxWidthMatch).not.toBeNull();
|
||||
expect(parseFloat(maxWidthMatch[1])).toBeLessThanOrEqual(WINDOW_WIDTH - GUTTER * 2);
|
||||
});
|
||||
|
||||
// .capture-pill--recording / --transcribing fully override box-shadow
|
||||
// (not just add to it) and win the cascade over the base .capture-pill
|
||||
// rule while active, so the base override alone isn't enough — the clip
|
||||
// would reappear the instant the user starts recording, which is the
|
||||
// pill's single most common state.
|
||||
it.each(['recording', 'transcribing'])(
|
||||
'also fits the %s state\'s own colored-glow shadow inside the gutter',
|
||||
(state) => {
|
||||
const rule = extractRule(`html[data-window='widget'] .capture-pill--${state}`);
|
||||
expect(rule).not.toBeNull();
|
||||
expectShadowFitsGutter(rule);
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves the main-window .capture-pill-host shadow untouched', () => {
|
||||
// The main window has real clearance for the shadow; only the widget
|
||||
// window's tight 8px gutter forces the smaller override above.
|
||||
expect(css).toContain('0 8px 32px rgba(0, 0, 0, 0.4)');
|
||||
expect(css).toContain('0 2px 8px rgba(0, 0, 0, 0.2)');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user