diff --git a/README.md b/README.md
index 414e2f78..5db08522 100644
--- a/README.md
+++ b/README.md
@@ -55,7 +55,7 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the
| **Keep** | Saved diffs: encrypted, tagged, optionally auto-expiring. Drag a row onto another to arrange the list yourself; starred rows stay above the rest. Your open tabs come back on the next launch, and the strip can be told to close the oldest comparison to make room for a new one. |
| **Share** | One signed file only the recipients you ticked can open, carrying the expiry you chose so every copy dies at the same moment. Give a trusted key an email address and Diff Bro opens an addressed message in your own mail app with the sealed file on the clipboard — it never sends anything itself. The key swap rides the same rails: email your key from the My key dialog, and a key copied out of any chat app is offered — fingerprint first — when you press + Trusted key. |
| **Export as image** | A real screenshot of the diff view — your theme, panes and highlighting — cropped to the change and stitched if it's taller than the window. Snippets go the same way, and a Mermaid snippet leaves as its rendered diagram. |
-| **Snippets** | An encrypted, tagged text library you can drag straight into the diff pane — two snippets compare like any two files, and editing one updates the comparison on screen. Per-language highlighting, live Mermaid (readable light or dark whatever the app is wearing), Markdown/Jira preview, and secret snippets that render as `****`. Every edit keeps the version it replaced — History in the snippet window lists them by timestamp, each diffed against its predecessor, any of them a copy away. Name one `Standup {{today}}` and the placeholder resolves as you save — `{{now}}`, `{{week}}`, `{{weekday}}` and the rest are listed under the field as you type. Naming is completed inline: type a few characters and the rest of the shared head of your existing names appears ahead of the caret, Tab to take it. Drag a row onto another to arrange the library by hand. |
+| **Snippets** | An encrypted, tagged text library you can drag straight into the diff pane — two snippets compare like any two files, and editing one updates the comparison on screen. Per-language highlighting, live Mermaid (readable light or dark whatever the app is wearing), Markdown/Jira preview — headings, nested lists, task lists, tables, strikethrough and code, with a formatting row that writes them for you — and secret snippets that render as `****`. Every edit keeps the version it replaced — History in the snippet window lists them by timestamp, each diffed against its predecessor, any of them a copy away. Name one `Standup {{today}}` and the placeholder resolves as you save — `{{now}}`, `{{week}}`, `{{weekday}}` and the rest are listed under the field as you type. Naming is completed inline: type a few characters and the rest of the shared head of your existing names appears ahead of the caret, Tab to take it. Drag a row onto another to arrange the library by hand, and right-click one to give it a colour — six of them, painted across the whole row, so a handful of snippets group by eye across whatever tags they carry. Nothing filters or searches by it; it is there to be seen. |
| **Quick look-up** | A global shortcut searches your snippets and diffs without raising the app; copy one straight to the clipboard, or capture a new one with `Ctrl/Cmd+N` — whatever you searched for becomes its name, and the body is syntax-coloured as you type in whatever language it turns out to be. |
| **Stays out of the way** | On Windows, closing the window keeps Diff Bro in the notification area so the quick look-up shortcut still answers — right-click the icon to exit, and turn either that or start-at-sign-in off in Settings ▸ Desktop. |
| **Language** | Every menu, dialog and label reads from one message catalogue, and Settings ▸ Appearance switches it — menus included, without a restart. English ships today; a new language is a data file, not a code change. |
@@ -72,7 +72,7 @@ Builds are **unsigned**, so SmartScreen and Gatekeeper warn on first launch (the
- **Drag & drop** files onto the window; it warns before discarding unsaved work.
- **Ctrl/Cmd+V** pastes straight into a comparison — including pasted text against a real file.
- **Copy diff** puts a git-style unified patch on the clipboard.
-- **Quick look-up keys** — ↑/↓ browse, **→** steps into a preview or the tools, **←** steps back out, **Enter** opens, **Ctrl/Cmd+N** captures a new snippet without raising the app (or arrow to the _Create snippet_ row), **Tab** accepts the inline name completion, and **Ctrl/Cmd+Enter** saves it.
+- **Quick look-up keys** — ↑/↓ browse, **→** steps into a preview or the tools, **←** steps back out, **Enter** opens, **Ctrl/Cmd+N** captures a new snippet without raising the app (or arrow to the _Create snippet_ row), **Tab** accepts the inline name completion, and **Ctrl/Cmd+Enter** saves it. The card grows for writing, a click opens whatever row it lands on, and a snippet you were part-way through is still there the next time you summon it. A Jira or Markdown snippet gets the same formatting row the editor gives it, with the rendered form drawn beside the syntax as you type.
- **Resizable dialogs** — the snippet editor and tool windows resize from any edge and remember their size; existing snippets open read-only until you press Edit.
- **Save a tool's output** — anything a tool produced goes straight into the snippet library from its own window; you supply the name, the app fills the rest.
- **Uniform snippet names** — every name is sentence-cased on save, so a library grown over months still reads consistently.
diff --git a/docs/screenshots/themes/snippet-colour-amber.png b/docs/screenshots/themes/snippet-colour-amber.png
new file mode 100644
index 00000000..66679218
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-amber.png differ
diff --git a/docs/screenshots/themes/snippet-colour-beacon.png b/docs/screenshots/themes/snippet-colour-beacon.png
new file mode 100644
index 00000000..8a063f74
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-beacon.png differ
diff --git a/docs/screenshots/themes/snippet-colour-bloom.png b/docs/screenshots/themes/snippet-colour-bloom.png
new file mode 100644
index 00000000..84f4c082
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-bloom.png differ
diff --git a/docs/screenshots/themes/snippet-colour-contrast.png b/docs/screenshots/themes/snippet-colour-contrast.png
new file mode 100644
index 00000000..578ebc4b
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-contrast.png differ
diff --git a/docs/screenshots/themes/snippet-colour-dark.png b/docs/screenshots/themes/snippet-colour-dark.png
new file mode 100644
index 00000000..cca5ac3f
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-dark.png differ
diff --git a/docs/screenshots/themes/snippet-colour-dim.png b/docs/screenshots/themes/snippet-colour-dim.png
new file mode 100644
index 00000000..c90fff3b
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-dim.png differ
diff --git a/docs/screenshots/themes/snippet-colour-ember.png b/docs/screenshots/themes/snippet-colour-ember.png
new file mode 100644
index 00000000..3ff5853d
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-ember.png differ
diff --git a/docs/screenshots/themes/snippet-colour-graphite.png b/docs/screenshots/themes/snippet-colour-graphite.png
new file mode 100644
index 00000000..39a12728
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-graphite.png differ
diff --git a/docs/screenshots/themes/snippet-colour-light.png b/docs/screenshots/themes/snippet-colour-light.png
new file mode 100644
index 00000000..484227ae
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-light.png differ
diff --git a/docs/screenshots/themes/snippet-colour-linen.png b/docs/screenshots/themes/snippet-colour-linen.png
new file mode 100644
index 00000000..06ddffc4
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-linen.png differ
diff --git a/docs/screenshots/themes/snippet-colour-matrix.png b/docs/screenshots/themes/snippet-colour-matrix.png
new file mode 100644
index 00000000..b21a84a5
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-matrix.png differ
diff --git a/docs/screenshots/themes/snippet-colour-meridian.png b/docs/screenshots/themes/snippet-colour-meridian.png
new file mode 100644
index 00000000..0fd5be37
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-meridian.png differ
diff --git a/docs/screenshots/themes/snippet-colour-neon.png b/docs/screenshots/themes/snippet-colour-neon.png
new file mode 100644
index 00000000..f4a66f95
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-neon.png differ
diff --git a/docs/screenshots/themes/snippet-colour-nord.png b/docs/screenshots/themes/snippet-colour-nord.png
new file mode 100644
index 00000000..207e9510
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-nord.png differ
diff --git a/docs/screenshots/themes/snippet-colour-nyan.png b/docs/screenshots/themes/snippet-colour-nyan.png
new file mode 100644
index 00000000..13b978f5
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-nyan.png differ
diff --git a/docs/screenshots/themes/snippet-colour-sepia.png b/docs/screenshots/themes/snippet-colour-sepia.png
new file mode 100644
index 00000000..faaf6811
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-sepia.png differ
diff --git a/docs/screenshots/themes/snippet-colour-solar.png b/docs/screenshots/themes/snippet-colour-solar.png
new file mode 100644
index 00000000..4728df1f
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-solar.png differ
diff --git a/docs/screenshots/themes/snippet-colour-tide.png b/docs/screenshots/themes/snippet-colour-tide.png
new file mode 100644
index 00000000..ce1f8e39
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-tide.png differ
diff --git a/docs/screenshots/themes/snippet-colour-vector.png b/docs/screenshots/themes/snippet-colour-vector.png
new file mode 100644
index 00000000..6c7c48df
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-vector.png differ
diff --git a/docs/screenshots/themes/snippet-colour-volcano.png b/docs/screenshots/themes/snippet-colour-volcano.png
new file mode 100644
index 00000000..05c9668c
Binary files /dev/null and b/docs/screenshots/themes/snippet-colour-volcano.png differ
diff --git a/e2e/quick-look-draft.spec.mjs b/e2e/quick-look-draft.spec.mjs
new file mode 100644
index 00000000..765d02a3
--- /dev/null
+++ b/e2e/quick-look-draft.spec.mjs
@@ -0,0 +1,84 @@
+import { test, expect } from './fixtures.mjs'
+
+// Composing in the launcher is WRITING, and it was being done in a card sized to
+// read one line in. Two things have to hold: the window grows for the job, and
+// pressing the chord twice — which is how the launcher is dismissed and brought
+// back — does not take an unsaved draft with it.
+
+const launcher = (app, read) =>
+ app.evaluate(({ BrowserWindow }) => {
+ const w = BrowserWindow.getAllWindows().find((x) =>
+ x.webContents.getURL().includes('quicklook')
+ )
+ return w ? { bounds: w.getBounds(), visible: w.isVisible() } : null
+ }, read)
+
+const bounds = async (app) => (await launcher(app)).bounds
+const isVisible = async (app) => (await launcher(app))?.visible === true
+const toggle = (page) => page.evaluate(() => window.api.quickLookToggle())
+
+async function summon(app, page) {
+ const [ql] = await Promise.all([app.waitForEvent('window'), toggle(page)])
+ await ql.waitForLoadState('domcontentloaded')
+ await expect(ql.locator('.ql-input')).toBeVisible()
+ return ql
+}
+
+// Main owns the resize, so the height is polled off the OS window rather than
+// off anything the page can report about itself.
+const height = (app) => expect.poll(async () => (await bounds(app)).height, { timeout: 4000 })
+
+test('composing grows the card, and leaving it gives the size back', async ({ app, page }) => {
+ const ql = await summon(app, page)
+ const resting = await bounds(app)
+
+ await ql.locator('.ql-add').click()
+ await expect(ql.locator('.ql-compose')).toBeVisible()
+ await height(app).toBeGreaterThan(resting.height * 1.4)
+
+ // It grew from where it already was — down and to the right. Re-centring
+ // would slide the row under the pointer away mid-click, which is exactly what
+ // ate two clicks on Edit.
+ const grown = await bounds(app)
+ expect(grown.width).toBeGreaterThan(resting.width)
+ expect({ x: grown.x, y: grown.y }).toEqual({ x: resting.x, y: resting.y })
+
+ await ql.getByRole('button', { name: 'Cancel' }).click()
+ await height(app).toBe(resting.height)
+})
+
+test('a re-summon keeps an unsaved draft, and the card it was written in', async ({
+ app,
+ page
+}) => {
+ const ql = await summon(app, page)
+ await ql.locator('.ql-add').click()
+ await ql.locator('.ql-compose-text').fill('line one\nline two\nline three')
+ await height(app).toBeGreaterThan(600)
+ const composing = await bounds(app)
+
+ await toggle(page)
+ await expect.poll(() => isVisible(app), { timeout: 4000 }).toBe(false)
+ await toggle(page)
+ await expect.poll(() => isVisible(app), { timeout: 4000 }).toBe(true)
+
+ await expect(ql.locator('.ql-compose')).toBeVisible()
+ await expect(ql.locator('.ql-compose-text')).toHaveValue('line one\nline two\nline three')
+ expect((await bounds(app)).height).toBe(composing.height)
+})
+
+// The other half of the rule: with nothing at stake a summon is still a fresh
+// start, or a stale query outlives the search that typed it.
+test('a summon with nothing typed into it still starts clean', async ({ app, page }) => {
+ const ql = await summon(app, page)
+ await ql.locator('.ql-input').fill('mermaid')
+ await ql.locator('.ql-add').click()
+ await expect(ql.locator('.ql-compose')).toBeVisible()
+
+ await toggle(page)
+ await expect.poll(() => isVisible(app), { timeout: 4000 }).toBe(false)
+ await toggle(page)
+
+ await expect(ql.locator('.ql-compose')).toBeHidden()
+ await expect(ql.locator('.ql-input')).toHaveValue('')
+})
diff --git a/e2e/quick-look-keyboard.spec.mjs b/e2e/quick-look-keyboard.spec.mjs
index 6b7a868b..727d9927 100644
--- a/e2e/quick-look-keyboard.spec.mjs
+++ b/e2e/quick-look-keyboard.spec.mjs
@@ -166,16 +166,20 @@ test('an unmodified inline edit does not rewrite the stored language', async ({
await expect(ql.locator('.ql-compose-lang option:checked')).toHaveText(/SQL/)
await ql.keyboard.press('ControlOrMeta+Enter')
+ // Searched, not clicked: a click on a row now does what ↵ does — it opens the
+ // snippet in the main window — and this test wants the preview, not the hand-off.
await ql.locator('.ql-input').fill('orders probe')
- await ql.locator('.ql-res:not(.ql-res-create)', { hasText: 'Orders probe' }).first().click()
+ await expect(ql.locator('.ql-pv-name')).toHaveText('Orders probe')
await ql.locator('.ql-pv-head button', { hasText: 'Edit' }).click()
// Still Auto — the picker would read a bare "SQL" if the language had frozen.
await expect(ql.locator('.ql-compose-lang')).toHaveValue('auto')
await ql.keyboard.press('ControlOrMeta+Enter')
+ // Searched, not clicked: a click on a row now does what ↵ does — it opens the
+ // snippet in the main window — and this test wants the preview, not the hand-off.
await ql.locator('.ql-input').fill('orders probe')
- await ql.locator('.ql-res:not(.ql-res-create)', { hasText: 'Orders probe' }).first().click()
+ await expect(ql.locator('.ql-pv-name')).toHaveText('Orders probe')
await ql.locator('.ql-pv-head button', { hasText: 'Edit' }).click()
await expect(ql.locator('.ql-compose-lang')).toHaveValue('auto')
})
@@ -200,19 +204,19 @@ test('changing the language with an arrow key does not discard the draft', async
// The launcher hides on blur and is kept warm, so a summon must land on a fresh
// search band. This could not pass until focusInput() stopped throwing: it runs
// first in onMounted, so it took the quicklook:show registration down with it
-// and refresh() never ran at all.
-test("a re-summon starts fresh, not on last time's draft", async ({ app, page }) => {
+// and refresh() never ran at all. What a summon deliberately KEEPS — an unsaved
+// draft, a tool panel mid-conversion — is quick-look-draft.spec.mjs.
+test("a re-summon starts fresh, not on last time's search", async ({ app, page }) => {
const ql = await summon(app, page)
await ql.locator('.ql-input').fill('marker-query')
- await ql.keyboard.press('ControlOrMeta+n')
- await ql.locator('.ql-compose-text').fill('half-written thought')
- await expect(ql.locator('.ql-compose')).toBeVisible()
+ await ql.keyboard.press('ArrowDown')
+ await expect(ql.locator('.ql-res.sel')).toBeVisible()
await page.evaluate(() => window.api.quickLookToggle())
await page.evaluate(() => window.api.quickLookToggle())
- await expect(ql.locator('.ql-compose')).toBeHidden()
await expect(ql.locator('.ql-input')).toBeVisible()
await expect(ql.locator('.ql-input')).toHaveValue('')
+ await expect(ql.locator('.ql-res').first()).toHaveClass(/sel/)
})
diff --git a/e2e/quick-look-markup.spec.mjs b/e2e/quick-look-markup.spec.mjs
new file mode 100644
index 00000000..ba7efafe
--- /dev/null
+++ b/e2e/quick-look-markup.spec.mjs
@@ -0,0 +1,169 @@
+import { test, expect } from './fixtures.mjs'
+
+// The launcher can write a snippet, and a Jira or Markdown one is written with
+// markup — but the compose panel offered no formatting row at all, so choosing
+// either language in the launcher gave a plain textarea while the main editor
+// gave a toolbar. Same two languages, same buttons, wherever the writing happens.
+
+async function summon(app, page) {
+ const [ql] = await Promise.all([
+ app.waitForEvent('window'),
+ page.evaluate(() => window.api.quickLookToggle())
+ ])
+ await ql.waitForLoadState('domcontentloaded')
+ await expect(ql.locator('.ql-input')).toBeVisible()
+ return ql
+}
+
+const compose = async (app, page) => {
+ const ql = await summon(app, page)
+ await ql.locator('.ql-add').click()
+ await expect(ql.locator('.ql-compose')).toBeVisible()
+ return ql
+}
+
+const toolbar = (ql) => ql.locator('.ql-compose .format-toolbar')
+
+test('choosing Markdown brings up its formatting row, and the buttons work', async ({
+ app,
+ page
+}) => {
+ const ql = await compose(app, page)
+ // Plaintext has no markup, so it has no row — the toolbar is a property of the
+ // language, not decoration on the panel.
+ await expect(toolbar(ql)).toHaveCount(0)
+
+ await ql.locator('.ql-compose-lang').selectOption('markdown')
+ await expect(toolbar(ql)).toBeVisible()
+
+ await ql.locator('.ql-compose-text').fill('release notes')
+ await ql.locator('.ql-compose-text').evaluate((el) => el.setSelectionRange(0, 7))
+ await toolbar(ql).getByRole('button', { name: /Bold/ }).click()
+ await expect(ql.locator('.ql-compose-text')).toHaveValue('**release** notes')
+
+ // The caret stays inside what was just wrapped, so typing continues there
+ // rather than after the markers.
+ const sel = await ql
+ .locator('.ql-compose-text')
+ .evaluate((el) => [el.selectionStart, el.selectionEnd])
+ expect(sel).toEqual([2, 9])
+})
+
+test('choosing Jira brings up ITS row, with its own syntax', async ({ app, page }) => {
+ const ql = await compose(app, page)
+ await ql.locator('.ql-compose-lang').selectOption('jira')
+ await expect(toolbar(ql)).toBeVisible()
+
+ await ql.locator('.ql-compose-text').fill('deploy notes')
+ await ql.locator('.ql-compose-text').evaluate((el) => el.setSelectionRange(0, 6))
+ await toolbar(ql).getByRole('button', { name: /Bold/ }).click()
+ // Jira's bold is a single asterisk, Markdown's is two.
+ await expect(ql.locator('.ql-compose-text')).toHaveValue('*deploy* notes')
+})
+
+// Auto is the default, and it is what the language actually resolves to that
+// decides — a body that reads as Markdown gets the Markdown row without anyone
+// naming the language.
+test('a detected Markdown body gets the row on Auto', async ({ app, page }) => {
+ const ql = await compose(app, page)
+ await ql.locator('.ql-compose-text').fill('# Heading\n\n- one\n- two\n\n**bold** text\n')
+ await expect(toolbar(ql)).toBeVisible()
+ await expect(ql.locator('.ql-compose-lang')).toHaveValue('auto')
+})
+
+test('the row goes away again when the language stops having markup', async ({ app, page }) => {
+ const ql = await compose(app, page)
+ await ql.locator('.ql-compose-lang').selectOption('markdown')
+ await expect(toolbar(ql)).toBeVisible()
+
+ await ql.locator('.ql-compose-lang').selectOption('sql')
+ await expect(toolbar(ql)).toHaveCount(0)
+})
+
+// Markup is written to be READ as its rendered form, so the launcher shows that
+// form beside the raw text and keeps it current as you type — it used to show
+// nothing but the syntax, which is the one view the snippet is not for.
+const preview = (ql) => ql.locator('.ql-compose-preview')
+
+test('the rendered form appears beside the syntax, and follows the typing', async ({
+ app,
+ page
+}) => {
+ const ql = await compose(app, page)
+ await expect(preview(ql)).toHaveCount(0)
+
+ await ql.locator('.ql-compose-lang').selectOption('markdown')
+ await expect(preview(ql)).toBeVisible()
+
+ await ql.locator('.ql-compose-text').fill('# Release notes\n\n- **one**\n- two\n')
+ await expect(preview(ql).locator('h1')).toHaveText('Release notes')
+ await expect(preview(ql).locator('li')).toHaveCount(2)
+ await expect(preview(ql).locator('strong')).toHaveText('one')
+
+ // Still an editor: the raw text is right there and takes the next keystroke.
+ await ql.locator('.ql-compose-text').fill('# Release notes\n\n- **one**\n- two\n- three\n')
+ await expect(preview(ql).locator('li')).toHaveCount(3)
+ await expect(ql.locator('.ql-compose-text')).toHaveValue(/three/)
+})
+
+test('Jira renders its own markup, and plaintext renders nothing', async ({ app, page }) => {
+ const ql = await compose(app, page)
+ await ql.locator('.ql-compose-lang').selectOption('jira')
+ await ql.locator('.ql-compose-text').fill('h1. Deploy steps\n\n* first\n* second\n')
+ await expect(preview(ql).locator('h1')).toHaveText('Deploy steps')
+ await expect(preview(ql).locator('li')).toHaveCount(2)
+
+ await ql.locator('.ql-compose-lang').selectOption('sql')
+ await expect(preview(ql)).toHaveCount(0)
+})
+
+// The four the toolbars gained, driven the way a reader drives them: select,
+// press, read the rendered pane back.
+test('strikethrough, tasks, nesting and a table all reach the preview', async ({ app, page }) => {
+ test.setTimeout(120_000)
+ const ql = await compose(app, page)
+ await ql.locator('.ql-compose-lang').selectOption('markdown')
+ const body = ql.locator('.ql-compose-text')
+
+ await body.fill('gone tomorrow')
+ await body.evaluate((el) => el.setSelectionRange(0, 4))
+ await toolbar(ql).getByRole('button', { name: /Strikethrough/ }).click()
+ await expect(body).toHaveValue('~~gone~~ tomorrow')
+ await expect(preview(ql).locator('s')).toHaveText('gone')
+
+ await body.fill('ship it')
+ await body.evaluate((el) => el.setSelectionRange(0, 7))
+ await toolbar(ql).getByRole('button', { name: /Task list/ }).click()
+ await expect(body).toHaveValue('- [ ] ship it')
+ await expect(preview(ql).locator('input[type=checkbox]')).not.toBeChecked()
+
+ await toolbar(ql).getByRole('button', { name: /^Indent/ }).click()
+ await expect(body).toHaveValue(' - [ ] ship it')
+ await toolbar(ql).getByRole('button', { name: /^Outdent/ }).click()
+ await expect(body).toHaveValue('- [ ] ship it')
+
+ await body.fill('')
+ await toolbar(ql).getByRole('button', { name: /Table/ }).click()
+ await expect(preview(ql).locator('th')).toHaveCount(2)
+ await expect(preview(ql).locator('tbody tr')).toHaveCount(1)
+ // The first heading is selected, so it is typed over rather than deleted.
+ await ql.keyboard.type('Env')
+ await expect(preview(ql).locator('th').first()).toHaveText('Env')
+})
+
+test('Jira gets nesting and its own table syntax', async ({ app, page }) => {
+ test.setTimeout(120_000)
+ const ql = await compose(app, page)
+ await ql.locator('.ql-compose-lang').selectOption('jira')
+ const body = ql.locator('.ql-compose-text')
+
+ await body.fill('* one')
+ await body.evaluate((el) => el.setSelectionRange(0, 5))
+ await toolbar(ql).getByRole('button', { name: /^Indent/ }).click()
+ await expect(body).toHaveValue('** one')
+
+ await body.fill('')
+ await toolbar(ql).getByRole('button', { name: /Table/ }).click()
+ await expect(body).toHaveValue('||Column||Column||\n| | |\n')
+ await expect(preview(ql).locator('th')).toHaveCount(2)
+})
diff --git a/e2e/quick-look-mouse.spec.mjs b/e2e/quick-look-mouse.spec.mjs
new file mode 100644
index 00000000..5b90af8a
--- /dev/null
+++ b/e2e/quick-look-mouse.spec.mjs
@@ -0,0 +1,67 @@
+import { test, expect } from './fixtures.mjs'
+
+// The launcher is keyboard-first, but the mouse must not be a dead end: a click
+// on a row did nothing but select it, so a tool, the Tools header and the create
+// row all looked broken to anyone who reached for the pointer. One click now
+// does exactly what ↵ does on that row.
+const EXAMPLE = 'Example — Mermaid diagram'
+
+async function summon(app, page) {
+ const [ql] = await Promise.all([
+ app.waitForEvent('window'),
+ page.evaluate(() => window.api.quickLookToggle())
+ ])
+ await ql.waitForLoadState('domcontentloaded')
+ await expect(ql.locator('.ql-input')).toBeVisible()
+ return ql
+}
+
+const row = (ql, name) => ql.locator('.ql-res:not(.ql-res-create)', { hasText: name })
+
+test('one click opens the Tools section and then a tool', async ({ app, page }) => {
+ const ql = await summon(app, page)
+
+ await ql.locator('.ql-res.section').click()
+ await expect(ql.locator('.ql-res.sub').first()).toBeVisible()
+
+ await ql.locator('.ql-input').fill('base64')
+ await row(ql, 'Base64').click()
+ await expect(ql.locator('.qc-name')).toHaveText('Base64')
+})
+
+test('one click on the create row opens the compose panel', async ({ app, page }) => {
+ const ql = await summon(app, page)
+
+ await ql.locator('.ql-input').fill('Clicked into being')
+ await ql.locator('.ql-res-create').click()
+ await expect(ql.locator('.ql-compose')).toBeVisible()
+ await expect(ql.locator('.ql-compose-name')).toHaveValue('Clicked into being')
+})
+
+test('one click on a snippet hands it to the main window', async ({ app, page }) => {
+ await expect(page.getByText(EXAMPLE)).toBeVisible()
+ const ql = await summon(app, page)
+
+ await ql.locator('.ql-input').fill('Mermaid')
+ await row(ql, EXAMPLE).click()
+
+ // The same hand-off ↵ performs: the main window opens that snippet.
+ const opened = page.getByRole('dialog', { name: 'Snippet', exact: true })
+ await expect(opened).toBeVisible()
+ await expect(opened.getByPlaceholder('Snippet name…')).toHaveValue(EXAMPLE)
+})
+
+// Double-click WAS the activator until a single click became one, so it is the
+// gesture most likely to arrive out of habit — and acting on both clicks opened
+// the Tools section and shut it again in one go.
+test('a double-click opens a row once, not twice', async ({ app, page }) => {
+ const ql = await summon(app, page)
+
+ await ql.locator('.ql-res.section').dblclick()
+ await expect(ql.locator('.ql-res.sub').first()).toBeVisible()
+
+ await ql.locator('.ql-input').fill('Doubled into being')
+ await ql.locator('.ql-res-create').dblclick()
+ await expect(ql.locator('.ql-compose')).toBeVisible()
+ await expect(ql.locator('.ql-compose-name')).toHaveValue('Doubled into being')
+})
diff --git a/e2e/quick-look.spec.mjs b/e2e/quick-look.spec.mjs
index ce2188e4..42ea03ea 100644
--- a/e2e/quick-look.spec.mjs
+++ b/e2e/quick-look.spec.mjs
@@ -317,7 +317,9 @@ test('an unnamed snippet can be created and then edited in place', async ({ app,
const row = ql.locator('.ql-res', { hasText: 'Untitled' }).first()
await expect(row).toBeVisible()
await expect(row).toContainText(/Untitled \d{4}-\d{2}-\d{2} \d{2}:\d{2}/)
- await row.click()
+ // Selected by searching, not by clicking: a click opens it in the main window.
+ await ql.locator('.ql-input').fill('Untitled')
+ await expect(ql.locator('.ql-pv-name')).toContainText('Untitled')
// Edit it right here rather than being bounced to the main window.
await ql.locator('.ql-pv-head button', { hasText: 'Edit' }).click()
@@ -329,7 +331,9 @@ test('an unnamed snippet can be created and then edited in place', async ({ app,
await ql.locator('.ql-compose .btn-primary').click()
await expect(ql.locator('.ql-compose')).toBeHidden()
- // Renamed in place — sentence-cased, and no second copy left behind.
+ // Renamed in place — sentence-cased, and no second copy left behind. Counted
+ // on the WHOLE list, so the search that selected it cannot flatter the result.
+ await ql.locator('.ql-input').fill('')
await expect(ql.locator('.ql-res', { hasText: 'Auth token' })).toHaveCount(1)
await expect(ql.locator('.ql-res', { hasText: 'Untitled' })).toHaveCount(0)
})
diff --git a/e2e/row-tags-toggle.spec.mjs b/e2e/row-tags-toggle.spec.mjs
new file mode 100644
index 00000000..c1e7c189
--- /dev/null
+++ b/e2e/row-tags-toggle.spec.mjs
@@ -0,0 +1,123 @@
+import {
+ test,
+ expect,
+ launchApp,
+ freshUserDataDir,
+ firstReadyPage,
+ newSnippetButton,
+ openSettings
+} from './fixtures.mjs'
+
+// A tag word sits next to the name on every saved-diff and snippet row. For a
+// library where nearly everything carries the same two tags that is noise beside
+// the one thing being read — the name — so it can be turned off. Tags themselves
+// are untouched: the shelf still filters and the search still finds by them.
+
+const TOGGLE = 'Show tags on sidebar rows'
+const snippetTag = (page) => page.locator('.snippets-section .row .tag-word')
+const diffTag = (page) => page.locator('li.diff .tag-word')
+
+async function seedSnippet(page, name, tags) {
+ await newSnippetButton(page).click()
+ const editor = page.getByRole('dialog', { name: 'New Snippet' })
+ await editor.getByPlaceholder('Snippet name…').fill(name)
+ await editor.locator('.editor').click()
+ await page.keyboard.type('seeded body')
+ for (const tag of tags) {
+ await editor.getByPlaceholder('add a tag…').fill(tag)
+ await editor.getByPlaceholder('add a tag…').press('Enter')
+ }
+ await editor.getByRole('button', { name: 'Save', exact: true }).click()
+ await expect(editor).toBeHidden()
+}
+
+async function seedDiff(page, name, tags) {
+ const left = page.getByPlaceholder('Paste original text here')
+ if (!(await left.isVisible().catch(() => false))) {
+ await page.getByRole('button', { name: 'Paste mode' }).click()
+ }
+ await left.fill(`before ${name}`)
+ await page.getByPlaceholder('Paste changed text here').fill(`after ${name}`)
+ await page.getByRole('button', { name: 'Compare', exact: true }).click()
+ await page.getByRole('button', { name: 'Save', exact: true }).click()
+ const dialog = page.getByRole('dialog', { name: 'Save diff' })
+ await dialog.getByLabel('Name', { exact: true }).fill(name)
+ for (const tag of tags) {
+ await dialog.getByPlaceholder('add a tag…').fill(tag)
+ await dialog.getByPlaceholder('add a tag…').press('Enter')
+ }
+ await dialog.getByRole('button', { name: 'Save', exact: true }).click()
+ await expect(page.locator('li.diff', { hasText: name })).toBeVisible()
+}
+
+async function setRowTags(page, on) {
+ await openSettings(page)
+ const toggle = page.getByText(TOGGLE)
+ await expect(toggle).toBeVisible()
+ const box = page.locator('.setting-toggle', { hasText: TOGGLE }).locator('input')
+ if ((await box.isChecked()) !== on) await box.click()
+ await expect(box).toBeChecked({ checked: on })
+ await page.keyboard.press('Escape')
+ await expect(page.getByRole('dialog', { name: 'Settings' })).toBeHidden()
+}
+
+test('the toggle takes the tag word off both kinds of row, and puts it back', async ({ page }) => {
+ test.setTimeout(120_000)
+ await seedSnippet(page, 'Rollback runbook', ['ops'])
+ await seedDiff(page, 'E2E tagged diff', ['ops'])
+
+ await expect(snippetTag(page).first()).toBeVisible()
+ await expect(diffTag(page).first()).toBeVisible()
+
+ await setRowTags(page, false)
+ await expect(snippetTag(page)).toHaveCount(0)
+ await expect(diffTag(page)).toHaveCount(0)
+ // The name is what the row is for, and it is still there.
+ await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible()
+
+ await setRowTags(page, true)
+ await expect(snippetTag(page).first()).toBeVisible()
+ await expect(diffTag(page).first()).toBeVisible()
+})
+
+// Hiding the WORD is not hiding the tag: both ways of reaching one still work,
+// which is the whole reason the row can afford to drop it.
+test('the shelf and the search still filter by tag with the words off', async ({ page }) => {
+ test.setTimeout(120_000)
+ await seedSnippet(page, 'Rollback runbook', ['ops'])
+ await seedSnippet(page, 'Untagged note', [])
+ await setRowTags(page, false)
+
+ const chip = page.locator('.usb-tag', { hasText: 'ops' }).first()
+ await expect(chip).toBeVisible()
+ await chip.click()
+ await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible()
+ await expect(page.locator('.snippets-section .row', { hasText: 'Untagged note' })).toHaveCount(0)
+ await chip.click()
+
+ await page.getByPlaceholder('Search diffs & snippets…').fill('ops')
+ await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible()
+ await expect(page.locator('.snippets-section .row', { hasText: 'Untagged note' })).toHaveCount(0)
+})
+
+test('the choice survives a relaunch', async () => {
+ test.setTimeout(120_000)
+ const dir = freshUserDataDir()
+ let app = await launchApp(dir)
+ try {
+ const page = await firstReadyPage(app)
+ await seedSnippet(page, 'Rollback runbook', ['ops'])
+ await setRowTags(page, false)
+ } finally {
+ await app.close()
+ }
+
+ app = await launchApp(dir)
+ try {
+ const page = await firstReadyPage(app)
+ await expect(page.locator('.snippets-section .row', { hasText: 'Rollback runbook' })).toBeVisible()
+ await expect(snippetTag(page)).toHaveCount(0)
+ } finally {
+ await app.close()
+ }
+})
diff --git a/e2e/snippet-colour.spec.mjs b/e2e/snippet-colour.spec.mjs
new file mode 100644
index 00000000..397b7779
--- /dev/null
+++ b/e2e/snippet-colour.spec.mjs
@@ -0,0 +1,185 @@
+import {
+ test,
+ expect,
+ launchApp,
+ freshUserDataDir,
+ firstReadyPage,
+ newSnippetButton
+} from './fixtures.mjs'
+
+// A colour the reader picks, painted across the whole row. It is decoration —
+// nothing filters, searches or sorts by it — so what has to hold is that it
+// SHOWS, that it stays readable on every ground, and that it never eats a state
+// the row already had. Only a launched app resolves `oklch(from …)` and
+// `color-mix`, so the contrast claims are measured here rather than asserted
+// against a stylesheet.
+
+const row = (page, name) => page.locator('.snippets-section .row', { hasText: name })
+const menu = (page) => page.locator('.rcm')
+
+async function seedSnippet(page, name) {
+ await newSnippetButton(page).click()
+ const editor = page.getByRole('dialog', { name: 'New Snippet' })
+ await editor.getByPlaceholder('Snippet name…').fill(name)
+ await editor.locator('.editor').click()
+ await page.keyboard.type('seeded body')
+ await editor.getByRole('button', { name: 'Save', exact: true }).click()
+ await expect(editor).toBeHidden()
+}
+
+const openMenu = async (page, name) => {
+ await row(page, name).click({ button: 'right' })
+ await expect(menu(page)).toBeVisible()
+}
+
+const pick = async (page, color) => {
+ await menu(page).locator(`[data-color="${color}"]`).click()
+ await expect(menu(page)).toHaveCount(0)
+}
+
+// WCAG contrast between the row's own ink and the ground it actually paints,
+// read off the live DOM — the wash is a color-mix over the panel, so nothing
+// short of a real render knows what it resolves to.
+const contrastOfName = (page, name) =>
+ row(page, name).evaluate((el) => {
+ const lum = (c) => {
+ const [r, g, b] = c
+ .match(/[\d.]+/g)
+ .slice(0, 3)
+ .map((v) => {
+ const x = Number(v) / 255
+ return x <= 0.04045 ? x / 12.92 : ((x + 0.055) / 1.055) ** 2.4
+ })
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
+ }
+ // The row's background is a mix over the panel; walk up for the ground it
+ // lands on so the ratio is what the eye actually gets.
+ const ground = (node) => {
+ for (let el = node; el; el = el.parentElement) {
+ const bg = getComputedStyle(el).backgroundColor
+ const alpha = Number(bg.match(/[\d.]+/g)?.[3] ?? 1)
+ if (alpha === 1) return bg
+ }
+ return 'rgb(255, 255, 255)'
+ }
+ const paint = (over, under) => {
+ const a = over.match(/[\d.]+/g).map(Number)
+ const b = under.match(/[\d.]+/g).map(Number)
+ const alpha = a[3] ?? 1
+ return `rgb(${[0, 1, 2].map((i) => a[i] * alpha + b[i] * (1 - alpha)).join(',')})`
+ }
+ const ink = getComputedStyle(el.querySelector('.nm')).color
+ const bg = paint(getComputedStyle(el).backgroundColor, ground(el.parentElement))
+ const [hi, lo] = [lum(ink), lum(bg)].sort((x, y) => y - x)
+ return (hi + 0.05) / (lo + 0.05)
+ })
+
+test('right-click paints the row, and the menu remembers what it is', async ({ page }) => {
+ test.setTimeout(120_000)
+ await seedSnippet(page, 'Deploy rollback')
+
+ // No colour to start with, so nothing about the row says there could be one.
+ await expect(row(page, 'Deploy rollback')).not.toHaveAttribute('data-color', /./)
+
+ await openMenu(page, 'Deploy rollback')
+ await pick(page, 'blue')
+ await expect(row(page, 'Deploy rollback')).toHaveAttribute('data-color', 'blue')
+
+ // The wash and the 3px edge are both painted, and neither is the panel.
+ const paint = await row(page, 'Deploy rollback').evaluate((el) => ({
+ bg: getComputedStyle(el).backgroundColor,
+ edge: getComputedStyle(el).boxShadow
+ }))
+ expect(paint.bg).not.toMatch(/rgba\(0, 0, 0, 0\)/)
+ expect(paint.edge).toMatch(/inset/)
+
+ // Reopening lands on what the row already is — a menu that opens on someone
+ // else's answer recolours by accident.
+ await openMenu(page, 'Deploy rollback')
+ await expect(menu(page).locator('[data-color="blue"]')).toHaveAttribute('aria-checked', 'true')
+ await expect(menu(page).locator('[data-color="none"]')).toHaveAttribute('aria-checked', 'false')
+ await page.keyboard.press('Escape')
+ await expect(menu(page)).toHaveCount(0)
+ await expect(row(page, 'Deploy rollback')).toHaveAttribute('data-color', 'blue')
+})
+
+test('None takes it off again', async ({ page }) => {
+ test.setTimeout(120_000)
+ await seedSnippet(page, 'Standup notes')
+ await openMenu(page, 'Standup notes')
+ await pick(page, 'rose')
+ await expect(row(page, 'Standup notes')).toHaveAttribute('data-color', 'rose')
+
+ await openMenu(page, 'Standup notes')
+ await pick(page, 'none')
+ await expect(row(page, 'Standup notes')).not.toHaveAttribute('data-color', /./)
+})
+
+test('the keyboard drives the menu without the pointer', async ({ page }) => {
+ test.setTimeout(120_000)
+ await seedSnippet(page, 'Kafka lag query')
+ await openMenu(page, 'Kafka lag query')
+
+ // Opens on None (no colour yet); Home is the first colour, → steps on.
+ await page.keyboard.press('Home')
+ await page.keyboard.press('ArrowRight')
+ await page.keyboard.press('Enter')
+ await expect(menu(page)).toHaveCount(0)
+ await expect(row(page, 'Kafka lag query')).toHaveAttribute('data-color', 'amber')
+})
+
+// The claim the design rests on: the name stays readable on the wash. Measured
+// on a light ground and on a dark one, for every colour in the palette.
+test('every colour keeps the name above the reading floor, light and dark', async ({ page }) => {
+ test.setTimeout(180_000)
+ await seedSnippet(page, 'Contrast probe')
+ const was = await page.evaluate(() => document.documentElement.dataset.theme)
+
+ for (const theme of ['light', 'dark']) {
+ await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), theme)
+ for (const colour of ['rose', 'amber', 'green', 'teal', 'blue', 'violet']) {
+ await openMenu(page, 'Contrast probe')
+ await pick(page, colour)
+ const ratio = await contrastOfName(page, 'Contrast probe')
+ expect(ratio, `${colour} on ${theme}`).toBeGreaterThanOrEqual(4.5)
+ }
+ }
+ // Put back exactly what was there, rather than toggling to whatever is next.
+ await page.evaluate((t) => document.documentElement.setAttribute('data-theme', t), was)
+})
+
+test('the launcher shows the colour, and it survives a relaunch', async () => {
+ test.setTimeout(120_000)
+ const dir = freshUserDataDir()
+ let app = await launchApp(dir)
+ try {
+ const page = await firstReadyPage(app)
+ await seedSnippet(page, 'Deploy rollback')
+ await openMenu(page, 'Deploy rollback')
+ await pick(page, 'violet')
+ } finally {
+ await app.close()
+ }
+
+ app = await launchApp(dir)
+ try {
+ const page = await firstReadyPage(app)
+ await expect(row(page, 'Deploy rollback')).toHaveAttribute('data-color', 'violet')
+
+ const [ql] = await Promise.all([
+ app.waitForEvent('window'),
+ page.evaluate(() => window.api.quickLookToggle())
+ ])
+ await ql.waitForLoadState('domcontentloaded')
+ await ql.locator('.ql-input').fill('Deploy rollback')
+ // Not the create row, which a `hasText` match picks up as well.
+ const result = ql.locator('.ql-res:not(.ql-res-create)', { hasText: 'Deploy rollback' })
+ await expect(result).toHaveAttribute('data-color', 'violet')
+ // Selected AND coloured: the accent keyline returns, the colour stays.
+ await expect(result).toHaveClass(/sel/)
+ const shown = await result.evaluate((el) => getComputedStyle(el).boxShadow)
+ expect(shown).toMatch(/inset/)
+ } finally {
+ await app.close()
+ }
+})
diff --git a/e2e/tag-overflow.spec.mjs b/e2e/tag-overflow.spec.mjs
index 681410d0..50149fd2 100644
--- a/e2e/tag-overflow.spec.mjs
+++ b/e2e/tag-overflow.spec.mjs
@@ -38,11 +38,16 @@ const TAGS = [
test('a long tag list stays bounded and the rest go behind a picker', async ({ page }) => {
await saveWithTags(page, 'E2E many tags', TAGS)
- // Two rows' worth, whatever the library holds — the seeded examples carry
- // tags of their own, so the bar is bounded by the cap, not by this test.
+ // Bounded by the shelf's own height, whatever the library holds — the seeded
+ // examples carry tags of their own, so the bar is bounded by the depth, not by
+ // this test. Nothing is clipped: the count is measured against that height.
const shown = await page.locator('.usb-tags .usb-tag').count()
- expect(shown).toBeLessThanOrEqual(8)
expect(shown).toBeLessThan(TAGS.length)
+ const box = await page.locator('.usb-shelf').boundingBox()
+ for (const chip of await page.locator('.usb-shelf > *').all()) {
+ const at = await chip.boundingBox()
+ expect(at.y + at.height).toBeLessThanOrEqual(box.y + box.height + 1)
+ }
const more = page.locator('.usb-more')
await expect(more).toBeVisible()
@@ -55,7 +60,8 @@ test('a long tag list stays bounded and the rest go behind a picker', async ({ p
await more.click()
const picker = page.getByRole('dialog', { name: 'Collapsed tags' })
await expect(picker.locator('.usb-tag')).toHaveCount(overflow)
- expect(overflow).toBeLessThan(TAGS.length)
+ // The bar and the picker partition the registry — no tag in both, none lost.
+ expect(shown + overflow).toBeGreaterThanOrEqual(TAGS.length)
await picker.getByLabel('Find a tag').fill('juli')
await expect(picker.locator('.usb-tag')).toHaveCount(1)
diff --git a/e2e/tag-shelf-resize.spec.mjs b/e2e/tag-shelf-resize.spec.mjs
index 54752a68..c7a5c889 100644
--- a/e2e/tag-shelf-resize.spec.mjs
+++ b/e2e/tag-shelf-resize.spec.mjs
@@ -7,25 +7,24 @@ import {
newSnippetButton
} from './fixtures.mjs'
-// The tag shelf's depth has exactly one affordance — the grip — so the drag is
-// proven against real layout (quantised on the measured chip row) and its
-// persistence is proven the only way it can be: a relaunch of the same profile.
-// Only a launched app renders either.
-
-// The first-run examples already seed four tags (example, mermaid, claude,
-// prompt); ten more guarantee the shelf overflows at every depth this test
-// visits, so the +N chip stays observable on both sides of the drag.
-const TAG_COUNT = 10
-const RESTING_CHIPS = 8 // MIN_TAG_ROWS (2) × TAGS_PER_ROW (4)
-const DRAGGED_CHIPS = 12 // one row deeper
-const OLD_CEILING_CHIPS = 48 // what twelve rows used to be worth
+// The shelf's depth is a HEIGHT the grip drags, and what it shows at that height
+// is measured — a chip is as wide as its name, so the four-per-row it used to
+// assume made one row of pointer travel worth anything between half a rendered
+// row and two. Only a launched app has widths to measure, so all of it is
+// proven here: the seam tracking the pointer, the "+N more" count matching what
+// was actually cut, and the depth surviving a relaunch.
+
+// The first-run examples seed four tags (example, mermaid, claude, prompt);
+// these guarantee the shelf overflows at every depth this test visits.
+const TAG_COUNT = 36
+// Whatever the shelf is dragged to, the sections keep this much of the column.
+const LIST_FLOOR = 150
// var(--space-3) under the last chip row, matching the air over the shelf's
// separator: the strip's border used to sit 5px under the chips at every depth.
const BOTTOM_GAP = 10
-// Whatever the shelf is dragged to, the sections keep this much of the column.
-const LIST_FLOOR = 150
// MAX_TAGS is 20 per snippet, so a deep shelf takes several.
const PER_SNIPPET = 18
+const GAP = 4
async function seedSnippet(page, name, tags) {
await newSnippetButton(page).click()
@@ -48,6 +47,9 @@ async function seedTags(page, count = TAG_COUNT) {
}
}
+const shelfHeight = async (page) => (await page.locator('.usb-shelf').boundingBox()).height
+const rowStep = async (page) => (await page.locator('.usb-tag').first().boundingBox()).height + GAP
+
async function dragShelf(page, toY) {
const box = await page.locator('.usb-shelf-grip').boundingBox()
const x = box.x + box.width / 2
@@ -58,11 +60,6 @@ async function dragShelf(page, toY) {
await page.mouse.up()
}
-async function dragShelfDownOneRow(page) {
- const rowStep = (await page.locator('.usb-tag').first().boundingBox()).height + 4
- await dragShelf(page, (y) => y + rowStep)
-}
-
// The air between the last chip row and the strip's border, which is the same
// number at every depth.
async function bottomGap(page) {
@@ -87,40 +84,64 @@ async function expectHandleOnTheSeam(page) {
expect(onSeam).toContain('usb-shelf-grip')
}
-test('the shelf grip deepens the tag rows, and the depth survives a relaunch', async () => {
- test.setTimeout(90_000)
+// Every chip the shelf shows is WHOLLY inside it: the count is measured, so a
+// chip clipped at the bottom edge means the measurement is wrong.
+async function expectNothingClipped(page) {
+ const box = await page.locator('.usb-shelf').boundingBox()
+ const chips = await page.locator('.usb-shelf > *').all()
+ for (const chip of chips) {
+ const at = await chip.boundingBox()
+ expect(at.y + at.height).toBeLessThanOrEqual(box.y + box.height + 1)
+ }
+}
+
+// "+N more" opens the N that did not fit — not the whole registry with the N
+// promised lost inside it.
+async function expectOverflowExact(page) {
+ const shown = await page.locator('.usb-tag').count()
+ const label = await page.locator('.usb-more').textContent()
+ const promised = Number(label.match(/\d+/)[0])
+
+ await page.locator('.usb-more').click()
+ const picker = page.locator('.picker')
+ await expect(picker.locator('.tag-chip')).toHaveCount(promised)
+ await page.locator('.picker-backdrop').click({ position: { x: 5, y: 5 } })
+ await expect(picker).toHaveCount(0)
+ return shown + promised
+}
+
+test('the grip moves the shelf with the pointer, and the depth survives a relaunch', async () => {
+ test.setTimeout(120_000)
const dir = freshUserDataDir()
let app = await launchApp(dir)
+ let dragged
try {
const page = await firstReadyPage(app)
await seedTags(page)
- const chips = page.locator('.usb-tag')
- await expect(chips).toHaveCount(RESTING_CHIPS)
- await expect(page.locator('.usb-more')).toBeVisible()
-
await expectHandleOnTheSeam(page)
+ await expectNothingClipped(page)
+ const total = await expectOverflowExact(page)
const restingGap = await bottomGap(page)
expect(restingGap).toBeGreaterThanOrEqual(BOTTOM_GAP)
- // "+6 more" opens the SIX that did not fit — not the whole registry with
- // the promised six lost inside it.
- await page.locator('.usb-more').click()
- const picker = page.locator('.picker')
- await expect(picker).toBeVisible()
- await expect(picker.locator('.tag-chip')).toHaveCount(6)
- await expect(picker.locator('.tag-chip', { hasText: 'tag-05' })).toBeVisible()
- await expect(picker.locator('.tag-chip', { hasText: 'example' })).toHaveCount(0)
- await page.locator('.picker-backdrop').click({ position: { x: 5, y: 5 } })
- await expect(picker).toHaveCount(0)
-
- await dragShelfDownOneRow(page)
-
- // One row deeper: four more chips show and the overflow count falls in step.
- await expect(chips).toHaveCount(DRAGGED_CHIPS)
- await expect(page.locator('.usb-more')).toContainText('+2')
- // And the air under the shelf is the air it had before the drag.
+ const step = await rowStep(page)
+ const before = await shelfHeight(page)
+ const shownBefore = await page.locator('.usb-tag').count()
+
+ // Three rows of travel is three rows of shelf — within the rounding that
+ // lands it on a whole row, not the half-to-double it used to be.
+ await dragShelf(page, (y) => y + 3 * step)
+ const after = await shelfHeight(page)
+ expect(after - before).toBeGreaterThan(3 * step - GAP)
+ expect(after - before).toBeLessThan(3 * step + GAP)
+
+ expect(await page.locator('.usb-tag').count()).toBeGreaterThan(shownBefore)
+ await expectNothingClipped(page)
+ expect(await expectOverflowExact(page)).toBe(total)
expect(await bottomGap(page)).toBeCloseTo(restingGap, 0)
+
+ dragged = await page.locator('.usb-tag').count()
} finally {
await app.close()
}
@@ -129,25 +150,25 @@ test('the shelf grip deepens the tag rows, and the depth survives a relaunch', a
app = await launchApp(dir)
try {
const page = await firstReadyPage(app)
- await expect(page.locator('.usb-tag')).toHaveCount(DRAGGED_CHIPS)
+ await expect(page.locator('.usb-tag')).toHaveCount(dragged)
} finally {
await app.close()
}
})
-// Dragged to the floor of the window: the shelf goes far deeper than the dozen
-// rows it used to stop at, and the sections it shares the column with keep a
-// usable slice of it — squeezed out, they take the grip off the bottom edge with
-// them and there is no way left to make the shelf small again.
-test('the shelf deepens past the old ceiling without swallowing the sidebar', async ({ page }) => {
+// Dragged to the floor of the window: the shelf goes as deep as the column
+// allows, and the sections it shares that column with keep a usable slice —
+// squeezed out, they take the grip off the bottom edge with them and there is no
+// way left to make the shelf small again.
+test('the shelf deepens to the floor without swallowing the sidebar', async ({ page }) => {
test.setTimeout(120_000)
await seedTags(page, 108)
- await expect(page.locator('.usb-tag')).toHaveCount(RESTING_CHIPS)
+ const resting = await page.locator('.usb-tag').count()
const floor = await page.evaluate(() => window.innerHeight - 4)
await dragShelf(page, () => floor)
- expect(await page.locator('.usb-tag').count()).toBeGreaterThan(OLD_CEILING_CHIPS)
+ expect(await page.locator('.usb-tag').count()).toBeGreaterThan(resting * 3)
const aside = await page.locator('.saved').boundingBox()
const list = await page.locator('.usb-scroll').boundingBox()
@@ -157,3 +178,72 @@ test('the shelf deepens past the old ceiling without swallowing the sidebar', as
expect(await bottomGap(page)).toBeGreaterThanOrEqual(BOTTOM_GAP)
await expectHandleOnTheSeam(page)
})
+
+// A drag was the only way to ask, and the seam carried no keyboard at all.
+test('double-click opens the shelf to every tag, and rests it again', async ({ page }) => {
+ test.setTimeout(120_000)
+ await seedTags(page, 24)
+ const resting = await shelfHeight(page)
+ await expect(page.locator('.usb-more')).toBeVisible()
+
+ await page.locator('.usb-shelf-grip').dblclick()
+ await expect(page.locator('.usb-more')).toHaveCount(0)
+ expect(await shelfHeight(page)).toBeGreaterThan(resting)
+ await expectNothingClipped(page)
+
+ await page.locator('.usb-shelf-grip').dblclick()
+ await expect(page.locator('.usb-more')).toBeVisible()
+ expect(await shelfHeight(page)).toBeCloseTo(resting, 0)
+})
+
+// LONG names, deliberately: wide chips do not fill the rows the stored height
+// pays for, so the box hugs its chips and renders shorter than the setting. With
+// the keys anchored on what was rendered, ↓ wrote a shallower depth and moved
+// nothing, and the ↑ after it fell two rows. Short `tag-NN` names fill their
+// rows, which is why the first version of this test never saw it.
+test('the arrow keys step a row at a time even when the chips do not fill it', async ({ page }) => {
+ test.setTimeout(120_000)
+ const names = Array.from({ length: 30 }, (_, i) => `platform-migration-phase-${i + 10}`)
+ for (let at = 0; at < names.length; at += PER_SNIPPET) {
+ await seedSnippet(page, `Wide farm ${at}`, names.slice(at, at + PER_SNIPPET))
+ }
+
+ const depth = async () =>
+ Number(
+ (await page.evaluate(() => document.querySelector('.usb-shelf').style.maxHeight)).replace(
+ 'px',
+ ''
+ )
+ )
+ const step = await rowStep(page)
+
+ // The first press snaps the resting default onto the measured row grid, so it
+ // is the presses AFTER that which must each be worth exactly one row.
+ await page.locator('.usb-shelf-grip').focus()
+ await page.keyboard.press('ArrowDown')
+ const onGrid = await depth()
+
+ await page.keyboard.press('ArrowDown')
+ const deeper = await depth()
+ expect(deeper - onGrid).toBeGreaterThan(step - 1)
+ expect(deeper - onGrid).toBeLessThan(step + 1)
+
+ // And back to exactly where it was — the ↑ that used to fall two rows.
+ await page.keyboard.press('ArrowUp')
+ expect(await depth()).toBe(onGrid)
+})
+
+test('the arrow keys deepen the shelf a row at a time', async ({ page }) => {
+ test.setTimeout(120_000)
+ await seedTags(page, 24)
+ const step = await rowStep(page)
+ const before = await shelfHeight(page)
+
+ await page.locator('.usb-shelf-grip').focus()
+ await page.keyboard.press('ArrowDown')
+ await expect.poll(() => shelfHeight(page)).toBeGreaterThan(before + step - GAP - 1)
+ expect(await shelfHeight(page)).toBeLessThan(before + step + GAP)
+
+ await page.keyboard.press('ArrowUp')
+ await expect.poll(() => shelfHeight(page)).toBeCloseTo(before, 0)
+})
diff --git a/scripts/lib/legacySize.mjs b/scripts/lib/legacySize.mjs
index 3040f809..bcf51c6f 100644
--- a/scripts/lib/legacySize.mjs
+++ b/scripts/lib/legacySize.mjs
@@ -9,13 +9,12 @@ export const LEGACY_SIZE = {
'src/main/hashDiff.js': { file: 335 },
'src/main/lineIndexCore.js': { fn: 67 },
'src/main/menu.js': { fn: 79 },
- 'src/main/quickLook.js': { file: 253 },
'src/main/sealing.js': { file: 388 },
'src/main/share.js': { fn: 205, file: 510 },
'src/main/window.js': { fn: 69 },
'src/renderer/src/composables/useMatrixRain.js': { fn: 96 },
'src/renderer/src/composables/useMonacoInput.js': { fn: 96 },
- 'src/renderer/src/composables/useQuickLook.js': { fn: 211 },
+ 'src/renderer/src/composables/useQuickLook.js': { fn: 202 },
'src/renderer/src/composables/useQuickLookKeys.js': { fn: 97 },
'src/renderer/src/composables/useSnippetDraft.js': { fn: 182 },
'src/renderer/src/composables/useSnippetPreview.js': { fn: 73 },
@@ -26,7 +25,7 @@ export const LEGACY_SIZE = {
'src/renderer/src/menus.js': { fn: 87 },
'src/renderer/src/monaco-mermaid.js': { fn: 94 },
'src/renderer/src/stores/diffStore.js': { file: 747 },
- 'src/renderer/src/stores/snippetStore.js': { file: 470 },
+ 'src/renderer/src/stores/snippetStore.js': { file: 448 },
'src/renderer/src/stores/tabsStore.js': { file: 323 },
'src/renderer/src/stores/vaultStore.js': { file: 362 },
'src/renderer/src/utils/structuralDiff.js': { file: 279 },
diff --git a/scripts/theme-sweep.mjs b/scripts/theme-sweep.mjs
index f388a8bb..62dbdc82 100644
--- a/scripts/theme-sweep.mjs
+++ b/scripts/theme-sweep.mjs
@@ -681,6 +681,47 @@ const SURFACES = [
'empty-section prompt': ['.empty-cta p', DIM]
}
},
+ {
+ name: 'snippet-colour',
+ // A colour the reader picked, painted across the row, plus the popover that
+ // sets it. Both are composed rather than tokenised — the row's ground is the
+ // picked ink re-lightened to --tag-l and mixed 16% into the panel, so no
+ // static scan can know what it lands on. The row's meta ink is the reason
+ // this surface exists: at --text-dim it falls under the 3.0 floor on nord
+ // (2.97) and sepia (2.72), which is why the coloured row steps to
+ // --text-hint and why that step is measured here on all twenty.
+ open: async (page) => {
+ const row = page.locator('.snippets-section .row').first()
+ await row.waitFor()
+ await row.click({ button: 'right' })
+ await page.locator('.rcm').waitFor()
+ // Teal: the darkest of the six once re-lightened on a light ground, so the
+ // wash it makes is the worst case the name has to stay readable on.
+ await page.locator('.rcm [data-color="teal"]').click()
+ await page.locator('.rcm').waitFor({ state: 'detached' })
+ await page.locator('.row[data-color]').first().waitFor()
+ // Reopened, so the menu's own inks are on screen to measure with it.
+ await row.click({ button: 'right' })
+ await page.locator('.rcm').waitFor()
+ await page.locator('.rcm [data-color="rose"]').hover()
+ },
+ // Put the library back the way the rest of the walk expects it.
+ close: async (page) => {
+ await page.locator('.rcm [data-color="none"]').click()
+ await page.locator('.rcm').waitFor({ state: 'detached' })
+ },
+ probes: {
+ 'name on a coloured row': ['.row[data-color] .nm', TEXT],
+ 'age on a coloured row': ['.row[data-color] .when', DIM],
+ 'menu label': ['.rcm-hd', DIM],
+ // The card the popover floats on, and the keyline that separates it from
+ // whatever it covers.
+ 'menu keyline': ['.rcm', SEPARATOR, 'border'],
+ // The cursor's mark: an accent keyline is the only thing saying which
+ // target a keypress would take.
+ 'cursor keyline': ['.rcm-sw.hot', DIM, 'border']
+ }
+ },
{
name: 'waiting-for-second',
// One side loaded. Dashed rim, tag, name and hint all sit on the pane
diff --git a/src/main/quickLook.js b/src/main/quickLook.js
index 42c809b2..a3ef8594 100644
--- a/src/main/quickLook.js
+++ b/src/main/quickLook.js
@@ -11,12 +11,14 @@ import { join } from 'path'
import { DEV_URL } from './env'
import { readSettings } from './appData'
import { appendLog } from './logger'
-import { allowsWhileFocused } from './quickLookFocus'
+import { allowMainFocus, hideLauncher, isCapturingShortcut } from './quickLookFocus'
import { defaultQuickLookShortcut } from '../shared/shortcuts'
import {
+ keepOnScreen,
placeWindow,
displayForPoint,
launcherDiagnostics,
+ launcherSize,
launcherSpaceBehavior,
needsMainWindow,
storedAccelerator,
@@ -28,6 +30,9 @@ import {
const DEFAULT_ACCELERATOR = defaultQuickLookShortcut(process.platform)
let win = null
+// Which size the card is at. Survives a hide, so re-summoning mid-draft brings
+// back the window the draft was being written in.
+let mode = 'default'
// The accelerator currently registered, so a change unregisters exactly it.
let currentAccelerator = null
// Injected by registerQuickLook, so this module never owns the main window.
@@ -36,10 +41,14 @@ let createMain = null
function build() {
const w = new BrowserWindow({
// The card is flat and fills the window, so these are its exact dimensions.
- width: 692,
- height: 452,
+ ...launcherSize(mode),
show: false,
frame: false,
+ // The launcher is summoned OVER another app, so it is showing while Diff Bro
+ // is not the active app. Without this macOS spends the first click on
+ // activating the window and never delivers it — every row and button in the
+ // card ignored the first press.
+ acceptsFirstMouse: true,
transparent: true,
backgroundColor: '#00000000',
resizable: false,
@@ -103,7 +112,7 @@ export const isLauncher = (candidate) => !!candidate && candidate === win
// Also ends the un-focusable state hideLauncher leaves behind, without which
// focus() below is a silent no-op.
export function ensureMainWindow() {
- allowMainFocus()
+ allowMainFocus(mainWindow())
const main = needsMainWindow(BrowserWindow.getAllWindows(), win) ? createMain?.() : mainWindow()
if (!main) return null
if (main.isMinimized()) main.restore()
@@ -134,77 +143,61 @@ function logDiag(event, displays, cursor, launcher) {
})
}
-// Repositioned every summon onto the display holding the pointer.
+// Repositioned every summon onto the display holding the pointer, at whatever
+// size its current job asks for.
function reveal() {
const w = ensure()
const point = screen.getCursorScreenPoint()
const displays = screen.getAllDisplays()
const display = displayForPoint(displays, point) ?? screen.getPrimaryDisplay()
- const { x, y } = placeWindow(display.workArea, w.getBounds())
- logDiag('reveal', displays, point, {
- x,
- y,
- width: w.getBounds().width,
- height: w.getBounds().height
- })
- w.setPosition(x, y)
+ const size = launcherSize(mode, display.workArea)
+ const { x, y } = placeWindow(display.workArea, size)
+ logDiag('reveal', displays, point, { x, y, ...size })
+ resize(w, { x, y, ...size })
// Separate Pinia instance — the renderer re-reads its library and refocuses.
w.webContents.send('quicklook:show')
w.show()
w.focus()
}
-// On macOS, hiding the launcher while DiffBro is active raises the app's next
-// window (the main window) to the front. app.hide() would prevent that but hides
-// the main window too, which the next summon then drags back up. So instead make
-// the main window briefly non-focusable: the OS can't make it key, the app
-// deactivates back to the previous app, and the main window stays put.
-// The un-focusable window above is a timed state, so anything that wants to
-// focus the main window must end it first — otherwise focus() is a silent no-op
-// and the window surfaces without keyboard focus.
-let refocusTimer = null
-export function allowMainFocus() {
- if (refocusTimer) {
- clearTimeout(refocusTimer)
- refocusTimer = null
- }
- const main = mainWindow()
- if (main && !main.isDestroyed()) main.setFocusable(true)
+function dismiss() {
+ logDiag('hide', screen.getAllDisplays(), screen.getCursorScreenPoint(), win?.getBounds())
+ hideLauncher({ launcher: win, main: mainWindow(), platform: process.platform })
}
-function hideLauncher() {
- logDiag('hide', screen.getAllDisplays(), screen.getCursorScreenPoint(), win?.getBounds())
- if (process.platform !== 'darwin') {
- win?.hide()
- return
- }
- const main = mainWindow()
- if (main && main.isVisible() && !main.isMinimized()) {
- main.setFocusable(false)
- win?.hide()
- if (refocusTimer) clearTimeout(refocusTimer)
- refocusTimer = setTimeout(() => {
- refocusTimer = null
- if (!main.isDestroyed()) main.setFocusable(true)
- }, 300)
- } else {
- win?.hide()
- }
+// The renderer names the JOB and main decides what it is worth in pixels. It
+// grows from where the card already IS: re-centring slides the row under the
+// pointer away mid-click.
+function setMode(next) {
+ mode = next === 'compose' ? 'compose' : 'default'
+ const w = ensure()
+ const at = w.getBounds()
+ const display = screen.getDisplayMatching(at)
+ const size = launcherSize(mode, display.workArea)
+ resize(w, { ...keepOnScreen(at, size, display.workArea), ...size })
+}
+
+// The card is not the reader's to resize, but `resizable: false` is documented
+// to lock the min/max size on Windows — where it would silently refuse this and
+// leave the compose card at the resting size. Lifted for the write only; macOS
+// and Linux do not need it and are unaffected.
+function resize(w, bounds) {
+ w.setResizable(true)
+ w.setBounds(bounds)
+ w.setResizable(false)
}
export function toggleQuickLook() {
const w = ensure()
- if (w.isVisible()) hideLauncher()
+ if (w.isVisible()) dismiss()
else reveal()
}
-// Global-shortcut entry point only (menu/IPC toggle unconditionally): skip
-// revealing when you're already in the app, e.g. capturing a new shortcut in
-// Settings — the keypress would otherwise pop the launcher over the field.
+// Global-shortcut entry point only (menu/IPC toggle unconditionally): the one
+// thing it must not interrupt is the Settings capture field, where the chord
+// being typed IS the input.
function onShortcut() {
- const w = ensure()
- const main = BrowserWindow.getAllWindows().find((x) => x !== w)
- if (!w.isVisible() && main?.isFocused() && !allowsWhileFocused()) return
+ if (!ensure().isVisible() && isCapturingShortcut()) return
toggleQuickLook()
}
@@ -239,7 +232,8 @@ export function registerQuickLook(openMainWindow) {
if (!res.ok) registerShortcut(DEFAULT_ACCELERATOR)
ipcMain.handle('quicklook:toggle', () => toggleQuickLook())
ipcMain.handle('quicklook:setShortcut', (_e, accel) => registerShortcut(accel))
- ipcMain.handle('quicklook:hide', () => hideLauncher())
+ ipcMain.handle('quicklook:hide', () => dismiss())
+ ipcMain.handle('quicklook:mode', (_e, next) => setMode(next))
ipcMain.handle('quicklook:open', (_e, payload) => openInMain(payload))
// Process-wide OS registrations — release on quit.
app.on('will-quit', () => globalShortcut.unregisterAll())
@@ -250,4 +244,6 @@ export function registerQuickLook(openMainWindow) {
export function destroyQuickLook() {
if (win && !win.isDestroyed()) win.destroy()
win = null
+ // The draft went with the renderer, so the card it was written in goes too.
+ mode = 'default'
}
diff --git a/src/main/quickLookCore.js b/src/main/quickLookCore.js
index e33abb49..3609dd92 100644
--- a/src/main/quickLookCore.js
+++ b/src/main/quickLookCore.js
@@ -8,6 +8,50 @@ const TOP_FRACTION = 0.28
const clamp = (v, lo, hi) => Math.max(lo, Math.min(v, Math.max(lo, hi)))
+// What the launcher is worth at each job. A Map rather than an object literal
+// because the mode arrives from the renderer — `SIZES['constructor']` on a
+// literal is a truthy hit with no width.
+const SIZES = new Map([
+ ['default', { width: 692, height: 452 }],
+ ['compose', { width: 960, height: 720 }]
+])
+// Air left around the card, so a size never runs edge to edge on a small display.
+const SCREEN_MARGIN = 80
+const SMALLEST = { width: 420, height: 320 }
+
+/**
+ * The window size for a launcher mode, never larger than the display holding it.
+ * @param {string} mode 'default' | 'compose'; anything else resolves to default
+ * @param {{width:number,height:number}} [workArea]
+ * @returns {{width:number,height:number}}
+ */
+export function launcherSize(mode, workArea) {
+ const want = SIZES.get(mode) ?? SIZES.get('default')
+ const fit = (px, available, floor) =>
+ Number.isFinite(available) ? Math.min(px, Math.max(floor, available - SCREEN_MARGIN)) : px
+ return {
+ width: Math.round(fit(want.width, workArea?.width, SMALLEST.width)),
+ height: Math.round(fit(want.height, workArea?.height, SMALLEST.height))
+ }
+}
+
+/**
+ * The top-left a card KEEPS when it changes size: growing for a job moves the
+ * window's edges, never its origin. Re-centring instead slides every row
+ * sideways out from under the pointer that just clicked one — which is what a
+ * click on Edit did, twice, before this existed.
+ * @param {{x:number,y:number}} origin where the card already is
+ * @param {{width:number,height:number}} size what it is about to be
+ * @param {{x:number,y:number,width:number,height:number}} workArea
+ * @returns {{x:number,y:number}} the origin, pulled back inside the work area
+ */
+export function keepOnScreen(origin, size, workArea) {
+ return {
+ x: Math.round(clamp(origin.x, workArea.x, workArea.x + workArea.width - size.width)),
+ y: Math.round(clamp(origin.y, workArea.y, workArea.y + workArea.height - size.height))
+ }
+}
+
/**
* @param {{x:number,y:number,width:number,height:number}} workArea
* @param {{width:number,height:number}} win
diff --git a/src/main/quickLookFocus.js b/src/main/quickLookFocus.js
index 5dc94a32..d47fde51 100644
--- a/src/main/quickLookFocus.js
+++ b/src/main/quickLookFocus.js
@@ -1,19 +1,65 @@
import { ipcMain } from 'electron'
-// The global shortcut deliberately does nothing while Diff Bro itself is
-// focused: capturing a new binding in Settings would otherwise pop the launcher
-// over the field being typed into. The onboarding tour asks the user to press
-// the chord WHILE the app is in front, so it lifts the guard for that one step
-// and puts it straight back.
-let allowed = false
+// Focus policy around the launcher, kept out of the window glue (quickLook.js)
+// so both halves unit-test with plain objects: what silences the global
+// shortcut, and the macOS dance that stops a dismissal dragging the main window
+// to the front.
-/** @returns {boolean} whether a focused main window should still summon it */
-export const allowsWhileFocused = () => allowed
+// The one thing the global shortcut must not interrupt: the Settings capture
+// field, where the chord being typed IS the input. Everywhere else it summons —
+// including with Diff Bro itself in front, which is where a launcher is most
+// often wanted and where it used to do nothing at all.
+let capturing = false
+
+/** @returns {boolean} whether Settings is waiting for a chord right now */
+export const isCapturingShortcut = () => capturing
export function registerQuickLookFocusIpc() {
// Boolean only: this flips an existing, user-triggered behaviour on and off.
// It names no window, no path and no key.
- ipcMain.on('quicklook:allow-while-focused', (_e, value) => {
- allowed = value === true
+ ipcMain.on('quicklook:capturing-shortcut', (e, value) => {
+ capturing = value === true
+ // MAIN owns the lifetime, not the window that armed it: a renderer closed or
+ // reloaded mid-capture never sends the false, and the chord would stay dead
+ // for the rest of the session with no window left to explain why.
+ if (capturing) e.sender.once('destroyed', () => (capturing = false))
})
}
+
+// On macOS, hiding the launcher while Diff Bro is active raises the app's next
+// window (the main window) to the front. app.hide() would prevent that but hides
+// the main window too, which the next summon then drags back up. So instead make
+// the main window briefly non-focusable: the OS can't make it key, the app
+// deactivates back to the previous app, and the main window stays put.
+const REFOCUS_MS = 300
+let refocusTimer = null
+
+// The un-focusable window above is a timed state, so anything that wants to
+// focus the main window must end it first — otherwise focus() is a silent no-op
+// and the window surfaces without keyboard focus.
+export function allowMainFocus(main) {
+ if (refocusTimer) {
+ clearTimeout(refocusTimer)
+ refocusTimer = null
+ }
+ if (main && !main.isDestroyed()) main.setFocusable(true)
+}
+
+/**
+ * Hide the launcher without handing the front to the main window.
+ * @param {object} o
+ * @param {{hide: () => void}|null} o.launcher
+ * @param {object|null} o.main
+ * @param {string} o.platform
+ */
+export function hideLauncher({ launcher, main, platform }) {
+ const parks = platform === 'darwin' && !!main && main.isVisible() && !main.isMinimized()
+ if (parks) main.setFocusable(false)
+ launcher?.hide()
+ if (!parks) return
+ if (refocusTimer) clearTimeout(refocusTimer)
+ refocusTimer = setTimeout(() => {
+ refocusTimer = null
+ if (!main.isDestroyed()) main.setFocusable(true)
+ }, REFOCUS_MS)
+}
diff --git a/src/preload/index.js b/src/preload/index.js
index 193c8a20..29b09647 100644
--- a/src/preload/index.js
+++ b/src/preload/index.js
@@ -173,9 +173,12 @@ contextBridge.exposeInMainWorld('api', {
// renderer receives that pick. Shared preload → both windows see these, but
// each only wires the half it uses.
quickLookToggle: () => ipcRenderer.invoke('quicklook:toggle'),
- // The tour's "press it now" step, which runs with this window in front.
- quickLookAllowWhileFocused: (on) =>
- ipcRenderer.send('quicklook:allow-while-focused', on === true),
+ // Settings → Shortcuts, while its capture field is armed: the chord being
+ // typed is the input, so the shortcut must not answer it.
+ quickLookCapturingShortcut: (on) => ipcRenderer.send('quicklook:capturing-shortcut', on === true),
+ // Launcher window: which job the card is doing, so main can size it. A NAME,
+ // never dimensions — the renderer does not get to decide how big a window is.
+ quickLookMode: (mode) => ipcRenderer.invoke('quicklook:mode', String(mode)),
// Settings → Shortcuts: apply a new summon accelerator live. Resolves to
// { ok } or { ok:false, error } ('unavailable' / 'invalid').
quickLookSetShortcut: (accel) => ipcRenderer.invoke('quicklook:setShortcut', accel),
diff --git a/src/renderer/src/components/AppDialogs.vue b/src/renderer/src/components/AppDialogs.vue
index 9de7df29..69f52911 100644
--- a/src/renderer/src/components/AppDialogs.vue
+++ b/src/renderer/src/components/AppDialogs.vue
@@ -15,6 +15,7 @@ import TextToolDialog from './TextToolDialog.vue'
import EncryptDecryptDialog from './EncryptDecryptDialog.vue'
import SnippetEditorDialog from './SnippetEditorDialog.vue'
import SnippetHistoryDialog from './SnippetHistoryDialog.vue'
+import SnippetColorMenu from './SnippetColorMenu.vue'
import SnippetPassphraseDialog from './SnippetPassphraseDialog.vue'
import SnippetDeleteDialog from './SnippetDeleteDialog.vue'
import SnippetFillDialog from './SnippetFillDialog.vue'
@@ -75,6 +76,7 @@ const conflicts = useConflictsStore()
+
diff --git a/src/renderer/src/components/JiraRendered.vue b/src/renderer/src/components/JiraRendered.vue
index 11950735..4766baf4 100644
--- a/src/renderer/src/components/JiraRendered.vue
+++ b/src/renderer/src/components/JiraRendered.vue
@@ -5,6 +5,7 @@ import { computed } from 'vue'
import { parseJira } from '../utils/jiraRender'
import { arrayOfShape } from '../utils/props'
import JiraInline from './JiraInline.vue'
+import JiraTable from './JiraTable.vue'
const props = defineProps({
content: { type: String, default: null },
@@ -30,11 +31,13 @@ const indent = (depth) => (depth > 1 ? { marginInlineStart: `${(depth - 1) * 16}
-
+
+
+
diff --git a/src/renderer/src/components/QuickLookCompose.vue b/src/renderer/src/components/QuickLookCompose.vue
index 16363aca..16ab4730 100644
--- a/src/renderer/src/components/QuickLookCompose.vue
+++ b/src/renderer/src/components/QuickLookCompose.vue
@@ -5,6 +5,11 @@
import { computed, nextTick, toRef } from 'vue'
import { useCaretBackOut } from '../composables/useCaretBackOut'
import { useHighlightedInput } from '../composables/useHighlightedInput'
+import { useFormatToolbar } from '../composables/useFormatToolbar'
+import { useTextareaMarkup } from '../composables/useTextareaMarkup'
+import FormatToolbar from './FormatToolbar.vue'
+import JiraRendered from './JiraRendered.vue'
+import MarkdownRendered from './MarkdownRendered.vue'
import SnippetNameField from './SnippetNameField.vue'
import { SNIPPET_LANGUAGES } from '../utils/detectLanguage'
import { t } from '../i18n'
@@ -24,6 +29,14 @@ const emit = defineEmits(['save', 'cancel'])
const { textareaEl, overlayEl, lines, isPlain, onScroll, onCompositionStart, onCompositionEnd } =
useHighlightedInput({ text: body, language: toRef(props, 'resolvedLanguage') })
+// The two languages that are written IN markup get the same row of buttons the
+// main editor gives them. Driven off the RESOLVED language, so a body detected
+// as Markdown on Auto gets it without anyone naming the language.
+const isMarkdown = computed(() => props.resolvedLanguage === 'markdown')
+const hasMarkup = computed(() => isMarkdown.value || props.resolvedLanguage === 'jira')
+const { applySelectionEdit } = useTextareaMarkup(textareaEl, body)
+const { actions: markupActions, applyAction } = useFormatToolbar({ isMarkdown, applySelectionEdit })
+
// Auto carries what it resolved to, so the picker doubles as the readout and
// there is no second chip saying the same thing.
const labelOf = (id) =>
@@ -68,34 +81,44 @@ defineExpose({ focus: () => nextTick(() => textareaEl.value?.focus()) })
+
+
-
-
-
{{ span.text }}
-
+
+
+
+
{{ span.text }}
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/QuickLookResults.vue b/src/renderer/src/components/QuickLookResults.vue
index 445f76d5..e1054f73 100644
--- a/src/renderer/src/components/QuickLookResults.vue
+++ b/src/renderer/src/components/QuickLookResults.vue
@@ -4,6 +4,7 @@
import { ref, watch } from 'vue'
import { useSnippetStore } from '../stores/snippetStore'
import { languageMonogram } from '../utils/languageMonogram'
+import { rowColorHex, rowColorId } from '../utils/rowColor'
import { isMac } from '../keys'
import AppIcon from './AppIcon.vue'
@@ -14,7 +15,20 @@ const props = defineProps({
toolsOpen: { type: Boolean, default: false }
})
const selected = defineModel('selected', { type: Number, required: true })
-defineEmits(['choose'])
+const emit = defineEmits(['choose'])
+
+// One click does exactly what ↵ does on that row. Selecting without activating
+// left a tool, the Tools header and the create row looking dead to the pointer —
+// nothing on them says a second click is what opens them.
+//
+// The REPEAT is dropped: double-click was the activator until this changed, so
+// it is the gesture most likely to arrive out of habit, and acting on both
+// clicks opened the Tools section and shut it again in one gesture.
+function activate(i, e) {
+ if (e.detail > 1) return
+ selected.value = i
+ emit('choose', i)
+}
const store = useSnippetStore()
const listEl = ref(null)
@@ -24,6 +38,11 @@ const mono = (lang) => languageMonogram(lang)
const monoStyle = (it) => ({ '--fam': it.kind === 'snippet' ? mono(it.lang).family : '' })
const monoText = (it) => (it.kind === 'snippet' ? mono(it.lang).label : '')
const tagStyle = (it) => ({ '--tc': store.colorOf(it.tags?.[0]) })
+// The snippet's own colour, painted the way the sidebar row paints it.
+const colorStyle = (it) => {
+ const hex = rowColorHex(it.color)
+ return hex ? { '--snip-color': hex } : null
+}
const rowIcon = () => (props.toolsOpen ? 'chevron-down' : 'chevron-right')
const NEW_KEY = isMac ? '⌘N' : 'Ctrl+N'
const KIND_LABEL = {
@@ -59,8 +78,9 @@ const resClass = (i) => ({
:key="it.kind + it.id"
class="ql-res"
:class="resClass(i)"
- @click="selected = i"
- @dblclick="$emit('choose', i)"
+ :data-color="rowColorId(it.color)"
+ :style="colorStyle(it)"
+ @click="activate(i, $event)"
>
diff --git a/src/renderer/src/components/SavedDiffRow.vue b/src/renderer/src/components/SavedDiffRow.vue
index 4ba55844..6c618a5c 100644
--- a/src/renderer/src/components/SavedDiffRow.vue
+++ b/src/renderer/src/components/SavedDiffRow.vue
@@ -19,6 +19,7 @@ import { shaped } from '../utils/props'
import AppIcon from './AppIcon.vue'
import { useShareStore } from '../features/share'
import { useUiStore } from '../stores/uiStore'
+import { useRowTags } from '../composables/useRowTags'
const props = defineProps({
/** @type {import('vue').PropType} */
@@ -41,7 +42,10 @@ const SOON_MS = 15 * 60_000
const isNew = computed(() => ui.lastCreatedRowId === props.entry.id)
const formatKey = computed(() => rowFormatKey(props.entry))
const mono = computed(() => languageMonogram(formatKey.value))
-const shownTags = computed(() => rowTags(props.entry))
+const { shown: shownTags, showing: showTags } = useRowTags(() => rowTags(props.entry))
+// With the words off the second line has nothing left to say unless the diff
+// came from someone — an empty one holds its own height open.
+const hasSubline = computed(() => showTags.value || !!props.entry.from)
const tagColor = (t) => snippets.colorOf(t) || 'var(--text-dim)'
const title = computed(() => {
@@ -128,7 +132,7 @@ function onDragStart(e) {
is information a ten-second-old snippet's age is not. -->
{{ $t('newRow.badge') }}
-
+ {{ $t('savedDiffRow.from', { who: entry.from }) }}
diff --git a/src/renderer/src/components/SavedDiffs.vue b/src/renderer/src/components/SavedDiffs.vue
index acc55a9d..f1d34e0a 100644
--- a/src/renderer/src/components/SavedDiffs.vue
+++ b/src/renderer/src/components/SavedDiffs.vue
@@ -1,10 +1,11 @@
+
+
+
+
+
{{ $t('rowColor.menuLabel') }}
+
+
+
+
+
+
+
+
+
diff --git a/src/renderer/src/components/SnippetRow.vue b/src/renderer/src/components/SnippetRow.vue
index 72b8b2a8..936a771c 100644
--- a/src/renderer/src/components/SnippetRow.vue
+++ b/src/renderer/src/components/SnippetRow.vue
@@ -3,11 +3,7 @@
// with a language monogram so the row is recognizable before the name is read.
import { computed } from 'vue'
import { useSnippetStore, languageOf } from '../stores/snippetStore'
-import { useDiffStore } from '../stores/diffStore'
-import { useCopyFeedback } from '../composables/useCopyFeedback'
import { languageMonogram } from '../utils/languageMonogram'
-import { firstClaudeUrl } from '../utils/detectLanguage'
-import { parseTemplateVars } from '../utils/templateVars'
import { ago } from '../utils/relativeTime'
import { shaped } from '../utils/props'
import AppIcon from './AppIcon.vue'
@@ -15,7 +11,9 @@ import { SECRET_NOTICE, isSecret } from '../utils/secretSnippet'
import { useSnippetDrag } from '../composables/useSnippetDrag'
import { injectRowReorder } from '../composables/useRowReorder'
import { useUiStore } from '../stores/uiStore'
-import { t } from '../i18n'
+import { useRowTags } from '../composables/useRowTags'
+import { useSnippetRowActions } from '../composables/useSnippetRowActions'
+import { rowColorHex, rowColorId } from '../utils/rowColor'
const props = defineProps({
/** @type {import('vue').PropType} */
@@ -27,10 +25,10 @@ const props = defineProps({
})
const store = useSnippetStore()
-
const ui = useUiStore()
-const diff = useDiffStore()
-const { copied, flash } = useCopyFeedback()
+const { copied, copySnippet, viewDiagram, openUrl, openLink } = useSnippetRowActions(
+ () => props.entry
+)
const { startDrag } = useSnippetDrag()
const reorder = injectRowReorder()
const rowState = computed(() => ({
@@ -47,40 +45,11 @@ const isClaude = computed(() => lang.value === 'claude')
const isUrl = computed(() => lang.value === 'url')
// Drop the tag that just restates the monogram (the auto format tag), so the
// tag word carries information the type anchor doesn't already.
-const shownTags = computed(() => props.entry.tags.filter((t) => t !== lang.value))
-
-async function copySnippet(id) {
- const content = await store.load(id)
- if (content == null) return
- // A Claude prompt with placeholders routes through the fill dialog first.
- if (isClaude.value && parseTemplateVars(content).length) {
- store.pendingFill = { name: props.entry.name, content }
- return
- }
- const res = await window.api.copyText(content)
- if (!res?.ok) return diff.showNotice(t('snippetRow.copyFailed'))
- flash()
-}
-async function viewDiagram(entry) {
- const code = await store.load(entry.id)
- if (code != null) ui.openMermaid(entry.name, code)
-}
-// Opening is gated by the main-process claude.ai allowlist; this only offers a
-// candidate URL from the snippet.
-// A URL snippet is the whole link; main fences the scheme and confirms.
-async function openUrl() {
- const content = await store.load(props.entry.id)
- const url = content?.trim().split(/\s+/)[0]
- const res = url ? await window.api.openLink(url) : { error: 'empty' }
- if (res?.error) diff.showNotice(t('snippetRow.noOpenableLink'))
-}
-
-async function openLink() {
- const content = await store.load(props.entry.id)
- const url = content != null ? firstClaudeUrl(content) : null
- if (url) await window.api.openClaudeLink(url)
- else diff.showNotice(t('snippetRow.noClaudeLink'))
-}
+const { shown: shownTags } = useRowTags(() => props.entry.tags.filter((t) => t !== lang.value))
+const colorHex = computed(() => rowColorHex(props.entry.color))
+// Right-click is the way in — the gesture that already opens a tag's manage
+// popover from the shelf.
+const openColorMenu = (e) => (ui.rowColorMenu = { id: props.entry.id, x: e.clientX, y: e.clientY })
// Hovering the name previews the snippet — not the whole row, which made the
// card appear while you were only reaching for the row's buttons.
@@ -103,9 +72,12 @@ function onDragStart(e) {
:class="[rowState, reorder.classFor(group, index)]"
:data-new-row="isNew ? entry.id : null"
:data-tour="isDiagram ? 'snippet-diagram' : null"
+ :data-color="rowColorId(entry.color)"
+ :style="colorHex ? { '--snip-color': colorHex } : null"
data-preview-anchor
:draggable="!isSecret(entry)"
@dragstart="onDragStart($event)"
+ @contextmenu.prevent="openColorMenu($event)"
v-on="reorder.handlersFor(group, index)"
>
@@ -168,7 +140,7 @@ function onDragStart(e) {
class="row-btn"
:data-tip="$t('snippetRow.diagram')"
:aria-label="$t('snippetRow.viewDiagram')"
- @click="viewDiagram(entry)"
+ @click="viewDiagram()"
>
@@ -194,7 +166,7 @@ function onDragStart(e) {
class="row-btn"
:data-tip="$t('common.copy')"
:aria-label="$t('snippetRow.copyToClipboard')"
- @click="copySnippet(entry.id)"
+ @click="copySnippet()"
>
diff --git a/src/renderer/src/components/styles/JiraRendered.css b/src/renderer/src/components/styles/JiraRendered.css
index b0fa08a4..a95ec8f1 100644
--- a/src/renderer/src/components/styles/JiraRendered.css
+++ b/src/renderer/src/components/styles/JiraRendered.css
@@ -45,6 +45,22 @@ h6.ji-h {
.ji-list li {
margin: 0.15em 0;
}
+/* A task list is a list of STATES, so it drops the bullet the state replaces
+ and lines the boxes up with the text beside them. The box is inert — this is a
+ preview of a snippet, not a checklist to tick. */
+.ji-list.ji-tasks {
+ padding-inline-start: 0.2em;
+ list-style: none;
+}
+.ji-task {
+ display: flex;
+ align-items: baseline;
+ gap: var(--space-2);
+}
+.ji-task input {
+ accent-color: var(--accent);
+ margin: 0;
+}
.ji-quote {
margin: 0.5em 0;
padding: 0.2em 0.9em;
diff --git a/src/renderer/src/components/styles/JiraTable.css b/src/renderer/src/components/styles/JiraTable.css
new file mode 100644
index 00000000..5ca5adc1
--- /dev/null
+++ b/src/renderer/src/components/styles/JiraTable.css
@@ -0,0 +1,26 @@
+/* A rendered markup table (JiraTable.vue). Wide ones scroll INSIDE their own
+ box: the preview pane is half a card, and a table that pushed it sideways
+ would take the raw editor beside it with it. */
+.ji-table-wrap {
+ overflow-x: auto;
+ margin: var(--space-2) 0;
+}
+.ji-table {
+ border-collapse: collapse;
+ font-size: var(--font-sm);
+ color: var(--text);
+}
+.ji-table th,
+.ji-table td {
+ padding: var(--space-1) var(--space-3);
+ border: 1px solid var(--border);
+ text-align: start;
+ vertical-align: top;
+}
+/* The header band is the one that must read as a header on all twenty themes:
+ --bg-elevated is the raised-surface role, so it steps away from the pane's
+ ground by construction rather than by a per-theme grey. */
+.ji-table th {
+ background: var(--bg-elevated);
+ font-weight: 600;
+}
diff --git a/src/renderer/src/components/styles/QuickLook.css b/src/renderer/src/components/styles/QuickLook.css
index 1b1d9194..a23d210e 100644
--- a/src/renderer/src/components/styles/QuickLook.css
+++ b/src/renderer/src/components/styles/QuickLook.css
@@ -120,6 +120,26 @@
background: color-mix(in srgb, var(--accent) 16%, transparent);
border-color: color-mix(in srgb, var(--accent) 50%, transparent);
}
+/* A snippet's own colour, painted as the sidebar row paints it (SnippetRow.css).
+ Selection stays legible on top: the accent keyline returns and the wash steps
+ to its hover strength, so neither cue hides the other. */
+.ql-res[data-color] {
+ --snip-ink: oklch(from var(--snip-color) var(--tag-l) c h);
+ background: color-mix(in srgb, var(--snip-ink) 16%, transparent);
+ box-shadow: inset 3px 0 0 var(--snip-ink);
+}
+.ql-res[data-color]:hover,
+.ql-res[data-color].sel {
+ background: color-mix(in srgb, var(--snip-ink) 26%, transparent);
+}
+.ql-res[data-color].sel {
+ border-color: color-mix(in srgb, var(--accent) 50%, transparent);
+}
+.ql-res[data-color] .ql-kind,
+.ql-res[data-color] .tag-word {
+ color: var(--text-hint);
+}
+
/* Seam above the collapsible Tools header: a horizontal divider so it reads as a
separate section while staying one keyboard list. */
.ql-res.group-start {
diff --git a/src/renderer/src/components/styles/QuickLookCompose.css b/src/renderer/src/components/styles/QuickLookCompose.css
index 455d093c..0ca4d9b2 100644
--- a/src/renderer/src/components/styles/QuickLookCompose.css
+++ b/src/renderer/src/components/styles/QuickLookCompose.css
@@ -81,6 +81,29 @@
outline: none;
}
+/* Markup is written to be READ as its rendered form, so a Jira or Markdown body
+ is drawn beside the syntax rather than behind a toggle — it follows the typing
+ because it renders straight off the bound text. Half each: the raw side keeps
+ its own scroll, and neither is ever narrower than the other. */
+.ql-compose-split {
+ display: flex;
+ flex: 1;
+ gap: var(--space-2);
+ min-height: 0;
+}
+.ql-compose-split > * {
+ flex: 1;
+ min-width: 0;
+}
+.ql-compose-preview {
+ overflow: auto;
+ padding: var(--space-2) var(--space-3);
+ border: 1px solid var(--border);
+ border-radius: var(--radius-sm);
+ background: var(--bg);
+ font-size: var(--font-sm);
+}
+
/* The two layers of the highlighted body. EVERY property that decides where a
glyph lands is set on both through the same token — change one and you must
change the other, or the colour drifts off the characters it belongs to.
diff --git a/src/renderer/src/components/styles/SavedDiffs.css b/src/renderer/src/components/styles/SavedDiffs.css
index e3a2499f..c981238b 100644
--- a/src/renderer/src/components/styles/SavedDiffs.css
+++ b/src/renderer/src/components/styles/SavedDiffs.css
@@ -100,15 +100,24 @@
/* The one part of the strip that gives way when the shelf is deeper than the
column can hold: the chips scroll rather than pushing the sections out. */
.usb-tags {
- display: flex;
- flex-wrap: wrap;
- gap: 4px;
min-height: 0;
overflow-y: auto;
margin-top: var(--space-1);
margin-bottom: var(--space-1);
}
+/* The dragged depth, in pixels, applied as a max-height (SavedDiffs.vue) — so a
+ shelf with little in it still hugs its chips. Clipped rather than scrolled:
+ useTagShelf measures what fits and hands the rest to the "+N more" picker, so
+ nothing is ever hidden behind an edge. The seam above stays OUT of this box,
+ which is what makes the stored number exactly the chip rows. */
+.usb-shelf {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 4px;
+ overflow: hidden;
+}
+
/* A dim separator LINE between the section filters and the tags — a rule of its
own with equal air either side, not a border welded to the chip row. Inset at
both ends so it reads as a hairline between two groups rather than a divider
@@ -122,10 +131,12 @@
panel everywhere, and 15% of it is a whisper rather than a rule. */
.usb-tags::before {
content: '';
- flex: 0 0 100%;
+ display: block;
box-sizing: border-box;
height: 1px;
- margin-block: var(--space-3);
+ /* The chip box below carries no gap above its first row, so the air under the
+ line is the whole distance — the flex row-gap used to add to this. */
+ margin-block: var(--space-3) calc(var(--space-3) + 4px);
padding-inline: var(--space-4);
background: color-mix(in srgb, var(--text) 15%, transparent);
background-clip: content-box;
@@ -169,9 +180,16 @@
touch-action: none;
}
.usb-shelf-grip:hover,
+.usb-shelf-grip:focus-visible,
.usb-shelf-grip.resizing {
background: color-mix(in srgb, var(--accent) 45%, transparent);
}
+/* Keyboard focus lights the seam itself rather than ringing a 5px band: ↑/↓
+ deepen it by a row, Home rests it, End opens it to every tag. An outline
+ offset outward here would trace the whole panel edge. */
+.usb-shelf-grip:focus-visible {
+ outline: none;
+}
/* The overflow keeps the chip shape — it stands in for tags, so it belongs in
their row — but carries no colour dot, because it is not one. */
diff --git a/src/renderer/src/components/styles/SnippetColorMenu.css b/src/renderer/src/components/styles/SnippetColorMenu.css
new file mode 100644
index 00000000..e39abf5c
--- /dev/null
+++ b/src/renderer/src/components/styles/SnippetColorMenu.css
@@ -0,0 +1,92 @@
+/* The snippet row's colour popover (SnippetColorMenu.vue). Shaped like the tab
+ strip's context menu — same backdrop, same raised card at the pointer — but
+ its body is one row of targets rather than a list of labels. */
+.rcm-backdrop {
+ position: fixed;
+ inset: 0;
+ z-index: 60;
+}
+.rcm {
+ position: fixed;
+ min-width: 0;
+ padding: var(--space-2) var(--space-3) var(--space-3);
+ background: var(--bg-raised);
+ border: 1px solid var(--border);
+ border-radius: var(--radius);
+ box-shadow: var(--shadow-3);
+}
+.rcm:focus-visible {
+ outline: none;
+}
+.rcm-hd {
+ font-size: var(--font-2xs);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--text-dim);
+ margin-bottom: var(--space-2);
+}
+.rcm-row {
+ display: flex;
+ gap: var(--space-2);
+}
+
+/* A target is the size of a control, and the dot inside it is the colour. The
+ box is what the cursor lands on, so it — not the dot — carries the hot state:
+ growing the dot would read as a different colour rather than a selection. */
+.rcm-sw {
+ position: relative;
+ width: var(--control-h-sm);
+ height: var(--control-h-sm);
+ padding: 0;
+ display: grid;
+ place-items: center;
+ background: none;
+ border: 1px solid transparent;
+ border-radius: var(--radius-sm);
+ cursor: pointer;
+}
+.rcm-sw.hot {
+ border-color: var(--accent);
+ background: color-mix(in srgb, var(--accent) 12%, transparent);
+}
+.rcm-sw:focus-visible {
+ outline: 2px solid var(--focus-ring);
+ outline-offset: 1px;
+}
+/* The same lightness normalisation the row and the tag ink use, so the swatch is
+ the colour the row will actually wear on this theme rather than the raw hex. */
+.rcm-dot {
+ width: 14px;
+ height: 14px;
+ border-radius: var(--radius-pill);
+ background: oklch(from var(--snip-color) var(--tag-l) c h);
+}
+/* No colour: an empty ring with a stroke through it, never a filled swatch. */
+.rcm-none .rcm-dot {
+ position: relative;
+ background: none;
+ border: 1px solid var(--border);
+ overflow: hidden;
+}
+.rcm-none .rcm-dot::after {
+ content: '';
+ position: absolute;
+ inset: 0;
+ margin: auto;
+ width: 1px;
+ height: 20px;
+ background: var(--border);
+ transform: rotate(45deg);
+}
+/* The applied one is ticked, so the current choice survives being read without
+ colour — the ring alone would be one cue, and it would be the colour itself. */
+.rcm-tick {
+ position: absolute;
+ width: 10px;
+ height: 10px;
+ right: -1px;
+ bottom: -1px;
+ color: var(--text);
+ background: var(--bg-raised);
+ border-radius: var(--radius-pill);
+}
diff --git a/src/renderer/src/components/styles/SnippetRow.css b/src/renderer/src/components/styles/SnippetRow.css
index 246dc53e..45b492cf 100644
--- a/src/renderer/src/components/styles/SnippetRow.css
+++ b/src/renderer/src/components/styles/SnippetRow.css
@@ -23,6 +23,30 @@
.row.favorite:hover {
background: color-mix(in srgb, var(--favorite) 22%, transparent);
}
+
+/* A colour the reader picked (SnippetColorMenu.vue). The hex is never painted:
+ it is re-lightened to --tag-l, the normalisation a tag's ink already goes
+ through, then mixed into the panel — which is what holds the name at 4.5:1 on
+ all twenty themes (worst: nord, 6.40).
+
+ AFTER .row.favorite on purpose: equal specificity, and a coloured favourite
+ wears the colour while the filled gold star carries the state. */
+.row[data-color] {
+ --snip-ink: oklch(from var(--snip-color) var(--tag-l) c h);
+ background: color-mix(in srgb, var(--snip-ink) 16%, transparent);
+ box-shadow: inset 3px 0 0 var(--snip-ink);
+}
+.row[data-color]:hover {
+ background: color-mix(in srgb, var(--snip-ink) 26%, transparent);
+}
+/* --text-dim loses the 3:1 non-text floor on a washed row in nord (2.97) and
+ sepia (2.72); --text-hint is the ink that exists for exactly this and clears
+ 4.5:1 on all twenty. */
+.row[data-color] .when,
+.row[data-color] .tag-word,
+.row[data-color] .varchip {
+ color: var(--text-hint);
+}
/* "Copied" acknowledgement over the action buttons. Anchored to the row, not
.rowacts, so it survives the pointer leaving (which hides .rowacts). */
.copied-flash {
diff --git a/src/renderer/src/composables/useQuickLook.js b/src/renderer/src/composables/useQuickLook.js
index 3209350b..a07d6e71 100644
--- a/src/renderer/src/composables/useQuickLook.js
+++ b/src/renderer/src/composables/useQuickLook.js
@@ -15,10 +15,6 @@ import { t } from '../i18n'
// convert tools only; diffs stay in the main window.
const MAX_PREVIEW_CHARS = 4000
-// A textarea handles any text, so the language is not the gate — only tooling
-// the launcher lacks is. A secret is refused because its guarantee is that the
-// contents never render where they can be read.
-const NEEDS_MAIN_WINDOW = new Set(['mermaid', 'claude'])
export function useQuickLook() {
const snippets = useSnippetStore()
@@ -103,7 +99,7 @@ export function useQuickLook() {
// add() pushes onto this window's own entries, so the list updates without a
// reload; the main window picks it up on its next reload().
- const compose = useQuickLookCompose({ snippets })
+ const compose = useQuickLookCompose({ snippets, current })
const startCompose = () => compose.open({ name: query.value.trim() })
function choose(i) {
@@ -134,8 +130,14 @@ export function useQuickLook() {
// Separate Pinia instance from the main window — re-read the snippet library on
// each summon to reflect changes made there.
+ //
+ // A summon is otherwise a fresh start, EXCEPT where the card holds work: a
+ // draft mid-sentence or a tool panel mid-conversion. Two presses of the chord
+ // in a row is how the launcher is dismissed and brought back, and it used to
+ // throw both away.
function refresh() {
snippets.reload()
+ if (compose.dirty.value || convertTool.value) return
query.value = ''
selected.value = 0
snippetText.value = ''
@@ -147,21 +149,6 @@ export function useQuickLook() {
compose.cancel()
}
- const canEditInline = computed(() => {
- const it = current.value
- return it?.kind === 'snippet' && !it.secret && !NEEDS_MAIN_WINDOW.has(it.lang)
- })
- // Loads the FULL body — snippetText here is truncated for preview, and saving
- // that back would amputate anything past MAX_PREVIEW_CHARS.
- async function editCurrent() {
- const it = current.value
- if (!canEditInline.value) return
- const text = await snippets.load(it.id)
- if (typeof text !== 'string') return
- const { id, name, tags, language } = it
- compose.open({ id, name, tags, content: text, language })
- }
-
// Rows are [glyph, message id]; utils/ cannot translate, so t() runs here.
const footHints = computed(() => {
const rows = compose.composing.value
@@ -227,7 +214,7 @@ export function useQuickLook() {
lastTool,
exitConvert,
compose,
- canEditInline,
- editCurrent
+ canEditInline: compose.canEdit,
+ editCurrent: compose.editCurrent
}
}
diff --git a/src/renderer/src/composables/useQuickLookCompose.js b/src/renderer/src/composables/useQuickLookCompose.js
index e9b408e6..c6d98cd1 100644
--- a/src/renderer/src/composables/useQuickLookCompose.js
+++ b/src/renderer/src/composables/useQuickLookCompose.js
@@ -8,6 +8,10 @@ import { effectiveLanguage } from '../utils/snippetState'
const AUTO = 'auto'
const DETECT_IDLE_MS = 250
+// A textarea handles any text, so the language is not the gate — only tooling
+// the launcher lacks is. A secret is refused because its guarantee is that the
+// contents never render where they can be read.
+const NEEDS_MAIN_WINDOW = new Set(['mermaid', 'claude'])
// `settled` is the body as of the last pause, and detection reads THAT rather
// than the live body — so one debounce serves both the guess and the switch
@@ -38,10 +42,19 @@ function useDraftFields() {
const body = ref('')
const editingId = ref(null)
const editingTags = ref([])
- // The opened content, so save() can tell an edit from a rename — the store
- // records a history version only when the content itself moved.
+ // What was opened, so save() can tell an edit from a rename — the store
+ // records a history version only when the content itself moved — and so a
+ // re-summon can tell a draft with work in it from an untouched one.
const baseline = ref('')
+ const baselineName = ref('')
const editing = computed(() => editingId.value !== null)
+ // A rename is not a new version, so the two are asked separately: the store
+ // records history off `contentChanged`, a re-summon keeps the card off `touched`.
+ const contentChanged = computed(() => body.value !== baseline.value)
+ const touched = computed(() => contentChanged.value || name.value !== baselineName.value)
+ // The body is what makes a snippet; an empty name falls back to a placeholder
+ // in the store, so it is never what blocks a save.
+ const hasBody = computed(() => body.value.trim() !== '')
// `content` must be the FULL body: the launcher's preview is truncated, and
// saving that back would amputate the snippet.
const fill = ({ id = null, tags = [], name: as = '', content = '' }) => {
@@ -50,10 +63,40 @@ function useDraftFields() {
name.value = as
body.value = content
baseline.value = content
+ baselineName.value = as
+ }
+ return { name, body, editingId, editingTags, editing, contentChanged, touched, hasBody, fill }
+}
+
+/**
+ * Reopening the selected row in the compose panel, which is what makes the
+ * launcher an editor rather than a reader.
+ * @param {object} o
+ * @param {{ load: (id: string) => Promise }} o.snippets
+ * @param {import('vue').Ref