From d8c6b8ab9126a07d4511135e424ee065bcf6b1be Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Tue, 25 Aug 2026 09:47:09 +0100 Subject: [PATCH 01/11] Add source-links: link inline code mentions of files and exports to GitHub Vite plugin emits virtual:source-symbols (repo URL, build commit, source files, exported definition lines); create_source_links turns matching spans into pinned GitHub links via a Svelte attachment. Exact, unambiguous names only. --- package.json | 31 +++-- readme.md | 25 ++++ src/lib/source-links/index.ts | 87 +++++++++++++ src/lib/source-links/virtual.d.ts | 8 ++ src/lib/source-links/vite-plugin.ts | 85 ++++++++++++ src/routes/+layout.svelte | 2 + src/site/source-links.ts | 5 + tests/package-smoke/vite.config.ts | 3 +- tests/playwright/source-links.test.ts | 31 +++++ tests/vitest/source-links.test.ts | 180 ++++++++++++++++++++++++++ vite.config.ts | 3 +- 11 files changed, 448 insertions(+), 12 deletions(-) create mode 100644 src/lib/source-links/index.ts create mode 100644 src/lib/source-links/virtual.d.ts create mode 100644 src/lib/source-links/vite-plugin.ts create mode 100644 src/site/source-links.ts create mode 100644 tests/playwright/source-links.test.ts create mode 100644 tests/vitest/source-links.test.ts diff --git a/package.json b/package.json index 1dc7b1a5..6b64e492 100644 --- a/package.json +++ b/package.json @@ -84,6 +84,17 @@ "types": "./dist/live-examples/create-highlighter.d.ts", "default": "./dist/live-examples/create-highlighter.js" }, + "./source-links": { + "types": "./dist/source-links/index.d.ts", + "default": "./dist/source-links/index.js" + }, + "./source-links/vite-plugin": { + "types": "./dist/source-links/vite-plugin.d.ts", + "default": "./dist/source-links/vite-plugin.js" + }, + "./source-links/virtual": { + "types": "./dist/source-links/virtual.d.ts" + }, "./clipboard": { "types": "./dist/clipboard.svelte.d.ts", "default": "./dist/clipboard.svelte.js" @@ -154,8 +165,8 @@ "@iconify-json/file-icons": "^1.2.2", "@iconify-json/gis": "^1.2.5", "@iconify-json/iconoir": "^1.2.11", - "@iconify-json/lucide": "^1.2.123", - "@iconify-json/material-symbols": "^1.2.88", + "@iconify-json/lucide": "^1.2.126", + "@iconify-json/material-symbols": "^1.2.89", "@iconify-json/mdi": "^1.2.3", "@iconify-json/octicon": "^1.2.32", "@iconify-json/ri": "^1.2.10", @@ -165,21 +176,21 @@ "@iconify/utils": "^3.1.4", "@playwright/test": "^1.62.1", "@sveltejs/adapter-static": "^3.0.10", - "@sveltejs/kit": "^2.70.2", + "@sveltejs/kit": "^2.70.3", "@sveltejs/package": "2.5.8", "@sveltejs/vite-plugin-svelte": "^7.3.0", - "@types/node": "^26.2.0", + "@types/node": "^26.3.0", "@typescript/native-preview": "7.0.0-dev.20260707.2", - "@vitest/coverage-v8": "4.1.10", + "@vitest/coverage-v8": "4.1.11", "@wooorm/starry-night": "^3.10.0", - "happy-dom": "^20.11.2", + "happy-dom": "^20.11.6", "katex": "^0.18.4", "mdsvex": "^0.12.8", "pagefind": "^1.5.2", - "svelte": "^5.56.8", - "typescript": "^6.0.3", - "vite": "^8.2.1", - "vite-plus": "0.2.9" + "svelte": "^5.56.10", + "typescript": "^7.0.2", + "vite": "^8.2.2", + "vite-plus": "0.3.0" }, "peerDependencies": { "@wooorm/starry-night": "^3.0.0", diff --git a/readme.md b/readme.md index 9e3d070d..5bc1e530 100644 --- a/readme.md +++ b/readme.md @@ -157,6 +157,9 @@ import { heading_anchors } from 'svelte-widgets/heading-anchors' | `/live-examples` | mdsvex live-example transform, Vite plugin and highlighter | | `/live-examples/create-highlighter` | Lightweight custom grammar highlighter factory | | `/print` | Element printing | +| `/source-links` | Link inline code mentions of your source to GitHub | +| `/source-links/vite-plugin` | Vite plugin emitting the file/export index those links use | +| `/source-links/virtual` | Types for the plugin's `virtual:source-symbols` module | | `/storage` | Non-throwing localStorage, persisted choices and MRU lists | | `/text-search` | Text ranges, highlighting and search-jump helpers | | `/theme` | Headless light/dark/system state | @@ -212,6 +215,28 @@ Import `katex/dist/katex.min.css` once in the app so the generated markup is sty See [src/lib/live-examples/readme.md](https://github.com/janosh/svelte-widgets/blob/-/src/lib/live-examples/readme.md) for optional live-example helpers. +Docs that mention source files or exports in inline code (`` `Footer` ``, `` `make_config` ``) can link them to the GitHub line they live on, pinned to the commit the site was built from. Add the plugin to `vite.config.ts`, reference its virtual-module types from `src/app.d.ts` and attach the linker to the element that wraps your pages: + +```ts +// vite.config.ts +import source_links from 'svelte-widgets/source-links/vite-plugin' +export default { plugins: [sveltekit(), source_links()] } // indexes src/lib by default + +// src/app.d.ts +/// + +// src/site/source-links.ts +import { create_source_links } from 'svelte-widgets/source-links' +import * as source_symbols from 'virtual:source-symbols' +export const { link_source_mentions, source_href } = create_source_links(source_symbols) +``` + +```svelte +
{@render children()}
+``` + +Only exact, unambiguous names link: a file name or bare component name (`Footer`, `utils.ts`) points at the file, an exported definition (`make_config`) at its line, and names defined in several files (`index.ts`) or that aren't source (`label`) are left alone. `source_href(name)` gives the same URL for use in your own markup. + ## 🆕   Changelog [View the changelog](changelog.md). diff --git a/src/lib/source-links/index.ts b/src/lib/source-links/index.ts new file mode 100644 index 00000000..a1b6b07d --- /dev/null +++ b/src/lib/source-links/index.ts @@ -0,0 +1,87 @@ +// Turns inline code spans that name a project's source into GitHub links, so docs never +// hand-maintain source URLs: a file (`Footer`, `Footer.svelte`, `utils.ts`) links to the +// file, an exported definition (`make_config`, `ThemeMode`) to its line. Only exact, +// unambiguous names match: `index.ts` exists in many folders and `label` is a prop, so +// neither links. Links pin the commit the site was built from so line numbers stay right. +// The data comes from `virtual:source-symbols`, emitted by ./vite-plugin.ts. + +export type SourceSymbols = { + repo: string // repository URL, e.g. https://github.com/janosh/svelte-widgets + ref: string // commit the site was built from (`main` when built outside git) + files: string[] // repo-relative source paths, e.g. /src/lib/Footer.svelte + symbols: Record // exported name -> `/path.ts#L12` +} + +export type SourceLinks = { + // repo path (with `#Lline` for definitions) behind `name`, undefined when unknown or ambiguous + source_location: (name: string) => string | undefined + source_href: (name: string) => string | undefined + // Svelte attachment: links every matching under `root`, now and as content arrives + link_source_mentions: (root: HTMLElement) => () => void +} + +export function create_source_links({ + repo, + ref, + files, + symbols, +}: SourceSymbols): SourceLinks { + // name -> repo path, or null once two files claim the name + const location_by_name = new Map() + const register = (name: string, location: string): void => { + location_by_name.set(name, location_by_name.has(name) ? null : location) + } + for (const path of files) { + const basename = path.split(`/`).pop() ?? path + register(basename, path) + // Components are referred to by bare name far more often than by file name + if (basename.endsWith(`.svelte`)) register(basename.slice(0, -`.svelte`.length), path) + } + for (const [name, location] of Object.entries(symbols)) { + if (!location_by_name.has(name)) location_by_name.set(name, location) + } + + const source_location = (name: string): string | undefined => + location_by_name.get(name.trim()) ?? undefined + const href_of = (location: string): string => `${repo}/blob/${ref}${location}` + const source_href = (name: string): string | undefined => { + const location = source_location(name) + return location && href_of(location) + } + + // Client-side navigation swaps the page inside the same root, so a MutationObserver keeps + // linking. The anchor goes inside the code element and adopts its existing child nodes, so + // Svelte's references to those nodes (dynamic text, block boundaries) stay valid. + const link_source_mentions = (root: HTMLElement): (() => void) => { + const linked = new WeakSet() + const scan = (): void => { + for (const code of root.querySelectorAll(`code`)) { + if (linked.has(code) || code.closest(`a, pre`)) continue + const location = source_location(code.textContent ?? ``) + if (!location) continue + linked.add(code) + const link = document.createElement(`a`) + link.href = href_of(location) + link.target = `_blank` + link.rel = `noopener` + link.title = `Source: ${location.replace(/^\//, ``)}` + link.append(...code.childNodes) + code.append(link) + } + } + let frame = 0 + const schedule = (): void => { + cancelAnimationFrame(frame) + frame = requestAnimationFrame(scan) + } + schedule() + const observer = new MutationObserver(schedule) + observer.observe(root, { childList: true, subtree: true }) + return () => { + observer.disconnect() + cancelAnimationFrame(frame) + } + } + + return { source_location, source_href, link_source_mentions } +} diff --git a/src/lib/source-links/virtual.d.ts b/src/lib/source-links/virtual.d.ts new file mode 100644 index 00000000..780f67c8 --- /dev/null +++ b/src/lib/source-links/virtual.d.ts @@ -0,0 +1,8 @@ +// Emitted by the `svelte-widgets/source-links/vite-plugin` Vite plugin. Reference this file +// from your app.d.ts: /// +declare module 'virtual:source-symbols' { + export const repo: string + export const ref: string + export const files: string[] + export const symbols: Record +} diff --git a/src/lib/source-links/vite-plugin.ts b/src/lib/source-links/vite-plugin.ts new file mode 100644 index 00000000..b2238952 --- /dev/null +++ b/src/lib/source-links/vite-plugin.ts @@ -0,0 +1,85 @@ +// Vite plugin behind `virtual:source-symbols`: the repository URL, the commit the site is +// built from, every source file under `dir` and the line of every exported definition, so +// docs can turn inline code mentions into pinned GitHub links (see ./index.ts). +import { execSync } from 'node:child_process' +import { readdirSync, readFileSync } from 'node:fs' +import { join, relative } from 'node:path' +import process from 'node:process' +import type { Plugin } from 'vite' + +export const SOURCE_SYMBOLS_MODULE_ID = `virtual:source-symbols` +const RESOLVED_ID = `\0${SOURCE_SYMBOLS_MODULE_ID}` +// .svelte and .ts sources, minus tests and declaration files +const is_source_file = (name: string): boolean => + /\.(?:svelte|ts)$/.test(name) && !/\.(?:test|d)\.ts$/.test(name) +const EXPORT_DEFINITION_RE = + /^export (?:async function|function|abstract class|class|const|let|interface|type|enum) (?[A-Za-z_$][\w$]*)/ + +// `repository` as package.json allows it: a URL string, a `{ url }` record (often with a +// `git+` prefix and `.git` suffix) or a GitHub shorthand like `user/repo` +export const repository_url = (repository: unknown): string => { + const raw = + typeof repository === `string` ? repository : (repository as { url?: unknown })?.url + if (typeof raw !== `string` || !raw) { + throw new Error( + `package.json needs a "repository" so source links know where to point`, + ) + } + if (/^[\w.-]+\/[\w.-]+$/.test(raw)) return `https://github.com/${raw}` + return raw.replace(/^git\+/, ``).replace(/\.git$/, ``) +} + +export type SourceLinksPluginOptions = { + root?: string // project root holding package.json and `dir`; defaults to the cwd + dir?: string // source directory to index, relative to root +} + +export default function source_links({ + root = process.cwd(), + dir = `src/lib`, +}: SourceLinksPluginOptions = {}): Plugin { + return { + name: `vite-plugin-source-links`, + resolveId: (id) => (id === SOURCE_SYMBOLS_MODULE_ID ? RESOLVED_ID : null), + load(id) { + if (id !== RESOLVED_ID) return null + const pkg = JSON.parse(readFileSync(join(root, `package.json`), `utf-8`)) as { + repository?: unknown + } + const files: string[] = [] + // name -> location, or null once two files define the same name (ambiguous: never linked) + const symbols = new Map() + for (const entry of readdirSync(join(root, dir), { + recursive: true, + withFileTypes: true, + })) { + if (!entry.isFile() || !is_source_file(entry.name)) continue + const file = join(entry.parentPath, entry.name) + const path = `/${relative(root, file).replaceAll(`\\`, `/`)}` + files.push(path) + if (!file.endsWith(`.ts`)) continue + for (const [idx, line] of readFileSync(file, `utf-8`).split(`\n`).entries()) { + const name = EXPORT_DEFINITION_RE.exec(line)?.groups?.name + if (name) symbols.set(name, symbols.has(name) ? null : `${path}#L${idx + 1}`) + } + } + let ref = `main` + try { + ref = execSync(`git rev-parse HEAD`, { cwd: root, stdio: `pipe` }) + .toString() + .trim() + } catch { + // no git (tarball build): links follow main instead of a pinned commit + } + const unique = Object.fromEntries( + [...symbols].filter(([, location]) => location !== null), + ) + return [ + `export const repo = ${JSON.stringify(repository_url(pkg.repository))}`, + `export const ref = ${JSON.stringify(ref)}`, + `export const files = ${JSON.stringify(files.toSorted())}`, + `export const symbols = ${JSON.stringify(unique)}`, + ].join(`\n`) + }, + } +} diff --git a/src/routes/+layout.svelte b/src/routes/+layout.svelte index 43e3f0ef..226a17e1 100644 --- a/src/routes/+layout.svelte +++ b/src/routes/+layout.svelte @@ -10,6 +10,7 @@ import { apply_theme_mode, resolve_theme_mode } from '$lib/theme.svelte' import { repository } from '$root/package.json' import { DemoNav, Footer } from '$site' + import { link_source_mentions } from '$site/source-links' import favicon from '$site/favicon.svg' import type { Snippet } from 'svelte' // eslint-disable-next-line import/no-unassigned-import -- global route styles @@ -102,6 +103,7 @@ css_class: `page-search-match`, duration_ms: 8000, })} + {@attach link_source_mentions} > {@render children?.()} diff --git a/src/site/source-links.ts b/src/site/source-links.ts new file mode 100644 index 00000000..6169d6cd --- /dev/null +++ b/src/site/source-links.ts @@ -0,0 +1,5 @@ +// Inline code mentions of this repo's files and exports link to their GitHub source +import { create_source_links } from '$lib/source-links' +import * as source_symbols from 'virtual:source-symbols' + +export const { link_source_mentions, source_href } = create_source_links(source_symbols) diff --git a/tests/package-smoke/vite.config.ts b/tests/package-smoke/vite.config.ts index 2e7232ea..b6fd11d3 100644 --- a/tests/package-smoke/vite.config.ts +++ b/tests/package-smoke/vite.config.ts @@ -2,10 +2,11 @@ import { svelte } from '@sveltejs/vite-plugin-svelte' // self-reference resolves via package.json exports to the packaged dist build, // proving the live-examples subpath (incl. its vite parseSync import) is consumable import { vite_plugin as live_examples } from 'svelte-widgets/live-examples' +import source_links from 'svelte-widgets/source-links/vite-plugin' import { defineConfig } from 'vite' export default defineConfig({ - plugins: [svelte(), ...live_examples()], + plugins: [svelte(), ...live_examples(), source_links()], resolve: { conditions: [`svelte`, `browser`], }, diff --git a/tests/playwright/source-links.test.ts b/tests/playwright/source-links.test.ts new file mode 100644 index 00000000..56768fe7 --- /dev/null +++ b/tests/playwright/source-links.test.ts @@ -0,0 +1,31 @@ +import { expect, test } from '@playwright/test' + +const SOURCE = `https://github.com/janosh/svelte-widgets/blob/` + +test(`inline code mentions of components link to their source, on load and after navigation`, async ({ + page, +}) => { + await page.goto(`/`) + // the readme's component table names every component in inline code + const multi_select = page.locator(`code > a`, { hasText: `MultiSelect` }).first() + await expect(multi_select).toHaveAttribute( + `href`, + /^.*\/blob\/[0-9a-f]{40}\/src\/lib\/MultiSelect\.svelte$/, + ) + await expect(multi_select).toHaveAttribute(`target`, `_blank`) + expect(await multi_select.getAttribute(`href`)).toContain(SOURCE) + // code inside pre blocks and existing links stays untouched + expect(await page.locator(`pre code a`).count()).toBe(0) + expect(await page.locator(`a code a`).count()).toBe(0) + + // client-side navigation swaps the page inside the same wrapper: new mentions link too + await page.goto(`/popover`) + await expect( + page.locator(`code > a[href$="/src/lib/Popover.svelte"]`).first(), + ).toBeVisible() + await page.locator(`header nav a[href="/dialogs"]`).first().click() + await expect(page).toHaveURL(/\/dialogs$/) + await expect( + page.locator(`code > a[href$="/src/lib/ConfirmDialog.svelte"]`).first(), + ).toBeVisible() +}) diff --git a/tests/vitest/source-links.test.ts b/tests/vitest/source-links.test.ts new file mode 100644 index 00000000..a9bcb8db --- /dev/null +++ b/tests/vitest/source-links.test.ts @@ -0,0 +1,180 @@ +import { create_source_links, type SourceSymbols } from '$lib/source-links' +import source_links, { + repository_url, + SOURCE_SYMBOLS_MODULE_ID, +} from '$lib/source-links/vite-plugin' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vite-plus/test' + +// Run the plugin's resolve + load hooks and evaluate the emitted module +const load_symbols = (root?: string): SourceSymbols => { + const plugin = source_links(root ? { root } : {}) + const resolve = plugin.resolveId as (id: string) => string | null + const load = plugin.load as (id: string) => string | null + expect(resolve(`some-other-module`)).toBeNull() + const resolved = resolve(SOURCE_SYMBOLS_MODULE_ID) + if (!resolved) throw new Error(`virtual module not resolved`) + expect(load(`some-other-module`)).toBeNull() + const code = load(resolved) + if (!code) throw new Error(`virtual module not loaded`) + // one `export const name = ` per line + return Object.fromEntries( + code.split(`\n`).map((line) => { + const match = /^export const (?\w+) = (?.*)$/.exec(line) + if (!match?.groups) throw new Error(`unexpected line in virtual module: ${line}`) + return [match.groups.name, JSON.parse(match.groups.json) as unknown] + }), + ) as SourceSymbols +} + +describe(`source_links vite plugin`, () => { + it(`indexes this repo's source files and exported definitions, pinned to the build commit`, () => { + const { repo, ref, files, symbols } = load_symbols() + expect(repo).toBe(`https://github.com/janosh/svelte-widgets`) + expect(ref).toMatch(/^(?:[0-9a-f]{40}|main)$/) + expect(files).toContain(`/src/lib/Footer.svelte`) + expect(files).toContain(`/src/lib/source-links/vite-plugin.ts`) + expect(files).toEqual(files.toSorted()) + expect(files.some((file) => /\.(?:test|d)\.ts$/.test(file))).toBe(false) + expect(symbols.make_config).toMatch(/^\/src\/lib\/vite-config\.ts#L\d+$/) + expect(symbols.create_source_links).toMatch( + /^\/src\/lib\/source-links\/index\.ts#L\d+$/, + ) + // types and interfaces count as definitions too + expect(symbols.SourceSymbols).toMatch(/^\/src\/lib\/source-links\/index\.ts#L\d+$/) + }) + + it(`drops names exported from more than one file and non-source files`, () => { + const root = mkdtempSync(join(tmpdir(), `source-links-`)) + try { + mkdirSync(join(root, `src/lib/nested`), { recursive: true }) + writeFileSync( + join(root, `package.json`), + JSON.stringify({ repository: { url: `git+https://github.com/user/repo.git` } }), + ) + writeFileSync( + join(root, `src/lib/a.ts`), + `export const shared = 1\nexport function only_a() {}\n`, + ) + writeFileSync( + join(root, `src/lib/nested/b.ts`), + `\nexport type shared = number\nexport class OnlyB {}\n`, + ) + writeFileSync(join(root, `src/lib/a.test.ts`), `export const from_test = 1\n`) + writeFileSync(join(root, `src/lib/types.d.ts`), `export const from_dts = 1\n`) + writeFileSync(join(root, `src/lib/Widget.svelte`), `
`) + writeFileSync(join(root, `src/lib/notes.md`), `# not source`) + const { repo, ref, files, symbols } = load_symbols(root) + expect(repo).toBe(`https://github.com/user/repo`) + expect(ref).toBe(`main`) // no git repository in a temp dir + expect(files).toEqual([ + `/src/lib/Widget.svelte`, + `/src/lib/a.ts`, + `/src/lib/nested/b.ts`, + ]) + expect(symbols).toEqual({ + only_a: `/src/lib/a.ts#L2`, + OnlyB: `/src/lib/nested/b.ts#L3`, + }) + } finally { + rmSync(root, { recursive: true, force: true }) + } + }) + + it.each([ + [`https://github.com/user/repo`, `https://github.com/user/repo`], + [`git+https://github.com/user/repo.git`, `https://github.com/user/repo`], + [{ url: `git+ssh://git@github.com/user/repo.git` }, `ssh://git@github.com/user/repo`], + [`user/repo`, `https://github.com/user/repo`], + ])(`normalizes repository %j to %s`, (repository, expected) => { + expect(repository_url(repository)).toBe(expected) + }) + + it.each([undefined, ``, { url: 42 }])( + `rejects a missing repository (%j)`, + (repository) => { + expect(() => repository_url(repository)).toThrow(`"repository"`) + }, + ) +}) + +describe(`create_source_links`, () => { + const data: SourceSymbols = { + repo: `https://github.com/user/repo`, + ref: `abc123`, + files: [ + `/src/lib/Footer.svelte`, + `/src/lib/utils.ts`, + `/src/lib/index.ts`, + `/src/lib/nested/index.ts`, + ], + symbols: { + make_config: `/src/lib/vite-config.ts#L7`, + Footer: `/src/lib/other.ts#L1`, + }, + } + const { source_location, source_href, link_source_mentions } = create_source_links(data) + + afterEach(() => { + document.body.innerHTML = `` + }) + + it.each([ + [`Footer`, `/src/lib/Footer.svelte`], // component by bare name beats a same-named export + [`Footer.svelte`, `/src/lib/Footer.svelte`], + [` utils.ts `, `/src/lib/utils.ts`], + [`make_config`, `/src/lib/vite-config.ts#L7`], + [`index.ts`, undefined], // one per folder: ambiguous + [`label`, undefined], // a prop, not a file + [`utils`, undefined], // only .svelte files link by bare name + ])(`resolves %j to %j`, (name, location) => { + expect(source_location(name)).toBe(location) + expect(source_href(name)).toBe( + location && `https://github.com/user/repo/blob/abc123${location}`, + ) + }) + + it(`links matching code spans in place, skipping pre blocks and existing links`, async () => { + const root = document.createElement(`main`) + root.innerHTML = + `

Footer and label

` + + `
Footer
Footer` + document.body.append(root) + const detach = link_source_mentions(root) + await new Promise(requestAnimationFrame) + const links = root.querySelectorAll(`code > a`) + expect(links).toHaveLength(1) + expect(links[0].getAttribute(`href`)).toBe( + `https://github.com/user/repo/blob/abc123/src/lib/Footer.svelte`, + ) + expect(links[0].getAttribute(`title`)).toBe(`Source: src/lib/Footer.svelte`) + expect(links[0].textContent).toBe(`Footer`) + // late-arriving content is picked up too, and a detached root is left alone + root.insertAdjacentHTML(`beforeend`, `

make_config

`) + await new Promise(requestAnimationFrame) + await new Promise(requestAnimationFrame) + expect(root.querySelectorAll(`code > a`)[1]?.getAttribute(`href`)).toMatch( + /vite-config\.ts#L7$/, + ) + detach() + root.insertAdjacentHTML(`beforeend`, `

utils.ts

`) + await new Promise(requestAnimationFrame) + await new Promise(requestAnimationFrame) + expect(root.querySelectorAll(`code > a`)).toHaveLength(2) + }) + + it(`does not re-link a span once its anchor exists, even after a rescan`, async () => { + const root = document.createElement(`main`) + root.innerHTML = `

Footer

` + document.body.append(root) + const detach = link_source_mentions(root) + await new Promise(requestAnimationFrame) + root.append(document.createElement(`span`)) + await new Promise(requestAnimationFrame) + await new Promise(requestAnimationFrame) + expect(root.querySelectorAll(`a`)).toHaveLength(1) + detach() + }) +}) diff --git a/vite.config.ts b/vite.config.ts index 9a0d5a1f..be084fca 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,6 +1,7 @@ import { sveltekit } from '@sveltejs/kit/vite' import { generate_icons } from './scripts/generate-icons.ts' import live_examples from './src/lib/live-examples/vite-plugin.ts' +import source_links from './src/lib/source-links/vite-plugin.ts' import { make_config } from './src/lib/vite-config.ts' await generate_icons() @@ -17,7 +18,7 @@ export default { }, }), - plugins: [sveltekit(), ...live_examples()], + plugins: [sveltekit(), ...live_examples(), source_links()], test: { include: [`tests/vitest/**/*.test.ts`], From 154dd82a21a4098d1b615b9d30a41332c3a2dab0 Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Tue, 25 Aug 2026 09:47:14 +0100 Subject: [PATCH 02/11] Add 493 icons from already-installed Iconify sets New blocks: transport, maps & gis, shapes & math, accessibility. mdi 318, simple-icons 88, lucide 54, gis 16, academicons 12, material-symbols 5. --- scripts/icons-manifest.ts | 497 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 497 insertions(+) diff --git a/scripts/icons-manifest.ts b/scripts/icons-manifest.ts index 3ece766e..dbbf2d00 100644 --- a/scripts/icons-manifest.ts +++ b/scripts/icons-manifest.ts @@ -7,6 +7,7 @@ export const iconify_icons = { AlignJustify: `mdi:format-align-justify`, AlignLeft: `mdi:format-align-left`, AlignRight: `mdi:format-align-right`, + Asterisk: `mdi:asterisk`, Autorenew: `mdi:autorenew`, Backspace: `mdi:backspace-outline`, Bold: `mdi:format-bold`, @@ -20,12 +21,16 @@ export const iconify_icons = { ClipboardMinus: `mdi:clipboard-minus-outline`, ClipboardPlus: `mdi:clipboard-plus-outline`, ClipboardText: `mdi:clipboard-text-outline`, + CodeBlock: `lucide:square-code`, + CommentEdit: `mdi:comment-edit-outline`, CommentPlus: `mdi:comment-plus-outline`, ContentCut: `mdi:content-cut`, ContentDuplicate: `mdi:content-duplicate`, ContentPaste: `mdi:content-paste`, Copy: `mdi:content-copy`, CursorText: `mdi:cursor-text`, + Delete: `mdi:delete-outline`, + DeleteForever: `mdi:delete-forever-outline`, Draft: `mdi:file-document-edit-outline`, Edit: `mdi:edit`, Eraser: `mdi:eraser`, @@ -33,7 +38,11 @@ export const iconify_icons = { FindReplace: `mdi:find-replace`, Font: `mdi:format-font`, FormatClear: `mdi:format-clear`, + FormatColorFill: `mdi:format-color-fill`, FormatColorText: `mdi:format-color-text`, + FormatHeader2: `mdi:format-header-2`, + FormatHeader3: `mdi:format-header-3`, + FormatHorizontalRule: `lucide:separator-horizontal`, FormatLetterSpacing: `mdi:format-letter-spacing`, FormatLineSpacing: `mdi:format-line-spacing`, FormatParagraph: `mdi:format-paragraph`, @@ -41,6 +50,8 @@ export const iconify_icons = { FormatQuoteClose: `mdi:format-quote-close`, FormatQuoteOpen: `mdi:format-quote-open`, FormatText: `mdi:format-text`, + FormatTextdirectionLToR: `mdi:format-textdirection-l-to-r`, + FormatTextdirectionRToL: `mdi:format-textdirection-r-to-l`, FormInput: `lucide:form-input`, Heading: `mdi:format-header-1`, Highlighter: `mdi:marker`, @@ -57,16 +68,19 @@ export const iconify_icons = { ListMinus: `lucide:list-minus`, ListNumbered: `mdi:format-list-numbered`, ListPlus: `lucide:list-plus`, + ListTodo: `lucide:list-todo`, MatchWholeWord: `mdi:format-letter-matches`, Merge: `mdi:call-merge`, Minus: `mdi:minus`, Notebook: `mdi:notebook-outline`, Outdent: `mdi:format-indent-decrease`, Pen: `mdi:pen`, + PencilPlus: `mdi:pencil-plus-outline`, PencilRuler: `mdi:pencil-ruler`, Plus: `mdi:plus`, Redo: `mdi:redo`, Regex: `mdi:regex`, + RemoveFormatting: `lucide:remove-formatting`, Rename: `mdi:rename-outline`, ScanLine: `lucide:scan-line`, ScanSearch: `lucide:scan-search`, @@ -76,6 +90,7 @@ export const iconify_icons = { SelectDrag: `mdi:select-drag`, SelectionLasso: `mdi:selection-lasso`, Signature: `mdi:signature-freehand`, + Slash: `mdi:slash-forward`, Sort: `mdi:sort`, SortAsc: `lucide:sort-asc`, SortDesc: `lucide:sort-desc`, @@ -113,9 +128,13 @@ export const iconify_icons = { CloudDownload: `mdi:cloud-download-outline`, CloudLock: `mdi:cloud-lock-outline`, CloudOff: `mdi:cloud-off-outline`, + CloudOutline: `mdi:cloud-outline`, + CloudPlus: `mdi:cloud-plus-outline`, CloudSync: `mdi:cloud-sync-outline`, CloudUpload: `mdi:cloud-upload-outline`, Database: `mdi:database`, + DatabaseOff: `mdi:database-off-outline`, + Disc: `mdi:disc`, Download: `mdi:download`, Export: `mdi:export`, ExportVariant: `mdi:export-variant`, @@ -126,6 +145,7 @@ export const iconify_icons = { FileAudio: `mdi:file-music-outline`, FileBadge: `lucide:file-badge`, FileCancel: `mdi:file-cancel-outline`, + FileCertificate: `mdi:file-certificate-outline`, FileChart: `mdi:file-chart-outline`, FileCheck: `mdi:file-check-outline`, FileClock: `mdi:file-clock-outline`, @@ -155,6 +175,7 @@ export const iconify_icons = { FileOutput: `lucide:file-output`, FilePDF: `mdi:file-pdf-box`, FilePlus: `mdi:file-plus-outline`, + FilePNG: `mdi:file-png-box`, FilePowerpoint: `mdi:file-powerpoint-outline`, FileQuestion: `mdi:file-question-outline`, FileRefresh: `mdi:file-refresh-outline`, @@ -181,6 +202,7 @@ export const iconify_icons = { FolderArchive: `lucide:folder-archive`, FolderCheck: `mdi:folder-check-outline`, FolderClock: `mdi:folder-clock-outline`, + FolderCode: `lucide:folder-code`, FolderCog: `mdi:folder-cog-outline`, FolderDownload: `mdi:folder-download-outline`, FolderEdit: `mdi:folder-edit-outline`, @@ -191,6 +213,7 @@ export const iconify_icons = { FolderInfo: `material-symbols:folder-info-outline-rounded`, FolderKey: `mdi:folder-key-outline`, FolderLock: `mdi:folder-lock-outline`, + FolderMinus: `mdi:folder-minus-outline`, FolderMove: `mdi:folder-move-outline`, FolderMultiple: `mdi:folder-multiple-outline`, FolderMusic: `mdi:folder-music-outline`, @@ -206,6 +229,7 @@ export const iconify_icons = { HardDrive: `mdi:harddisk`, Import: `mdi:import`, ImportExport: `mdi:import-export`, + MemoryCard: `mdi:sd`, Paperclip: `mdi:paperclip`, Save: `mdi:content-save-outline`, SaveAll: `mdi:content-save-all-outline`, @@ -218,28 +242,40 @@ export const iconify_icons = { TrayArrowUp: `mdi:tray-arrow-up`, Upload: `mdi:upload`, USB: `mdi:usb`, + USBFlashDrive: `mdi:usb-flash-drive-outline`, // navigation Apps: `mdi:apps`, AppWindow: `lucide:app-window`, ArrowBack: `mdi:arrow-u-left-top`, + ArrowCollapse: `mdi:arrow-collapse`, ArrowCollapseAll: `mdi:arrow-collapse-all`, ArrowCollapseHorizontal: `mdi:arrow-collapse-horizontal`, ArrowCollapseVertical: `mdi:arrow-collapse-vertical`, ArrowDecision: `mdi:arrow-decision-outline`, ArrowDown: `mdi:arrow-down`, + ArrowDownBold: `mdi:arrow-down-bold-outline`, + ArrowDownCircle: `mdi:arrow-down-circle-outline`, ArrowDownLeft: `mdi:arrow-bottom-left`, ArrowDownRight: `mdi:arrow-bottom-right`, + ArrowExpand: `mdi:arrow-expand`, ArrowExpandAll: `mdi:arrow-expand-all`, ArrowExpandHorizontal: `mdi:arrow-expand-horizontal`, ArrowExpandVertical: `mdi:arrow-expand-vertical`, ArrowForward: `mdi:arrow-u-right-top`, ArrowLeft: `mdi:arrow-left`, + ArrowLeftBold: `mdi:arrow-left-bold-outline`, + ArrowLeftCircle: `mdi:arrow-left-circle-outline`, ArrowLeftRight: `mdi:arrow-left-right`, ArrowRight: `mdi:arrow-right`, + ArrowRightBold: `mdi:arrow-right-bold-outline`, + ArrowRightCircle: `mdi:arrow-right-circle-outline`, ArrowUp: `mdi:arrow-up`, + ArrowUpBold: `mdi:arrow-up-bold-outline`, + ArrowUpCircle: `mdi:arrow-up-circle-outline`, ArrowUpDown: `mdi:arrow-up-down`, ArrowUpLeft: `mdi:arrow-top-left`, ArrowUpRight: `mdi:arrow-top-right`, + Backburger: `mdi:backburger`, ChevronDoubleDown: `mdi:chevron-double-down`, ChevronDoubleLeft: `mdi:chevron-double-left`, ChevronDoubleRight: `mdi:chevron-double-right`, @@ -247,11 +283,17 @@ export const iconify_icons = { ChevronDown: `mdi:chevron-down`, ChevronLeft: `mdi:chevron-left`, ChevronRight: `mdi:chevron-right`, + ChevronsLeftRight: `lucide:chevrons-left-right`, + ChevronsRightLeft: `lucide:chevrons-right-left`, ChevronUp: `mdi:chevron-up`, ChevronUpDown: `mdi:unfold-more-vertical`, Collapse: `iconoir:collapse`, Columns: `lucide:columns`, Command: `mdi:apple-keyboard-command`, + CornerDownLeft: `lucide:corner-down-left`, + CornerDownRight: `lucide:corner-down-right`, + CornerUpLeft: `lucide:corner-up-left`, + CornerUpRight: `lucide:corner-up-right`, CursorDefault: `mdi:cursor-default-outline`, CursorPointer: `mdi:cursor-pointer`, Dashboard: `mdi:view-dashboard-outline`, @@ -270,12 +312,20 @@ export const iconify_icons = { FormDropdown: `mdi:form-dropdown`, Fullscreen: `mdi:fullscreen`, FullscreenExit: `mdi:fullscreen-exit`, + GesturePinch: `mdi:gesture-pinch`, GestureSwipe: `mdi:gesture-swipe`, GestureTap: `mdi:gesture-tap`, GripVertical: `mdi:drag-vertical`, Home: `mdi:home`, HomeOutline: `mdi:home-outline`, Keyboard: `mdi:keyboard-outline`, + KeyboardBackspace: `mdi:keyboard-backspace`, + KeyboardEsc: `mdi:keyboard-esc`, + KeyboardOff: `mdi:keyboard-off-outline`, + KeyboardReturn: `mdi:keyboard-return`, + KeyboardShift: `mdi:apple-keyboard-shift`, + KeyboardTab: `mdi:keyboard-tab`, + LayoutDashboard: `lucide:layout-dashboard`, LayoutGrid: `mdi:view-dashboard-variant`, MapPinned: `lucide:map-pinned`, Masonry: `mdi:view-quilt`, @@ -304,7 +354,11 @@ export const iconify_icons = { Pan: `mdi:pan`, PanelBottom: `mdi:page-layout-footer`, PanelLeft: `lucide:panel-left`, + PanelLeftClose: `lucide:panel-left-close`, + PanelLeftOpen: `lucide:panel-left-open`, PanelRight: `lucide:panel-right`, + PanelRightClose: `lucide:panel-right-close`, + PanelRightOpen: `lucide:panel-right-open`, PanelsTopLeft: `lucide:panels-top-left`, PanelTop: `mdi:page-layout-header`, PictureInPicture: `mdi:picture-in-picture-bottom-right`, @@ -320,9 +374,12 @@ export const iconify_icons = { Stairs: `mdi:stairs`, StepBackward: `mdi:step-backward`, StepForward: `mdi:step-forward`, + SubdirectoryArrowLeft: `mdi:subdirectory-arrow-left`, + SubdirectoryArrowRight: `mdi:subdirectory-arrow-right`, SwapHorizontal: `mdi:swap-horizontal`, SwapVertical: `mdi:swap-vertical`, Tab: `mdi:tab`, + TableOfContents: `lucide:table-of-contents`, TabMinus: `mdi:tab-minus`, TabPlus: `mdi:tab-plus`, ViewAgenda: `mdi:view-agenda-outline`, @@ -348,10 +405,14 @@ export const iconify_icons = { AlertRhombus: `mdi:alert-rhombus-outline`, BadgeInfo: `lucide:badge-info`, Ban: `mdi:cancel`, + BatteryAlert: `mdi:battery-alert-variant-outline`, + BatteryLow: `mdi:battery-low`, + BatteryOff: `mdi:battery-off-outline`, BellBadge: `mdi:bell-badge-outline`, BellCheck: `mdi:bell-check-outline`, BellMinus: `lucide:bell-minus`, BellOff: `mdi:bell-off-outline`, + BellOutline: `mdi:bell-outline`, BellPlus: `lucide:bell-plus`, BellRing: `mdi:bell-ring-outline`, Check: `mdi:check`, @@ -359,6 +420,7 @@ export const iconify_icons = { CheckBold: `mdi:check-bold`, Checkbox: `mdi:checkbox-outline`, CheckboxIndeterminate: `mdi:minus-box-outline`, + CheckboxMarked: `mdi:checkbox-marked`, CheckboxMultiple: `mdi:checkbox-multiple-outline`, CheckCircle: `mdi:check-circle`, CheckCircleOutline: `mdi:check-circle-outline`, @@ -366,13 +428,17 @@ export const iconify_icons = { Circle: `mdi:circle`, CircleDashed: `lucide:circle-dashed`, CircleDot: `lucide:circle-dot`, + CircleOutline: `mdi:circle-outline`, CircleSlice: `mdi:circle-slice-4`, CloseCircle: `mdi:close-circle-outline`, CloseOctagon: `mdi:close-octagon-outline`, + CloseThick: `mdi:close-thick`, + Construction: `lucide:construction`, Help: `mdi:help`, HelpBox: `mdi:help-box-outline`, HelpCircle: `mdi:help-circle-outline`, Hourglass: `mdi:timer-sand`, + HourglassEmpty: `mdi:timer-sand-empty`, Incomplete: `mdi:circle-half-full`, Info: `mdi:info`, InfoBox: `mdi:information-box-outline`, @@ -385,11 +451,14 @@ export const iconify_icons = { InfoSquare: `carbon:information-square`, InfoSquareFilled: `carbon:information-square-filled`, Lightbulb: `mdi:lightbulb-outline`, + LightbulbOff: `mdi:lightbulb-off-outline`, Loading: `mdi:loading`, MinusCircle: `mdi:minus-circle-outline`, NewBox: `mdi:new-box`, + OctagonPause: `lucide:octagon-pause`, Pending: `mdi:dots-horizontal-circle-outline`, PlusCircle: `mdi:plus-circle-outline`, + PlusThick: `mdi:plus-thick`, Power: `mdi:power`, Progress: `mdi:progress-clock`, ProgressAlert: `mdi:progress-alert`, @@ -406,6 +475,9 @@ export const iconify_icons = { ShieldLock: `mdi:shield-lock-outline`, Signal: `mdi:signal`, SignalOff: `mdi:signal-off`, + Siren: `lucide:siren`, + StarFourPoints: `mdi:star-four-points-outline`, + StarOff: `mdi:star-off-outline`, StarOutline: `mdi:star-outline`, ThumbDown: `mdi:thumb-down-outline`, ThumbUp: `mdi:thumb-up-outline`, @@ -428,15 +500,19 @@ export const iconify_icons = { CameraPlus: `mdi:camera-plus-outline`, CaptionsOff: `lucide:captions-off`, Cast: `mdi:cast`, + CastOff: `mdi:cast-off`, + Clapperboard: `lucide:clapperboard`, ClosedCaption: `mdi:closed-caption-outline`, Eject: `mdi:eject-outline`, Equalizer: `mdi:equalizer`, FastForward: `mdi:fast-forward`, + FastForward10: `mdi:fast-forward-10`, Film: `mdi:film`, Gallery: `mdi:image-multiple-outline`, Headphones: `mdi:headphones`, Image: `mdi:image`, ImageEdit: `mdi:image-edit-outline`, + ImageOutline: `mdi:image-outline`, ImagePlus: `mdi:image-plus-outline`, ImageSearch: `mdi:image-search-outline`, ListMusic: `lucide:list-music`, @@ -464,6 +540,7 @@ export const iconify_icons = { RepeatOnce: `mdi:repeat-once`, Replay: `mdi:replay`, Rewind: `mdi:rewind`, + Rewind10: `mdi:rewind-10`, ScreenRecord: `mdi:record-rec`, ScreenShare: `lucide:screen-share`, ScreenShareOff: `lucide:screen-share-off`, @@ -484,9 +561,11 @@ export const iconify_icons = { VolumeMute: `mdi:volume-mute`, VolumeOff: `mdi:volume-off`, VolumeOn: `mdi:volume-high`, + Waveform: `mdi:waveform`, Webcam: `mdi:webcam`, // people Account: `mdi:account-outline`, + AccountAlert: `mdi:account-alert-outline`, AccountCheck: `mdi:account-check-outline`, AccountCircle: `mdi:account-circle-outline`, AccountClock: `mdi:account-clock-outline`, @@ -505,11 +584,15 @@ export const iconify_icons = { AccountSwitch: `mdi:account-switch-outline`, AccountVoice: `mdi:account-voice`, AddressBook: `mdi:contacts-outline`, + Baby: `mdi:baby-face-outline`, BadgeAccount: `mdi:badge-account-outline`, Briefcase: `mdi:briefcase-outline`, Contact: `lucide:contact`, Hand: `lucide:hand`, + HandClap: `mdi:hand-clap`, + HandHeart: `mdi:hand-heart-outline`, Handshake: `mdi:handshake-outline`, + HandWave: `mdi:hand-wave-outline`, HelpingHand: `lucide:hand-helping`, HumanGreeting: `mdi:human-greeting`, HumanMaleFemale: `mdi:human-male-female`, @@ -535,6 +618,7 @@ export const iconify_icons = { Email: `mdi:email`, EmailCheck: `mdi:email-check-outline`, EmailOpen: `mdi:email-open-outline`, // opened envelope; closed is custom.ts Email + EmailOutline: `mdi:email-outline`, EmailPlus: `mdi:email-plus-outline`, EmailSend: `mdi:email-send-outline`, Forum: `mdi:forum-outline`, @@ -609,7 +693,9 @@ export const iconify_icons = { AngleRight: `mdi:angle-right`, ApproxEqual: `mdi:approximately-equal`, Atom: `mdi:atom`, + Bacteria: `mdi:bacteria-outline`, Binary: `lucide:binary`, + Biohazard: `mdi:biohazard`, Boxes: `lucide:boxes`, ChartArc: `mdi:chart-arc`, ChartArea: `mdi:chart-areaspline`, @@ -625,6 +711,7 @@ export const iconify_icons = { ChartGantt: `mdi:chart-gantt`, ChartHistogram: `mdi:chart-histogram`, ChartLine: `mdi:chart-line`, + ChartLineStacked: `mdi:chart-line-stacked`, ChartLineVariant: `mdi:chart-line-variant`, ChartMultiline: `mdi:chart-multiline`, ChartNetwork: `lucide:chart-network`, @@ -633,6 +720,7 @@ export const iconify_icons = { ChartSankey: `mdi:chart-sankey`, ChartScatter: `mdi:chart-scatter-plot`, ChartScatterHexbin: `mdi:chart-scatter-plot-hexbin`, + ChartSpline: `lucide:chart-spline`, ChartTimeline: `mdi:chart-timeline-variant`, ChartTree: `mdi:chart-tree`, ChartWaterfall: `mdi:chart-waterfall`, @@ -640,18 +728,22 @@ export const iconify_icons = { Counter: `mdi:counter`, Cube: `mdi:cube-outline`, CubeScan: `mdi:cube-scan`, + CurrentAC: `mdi:current-ac`, Cylinder: `mdi:cylinder`, + DatabaseAlert: `mdi:database-alert-outline`, DatabaseCheck: `mdi:database-check-outline`, DatabaseCog: `mdi:database-cog-outline`, DatabaseExport: `mdi:database-export-outline`, DatabaseEye: `mdi:database-eye-outline`, DatabaseImport: `mdi:database-import-outline`, DatabaseLock: `mdi:database-lock-outline`, + DatabaseOutline: `mdi:database-outline`, DatabasePlus: `mdi:database-plus-outline`, DatabaseRemove: `mdi:database-remove-outline`, DatabaseSearch: `mdi:database-search-outline`, DatabaseSync: `mdi:database-sync-outline`, DatabaseZap: `lucide:database-zap`, + Decimal: `mdi:decimal`, Delta: `mdi:delta`, Diffraction: `mdi:sine-wave`, Divide: `mdi:division`, @@ -659,15 +751,20 @@ export const iconify_icons = { Equal: `mdi:equal`, Flask: `mdi:flask`, FlaskOff: `mdi:flask-off`, + FlaskRoundBottom: `mdi:flask-round-bottom-outline`, Function: `mdi:function-variant`, Gas: `mdi:gas-cylinder`, + Gauge: `mdi:gauge`, Graph: `mdi:graph`, GraphOutline: `mdi:graph-outline`, GreaterThan: `mdi:greater-than`, + Grid: `mdi:grid`, + GridOff: `mdi:grid-off`, Hexagon: `mdi:hexagon-outline`, Infinite: `mdi:infinity`, // not Infinity (shadows global) Integral: `mdi:math-integral`, Kelvin: `mdi:temperature-kelvin`, + Lambda: `mdi:lambda`, LessThan: `mdi:less-than`, Liquid: `mdi:water-outline`, Magnet: `mdi:magnet`, @@ -679,11 +776,24 @@ export const iconify_icons = { MathTan: `mdi:math-tan`, Matrix: `mdi:matrix`, Memory: `mdi:memory`, + MeterElectric: `mdi:meter-electric-outline`, Microscope: `mdi:microscope`, Molecule: `mdi:molecule`, + MoleculeCO2: `mdi:molecule-co2`, Multiply: `mdi:multiplication`, NotEqual: `mdi:not-equal-variant`, + Numeric0: `mdi:numeric-0`, + Numeric1: `mdi:numeric-1`, + Numeric2: `mdi:numeric-2`, + Numeric3: `mdi:numeric-3`, + Numeric4: `mdi:numeric-4`, + Numeric5: `mdi:numeric-5`, + Numeric6: `mdi:numeric-6`, + Numeric7: `mdi:numeric-7`, + Numeric8: `mdi:numeric-8`, + Numeric9: `mdi:numeric-9`, Octagon: `mdi:octagon-outline`, + Omega: `mdi:omega`, Orbit: `mdi:orbit`, Pentagon: `mdi:pentagon-outline`, PeriodicTable: `mdi:periodic-table`, @@ -691,13 +801,19 @@ export const iconify_icons = { PlusMinus: `mdi:plus-minus-variant`, Polymer: `mdi:polymer`, Pulse: `mdi:pulse`, + Radar: `mdi:radar`, Radioactive: `mdi:radioactive`, Rhombus: `mdi:rhombus-outline`, + Rotate3D: `mdi:rotate-3d`, Rows: `mdi:table-row`, Ruler: `mdi:ruler`, + RulerDimensionLine: `lucide:ruler-dimension-line`, RulerSquareCompass: `mdi:ruler-square-compass`, Satellite: `mdi:satellite-variant`, + SawtoothWave: `mdi:sawtooth-wave`, Scale: `mdi:scale`, + ScaleBalance: `mdi:scale-balance`, + ScaleUnbalanced: `mdi:scale-unbalanced`, SetCenter: `mdi:set-center`, SetLeft: `mdi:set-left`, SetNone: `mdi:set-none`, @@ -707,7 +823,10 @@ export const iconify_icons = { Sphere: `mdi:sphere`, Square: `mdi:square-outline`, SquareRoot: `mdi:square-root`, + SquareWave: `mdi:square-wave`, + Symbol: `mdi:symbol`, Table: `mdi:table`, + TableColumn: `mdi:table-column`, TableColumnPlus: `mdi:table-column-plus-after`, TableColumnRemove: `mdi:table-column-remove`, TableColumnWidth: `mdi:table-column-width`, @@ -718,6 +837,8 @@ export const iconify_icons = { TableImport: `mdi:table-import`, TableLarge: `mdi:table-large`, TableMerge: `mdi:table-merge-cells`, + TableMultiple: `mdi:table-multiple`, + TablePivot: `mdi:table-pivot`, TablePlus: `mdi:table-plus`, TableRemove: `mdi:table-remove`, TableRowHeight: `mdi:table-row-height`, @@ -725,38 +846,57 @@ export const iconify_icons = { TableRowRemove: `mdi:table-row-remove`, TableSearch: `mdi:table-search`, TableSettings: `mdi:table-settings`, + TableSplitCell: `mdi:table-split-cell`, TableSync: `mdi:table-sync`, Tag: `mdi:tag-outline`, + TagPlus: `mdi:tag-plus-outline`, Telescope: `mdi:telescope`, TestTube: `mdi:test-tube`, + TestTubeEmpty: `mdi:test-tube-empty`, Thermometer: `mdi:thermometer`, + ThermometerHigh: `mdi:thermometer-high`, + ThermometerLow: `mdi:thermometer-low`, TrendingDown: `mdi:trending-down`, TrendingFlat: `mdi:trending-neutral`, TrendingUp: `mdi:trending-up`, + TriangleWave: `mdi:triangle-wave`, Variable: `mdi:variable`, VectorBezier: `mdi:vector-bezier`, VectorCircle: `mdi:vector-circle`, + VectorCombine: `mdi:vector-combine`, + VectorDifference: `mdi:vector-difference`, + VectorIntersection: `mdi:vector-intersection`, VectorLine: `mdi:vector-line`, VectorPoint: `mdi:vector-point`, VectorPolygon: `mdi:vector-polygon`, VectorPolyline: `mdi:vector-polyline`, VectorSquare: `mdi:vector-square`, + VectorUnion: `mdi:vector-union`, + Virus: `mdi:virus-outline`, Waves: `mdi:waves`, Weight: `mdi:weight`, + WeightKilogram: `mdi:weight-kilogram`, XRay: `mdi:radiology-box-outline`, // dev ABTesting: `mdi:ab-testing`, + Ampersand: `lucide:ampersand`, API: `mdi:api`, APIOff: `mdi:api-off`, ApplicationBrackets: `mdi:application-brackets-outline`, + ApplicationCog: `mdi:application-cog-outline`, AxisArrow: `mdi:axis-arrow`, Bash: `mdi:bash`, Benchmark: `mdi:speedometer-medium`, + Blocks: `lucide:blocks`, + BotMessageSquare: `lucide:bot-message-square`, Breakpoint: `mdi:record-circle`, Bug: `mdi:bug-outline`, BugCheck: `mdi:bug-check-outline`, + BugOff: `lucide:bug-off`, + BugPlay: `lucide:bug-play`, Cable: `lucide:cable`, Chip: `mdi:chip`, + CircuitBoard: `mdi:developer-board`, CloudBraces: `mdi:cloud-braces`, CloudPrint: `mdi:cloud-print-outline`, Cluster: `mdi:server-network`, @@ -766,39 +906,53 @@ export const iconify_icons = { CodeBrackets: `mdi:code-brackets`, CodeEqual: `mdi:code-equal`, CodeGreaterThan: `mdi:code-greater-than`, + CodeNotEqual: `mdi:code-not-equal`, CodeParentheses: `mdi:code-parentheses`, CodeString: `mdi:code-string`, CodeTags: `mdi:code-tags`, CodeTagsCheck: `mdi:code-tags-check`, + CogOff: `mdi:cog-off-outline`, CogOutline: `mdi:cog-outline`, CogRefresh: `mdi:cog-refresh-outline`, + Cogs: `mdi:cogs`, + CogSync: `mdi:cog-sync-outline`, Console: `mdi:console`, ConsoleLine: `mdi:console-line`, Container: `lucide:container`, CPU: `mdi:cpu-64-bit`, Cron: `mdi:calendar-sync-outline`, Debug: `mdi:debug-step-over`, + DNS: `mdi:dns-outline`, Docker: `simple-icons:docker`, EthernetPort: `lucide:ethernet-port`, GitBranch: `octicon:git-branch`, GitCommit: `octicon:git-commit`, GitCompare: `octicon:git-compare`, + GitGraph: `lucide:git-graph`, GitMerge: `octicon:git-merge`, GPU: `mdi:expansion-card`, IterationCcw: `lucide:iteration-ccw`, + IterationCw: `lucide:iteration-cw`, + Lan: `mdi:lan`, + LanCheck: `mdi:lan-check`, LanDisconnect: `mdi:lan-disconnect`, + LightningOff: `lucide:zap-off`, Lint: `mdi:broom`, ListTree: `lucide:list-tree`, Log: `mdi:text-box-search-outline`, Metrics: `mdi:chart-multiple`, Network: `mdi:lan-connect`, NetworkOff: `mdi:network-off-outline`, + NetworkOutline: `mdi:network-outline`, Package: `mdi:package-variant-closed`, PackageCheck: `mdi:package-check`, PackageDown: `mdi:package-down`, + PackageMinus: `mdi:package-variant-closed-minus`, PackageOpen: `lucide:package-open`, + PackagePlus: `mdi:package-variant-closed-plus`, PackageSearch: `lucide:package-search`, PackageUp: `mdi:package-up`, + PackageVariant: `mdi:package-variant`, Pipeline: `mdi:pipe`, Plugin: `mdi:toy-brick-outline`, Profiler: `mdi:chart-timeline-variant-shimmer`, @@ -810,18 +964,25 @@ export const iconify_icons = { RelationManyToMany: `mdi:relation-many-to-many`, RelationOneToMany: `mdi:relation-one-to-many`, Robot: `mdi:robot-outline`, + RobotOff: `mdi:robot-off-outline`, Rocket: `mdi:rocket-launch-outline`, Router: `mdi:router-wireless`, + RouterWirelessOff: `mdi:router-wireless-off`, + Script: `mdi:script-outline`, + ScriptText: `mdi:script-text-outline`, ServerCog: `lucide:server-cog`, ServerOff: `mdi:server-off`, ServerPlus: `mdi:server-plus`, + ServerSecurity: `mdi:server-security`, Settings: `mdi:settings`, Sitemap: `mdi:sitemap-outline`, SourceBranch: `mdi:source-branch`, SourceBranchCheck: `mdi:source-branch-check`, SourceBranchPlus: `mdi:source-branch-plus`, SourceBranchRemove: `mdi:source-branch-remove`, + SourceBranchSync: `mdi:source-branch-sync`, SourceCommit: `mdi:source-commit`, + SourceCommitLocal: `mdi:source-commit-local`, SourceFork: `mdi:source-fork`, SourceMerge: `mdi:source-merge`, SourcePull: `mdi:source-pull`, @@ -832,67 +993,110 @@ export const iconify_icons = { StepOut: `mdi:debug-step-out`, TagMultiple: `mdi:tag-multiple-outline`, Terminal: `mdi:terminal`, + TestTubes: `lucide:test-tubes`, Timeline: `mdi:timeline-outline`, Tools: `mdi:tools`, + Transfer: `mdi:transfer`, + TransitConnectionVariant: `mdi:transit-connection-variant`, VariableBox: `mdi:variable-box`, Version: `carbon:version`, Versions: `octicon:versions-16`, Webhook: `mdi:webhook`, WebhookOff: `lucide:webhook-off`, + WebRefresh: `mdi:web-refresh`, + WebSync: `mdi:web-sync`, Workflow: `octicon:workflow-16`, Wrench: `mdi:wrench-outline`, + WrenchCheck: `mdi:wrench-check-outline`, XML: `mdi:xml`, // brands: langs & tools + AmazonS3: `simple-icons:amazons3`, Anaconda: `simple-icons:anaconda`, + Android: `simple-icons:android`, Angular: `simple-icons:angular`, + ApacheSpark: `simple-icons:apachespark`, + ApolloGraphQL: `simple-icons:apollographql`, Apple: `simple-icons:apple`, + ArchLinux: `simple-icons:archlinux`, Arduino: `simple-icons:arduino`, Astro: `simple-icons:astro`, AWS: `simple-icons:amazonwebservices`, + AWSLambda: `simple-icons:awslambda`, Babel: `simple-icons:babel`, Biome: `simple-icons:biome`, Bitbucket: `simple-icons:bitbucket`, + Blender: `simple-icons:blender`, + Bootstrap: `simple-icons:bootstrap`, Bun: `simple-icons:bun`, // not `C`, which would be a single-letter export CLang: `simple-icons:c`, Claude: `simple-icons:claude`, ClaudeCode: `simple-icons:claudecode`, + ClickHouse: `simple-icons:clickhouse`, Cloudflare: `simple-icons:cloudflare`, Codecov: `simple-icons:codecov`, + CPlusPlus: `simple-icons:cplusplus`, + CSharp: `simple-icons:csharp`, CSS: `simple-icons:css3`, Curl: `simple-icons:curl`, CursorIDE: `simple-icons:cursor`, + D3: `simple-icons:d3`, Dart: `simple-icons:dart`, Dask: `simple-icons:dask`, + Databricks: `simple-icons:databricks`, + Debian: `simple-icons:debian`, DeepSeek: `simple-icons:deepseek`, Deno: `simple-icons:deno`, DigitalOcean: `simple-icons:digitalocean`, Django: `simple-icons:django`, + DotNet: `simple-icons:dotnet`, + Dropbox: `simple-icons:dropbox`, + DuckDB: `simple-icons:duckdb`, Elasticsearch: `simple-icons:elasticsearch`, Electron: `simple-icons:electron`, ESLint: `simple-icons:eslint`, + Excalidraw: `simple-icons:excalidraw`, FastAPI: `simple-icons:fastapi`, + Fedora: `simple-icons:fedora`, Figma: `simple-icons:figma`, Firebase: `simple-icons:firebase`, Flutter: `simple-icons:flutter`, + GhostCMS: `simple-icons:ghost`, Git: `simple-icons:git`, GitHub: `simple-icons:github`, GitHubActions: `simple-icons:githubactions`, GitHubCopilot: `simple-icons:githubcopilot`, GitLab: `simple-icons:gitlab`, + Godot: `simple-icons:godotengine`, GoLang: `simple-icons:go`, + Google: `simple-icons:google`, + GoogleAnalytics: `simple-icons:googleanalytics`, + GoogleBigQuery: `simple-icons:googlebigquery`, + GoogleChrome: `simple-icons:googlechrome`, GoogleCloud: `simple-icons:googlecloud`, + GoogleColab: `simple-icons:googlecolab`, + GoogleDrive: `simple-icons:googledrive`, GoogleGemini: `simple-icons:googlegemini`, + GoogleMaps: `simple-icons:googlemaps`, + GoogleSheets: `simple-icons:googlesheets`, Grafana: `simple-icons:grafana`, GraphQL: `simple-icons:graphql`, + Heroku: `simple-icons:heroku`, Homebrew: `simple-icons:homebrew`, HTML5: `simple-icons:html5`, + Hugo: `simple-icons:hugo`, + Intel: `simple-icons:intel`, + IntelliJ: `simple-icons:intellijidea`, + IOS: `simple-icons:ios`, Java: `simple-icons:openjdk`, JavaScript: `simple-icons:javascript`, Jenkins: `simple-icons:jenkins`, Jest: `simple-icons:jest`, + JetBrains: `simple-icons:jetbrains`, + Jira: `simple-icons:jira`, Julia: `simple-icons:julia`, Jupyter: `simple-icons:jupyter`, + Kafka: `simple-icons:apachekafka`, Kaggle: `simple-icons:kaggle`, Keras: `simple-icons:keras`, Kotlin: `simple-icons:kotlin`, @@ -901,25 +1105,35 @@ export const iconify_icons = { Linux: `simple-icons:linux`, Lit: `simple-icons:lit`, Lua: `simple-icons:lua`, + MacOS: `simple-icons:macos`, Markdown: `simple-icons:markdown`, Mathematica: `simple-icons:wolframmathematica`, MCP: `simple-icons:modelcontextprotocol`, Mermaid: `simple-icons:mermaid`, Microsoft: `simple-icons:microsoft`, MicrosoftAzure: `simple-icons:microsoftazure`, + MicrosoftExcel: `simple-icons:microsoftexcel`, + MicrosoftSQLServer: `simple-icons:microsoftsqlserver`, + MicrosoftTeams: `simple-icons:microsoftteams`, + MicrosoftWord: `simple-icons:microsoftword`, MistralAI: `simple-icons:mistralai`, MongoDB: `simple-icons:mongodb`, MySQL: `simple-icons:mysql`, + Neo4j: `simple-icons:neo4j`, + Neovim: `simple-icons:neovim`, Netlify: `simple-icons:netlify`, NextJs: `simple-icons:nextdotjs`, NGINX: `simple-icons:nginx`, + NixOS: `simple-icons:nixos`, NodeJs: `simple-icons:nodedotjs`, NPM: `simple-icons:npm`, NumPy: `simple-icons:numpy`, Nuxt: `simple-icons:nuxt`, + NVIDIA: `simple-icons:nvidia`, Observable: `simple-icons:observable`, Ollama: `simple-icons:ollama`, OpenAI: `simple-icons:openai`, + OpenAPI: `simple-icons:openapiinitiative`, OpenRouter: `simple-icons:openrouter`, OpenStreetMap: `simple-icons:openstreetmap`, Pandas: `simple-icons:pandas`, @@ -933,9 +1147,12 @@ export const iconify_icons = { Poetry: `simple-icons:poetry`, Polars: `simple-icons:polars`, PostgreSQL: `simple-icons:postgresql`, + Postman: `simple-icons:postman`, + PowerShell: `simple-icons:powershell`, Prettier: `simple-icons:prettier`, Prisma: `simple-icons:prisma`, Prometheus: `simple-icons:prometheus`, + PyCharm: `simple-icons:pycharm`, Pydantic: `simple-icons:pydantic`, PyPI: `simple-icons:pypi`, Pytest: `simple-icons:pytest`, @@ -944,8 +1161,10 @@ export const iconify_icons = { Quarto: `simple-icons:quarto`, Qwik: `simple-icons:qwik`, RabbitMQ: `simple-icons:rabbitmq`, + Railway: `simple-icons:railway`, RaspberryPi: `simple-icons:raspberrypi`, React: `simple-icons:react`, + RedHat: `simple-icons:redhat`, Redis: `simple-icons:redis`, Remix: `simple-icons:remix`, RLang: `simple-icons:r`, @@ -953,10 +1172,13 @@ export const iconify_icons = { Ruby: `simple-icons:ruby`, Ruff: `simple-icons:ruff`, Rust: `simple-icons:rust`, + Sass: `simple-icons:sass`, ScikitLearn: `simple-icons:scikitlearn`, SciPy: `simple-icons:scipy`, Sentry: `simple-icons:sentry`, ShadcnUI: `simple-icons:shadcnui`, + SnowflakeDB: `simple-icons:snowflake`, + SocketIO: `simple-icons:socketdotio`, SolidJS: `simple-icons:solid`, SQLAlchemy: `simple-icons:sqlalchemy`, SQLite: `simple-icons:sqlite`, @@ -973,87 +1195,136 @@ export const iconify_icons = { Turborepo: `simple-icons:turborepo`, TypeScript: `simple-icons:typescript`, Ubuntu: `simple-icons:ubuntu`, + Unity: `simple-icons:unity`, + UnrealEngine: `simple-icons:unrealengine`, UV: `simple-icons:uv`, Vercel: `simple-icons:vercel`, + Vim: `simple-icons:vim`, Vite: `simple-icons:vite`, Vitest: `simple-icons:vitest`, VSCode: `simple-icons:visualstudiocode`, Vue: `simple-icons:vuedotjs`, Wasm: `simple-icons:webassembly`, Webpack: `simple-icons:webpack`, + Windows: `simple-icons:windows`, + WordPress: `simple-icons:wordpress`, + Xcode: `simple-icons:xcode`, Yarn: `simple-icons:yarn`, + Zapier: `simple-icons:zapier`, Zig: `simple-icons:zig`, Zod: `simple-icons:zod`, // brands: social + Amazon: `simple-icons:amazon`, Anthropic: `simple-icons:anthropic`, + AppleMusic: `simple-icons:applemusic`, Behance: `simple-icons:behance`, + Bitwarden: `simple-icons:bitwarden`, Bluesky: `simple-icons:bluesky`, + Canva: `simple-icons:canva`, CodePen: `simple-icons:codepen`, DevTo: `simple-icons:devdotto`, Discord: `simple-icons:discord`, Dribbble: `simple-icons:dribbble`, Facebook: `simple-icons:facebook`, + Gmail: `simple-icons:gmail`, + GoogleCalendar: `simple-icons:googlecalendar`, + GooglePlay: `simple-icons:googleplay`, Instagram: `simple-icons:instagram`, Linear: `simple-icons:linear`, LinkedIn: `simple-icons:linkedin`, Mastodon: `simple-icons:mastodon`, Medium: `simple-icons:medium`, Meta: `simple-icons:meta`, + Netflix: `simple-icons:netflix`, Notion: `simple-icons:notion`, Obsidian: `simple-icons:obsidian`, + OnePassword: `simple-icons:1password`, + PayPal: `simple-icons:paypal`, Pinterest: `simple-icons:pinterest`, Reddit: `simple-icons:reddit`, + Shopify: `simple-icons:shopify`, SignalApp: `simple-icons:signal`, Slack: `simple-icons:slack`, Spotify: `simple-icons:spotify`, StackBlitz: `simple-icons:stackblitz`, StackOverflow: `simple-icons:stackoverflow`, + Steam: `simple-icons:steam`, + Stripe: `simple-icons:stripe`, Telegram: `simple-icons:telegram`, Threads: `simple-icons:threads`, TikTok: `simple-icons:tiktok`, Twitch: `simple-icons:twitch`, Twitter: `fa-brands:twitter`, + Vimeo: `simple-icons:vimeo`, + VLC: `simple-icons:vlcmediaplayer`, WhatsApp: `simple-icons:whatsapp`, + Wikipedia: `simple-icons:wikipedia`, X: `simple-icons:x`, // X logo; Twitter keeps the bird YouTube: `simple-icons:youtube`, // publishing & academia + ACM: `simple-icons:acm`, Article: `ri:article-line`, ArXiv: `simple-icons:arxiv`, + BioRxiv: `academicons:biorxiv`, Book: `mdi:book-open-page-variant-outline`, + BookOpen: `mdi:book-open-outline`, + BookPlus: `mdi:book-plus-outline`, + BookSearch: `mdi:book-search-outline`, Certificate: `mdi:certificate-outline`, Citation: `ri:quote-text`, + ClosedAccess: `academicons:closed-access`, Crossref: `academicons:crossref`, CvSquare: `academicons:cv-square`, + DataCite: `academicons:datacite`, + Dataverse: `simple-icons:dataverse`, + DBLP: `academicons:dblp`, DOI: `academicons:doi`, Education: `zondicons:education`, Elsevier: `simple-icons:elsevier`, Figshare: `simple-icons:figshare`, GoogleScholar: `academicons:google-scholar`, + GraduationCap: `lucide:graduation-cap`, HuggingFace: `simple-icons:huggingface`, + IEEE: `simple-icons:ieee`, Interests: `material-symbols:interests`, + InternetArchive: `simple-icons:internetarchive`, Journal: `iconoir:journal`, + JSTOR: `academicons:jstor`, Languages: `lucide:languages`, LaTeX: `simple-icons:latex`, LaTeXFile: `file-icons:latex`, Library: `mdi:bookshelf`, + MathOverflow: `academicons:mathoverflow`, + Mendeley: `simple-icons:mendeley`, + NASA: `simple-icons:nasa`, + NASAADS: `academicons:ads`, Newspaper: `mdi:newspaper`, Note: `mdi:note-outline`, OpenAccess: `simple-icons:openaccess`, + OpenData: `academicons:open-data`, OpenSource: `ri:open-source-line`, ORCID: `academicons:orcid`, + OSF: `simple-icons:osf`, Overleaf: `simple-icons:overleaf`, People: `octicon:people-16`, Presentation: `mdi:presentation`, Project: `octicon:project`, PubMed: `simple-icons:pubmed`, + PubPeer: `academicons:pubpeer`, Quote: `octicon:quote`, ResearchGate: `academicons:researchgate`, + ROR: `academicons:ror`, + Scopus: `simple-icons:scopus`, ScrollText: `lucide:scroll-text`, SearchCountry: `gis:search-country`, SemanticScholar: `academicons:semantic-scholar`, SkillLevel: `carbon:skill-level-advanced`, + Springer: `academicons:springer`, + SSRN: `simple-icons:ssrn`, Sunglasses: `mdi:sunglasses`, Typst: `simple-icons:typst`, + Wikidata: `simple-icons:wikidata`, + Wiley: `academicons:wiley`, Zenodo: `academicons:zenodo`, Zoom: `simple-icons:zoom`, Zotero: `simple-icons:zotero`, @@ -1062,6 +1333,8 @@ export const iconify_icons = { AlignEndHorizontal: `lucide:align-end-horizontal`, AlignEndVertical: `lucide:align-end-vertical`, AlignHorizontalCenter: `mdi:align-horizontal-center`, + AlignHorizontalLeft: `mdi:align-horizontal-left`, + AlignHorizontalRight: `mdi:align-horizontal-right`, AlignStartHorizontal: `lucide:align-start-horizontal`, AlignStartVertical: `lucide:align-start-vertical`, AlignTop: `mdi:align-vertical-top`, @@ -1069,50 +1342,72 @@ export const iconify_icons = { BetweenHorizontalEnd: `lucide:between-horizontal-end`, BetweenHorizontalStart: `lucide:between-horizontal-start`, Blur: `mdi:blur`, + BlurOff: `mdi:blur-off`, BorderAll: `mdi:border-all`, + BorderColor: `mdi:border-color`, BorderNone: `mdi:border-none-variant`, + BorderRadius: `mdi:border-radius`, BoxShadow: `mdi:box-shadow`, Brightness: `mdi:brightness-6`, BringForward: `mdi:arrange-bring-forward`, BringToFront: `lucide:bring-to-front`, Brush: `mdi:brush`, + BrushVariant: `mdi:brush-variant`, ColorPalette: `carbon:color-palette`, Combine: `lucide:combine`, Contrast: `mdi:contrast-circle`, Crop: `mdi:crop`, + CropRotate: `mdi:crop-rotate`, DistributeHorizontal: `mdi:align-horizontal-distribute`, DistributeVertical: `mdi:align-vertical-distribute`, + Drawing: `mdi:drawing`, + Ellipse: `mdi:ellipse-outline`, Eye: `mdi:eye`, Eyedropper: `mdi:eyedropper`, EyeOff: `mdi:eye-off`, FlipHorizontal: `mdi:flip-horizontal`, FlipVertical: `mdi:flip-vertical`, Focus: `lucide:focus`, + FormatPaint: `mdi:format-paint`, Gradient: `mdi:gradient-vertical`, GradientHorizontal: `mdi:gradient-horizontal`, Group: `mdi:group`, + ImageFilterBlackWhite: `mdi:image-filter-black-white`, ImageFilterCenterFocus: `mdi:image-filter-center-focus`, ImageFrame: `mdi:image-frame`, ImageOff: `mdi:image-off-outline`, + InvertColors: `mdi:invert-colors`, + InvertColorsOff: `mdi:invert-colors-off`, Layers: `mdi:layers-outline`, + LayersEdit: `mdi:layers-edit`, LayersOff: `mdi:layers-off-outline`, LayersPlus: `mdi:layers-plus`, + MirrorRectangle: `mdi:mirror-rectangle`, Move3D: `lucide:move-3d`, NoImage: `carbon:no-image`, Opacity: `mdi:opacity`, PaintBucket: `lucide:paint-bucket`, + PaletteSwatch: `mdi:palette-swatch-outline`, Panorama: `mdi:panorama-outline`, Proportions: `lucide:proportions`, Radius: `mdi:radius-outline`, + RectangleHorizontal: `lucide:rectangle-horizontal`, + RectangleVertical: `lucide:rectangle-vertical`, RotateLeft: `mdi:rotate-left`, RotateRight: `mdi:rotate-right`, + Scaling: `lucide:scaling`, SendBackward: `mdi:arrange-send-backward`, SendToBack: `lucide:send-to-back`, Shape: `mdi:shape-outline`, + ShapePlus: `mdi:shape-plus-outline`, Skew: `mdi:skew-less`, + Swatchbook: `lucide:swatch-book`, + TapeMeasure: `mdi:tape-measure`, Texture: `mdi:texture-box`, + Tint: `mdi:water`, Transition: `mdi:transition`, Ungroup: `mdi:ungroup`, + VectorPen: `lucide:pen-tool`, WandSparkles: `lucide:wand-sparkles`, ZoomIn: `mdi:zoom-in`, ZoomOut: `mdi:zoom-out`, @@ -1128,20 +1423,34 @@ export const iconify_icons = { SortNumeric: `mdi:sort-numeric-ascending`, SortVariant: `mdi:sort-variant`, // security + Cctv: `mdi:cctv`, + EyeOffOutline: `mdi:eye-off-outline`, + EyeOutline: `mdi:eye-outline`, Fingerprint: `mdi:fingerprint`, Firewall: `mdi:wall-fire`, Incognito: `mdi:incognito`, Key: `mdi:key`, KeyAlert: `mdi:key-alert-outline`, + KeyChain: `mdi:key-chain`, KeyChange: `mdi:key-change`, + KeyOutline: `mdi:key-outline`, KeyVariant: `mdi:key-variant`, Lock: `mdi:lock`, + LockAlert: `mdi:lock-alert-outline`, + LockCheck: `mdi:lock-check-outline`, + LockOff: `mdi:lock-off-outline`, LockOpen: `mdi:lock-open-variant-outline`, + LockOutline: `mdi:lock-outline`, + LockQuestion: `mdi:lock-question`, LockReset: `mdi:lock-reset`, Password: `mdi:form-textbox-password`, ScanFace: `lucide:scan-face`, + Security: `mdi:security`, ShieldAccount: `mdi:shield-account-outline`, + ShieldBug: `mdi:shield-bug-outline`, + ShieldHome: `mdi:shield-home-outline`, ShieldKey: `mdi:shield-key-outline`, + ShieldLockOpen: `mdi:shield-lock-open-outline`, ShieldOff: `mdi:shield-off-outline`, ShieldQuestion: `material-symbols:shield-question-outline`, ShieldRefresh: `mdi:shield-refresh-outline`, @@ -1153,29 +1462,53 @@ export const iconify_icons = { WebOff: `mdi:web-off`, // commerce BadgeDollarSign: `lucide:badge-dollar-sign`, + BadgePercent: `lucide:badge-percent`, Bank: `mdi:bank-outline`, + BankTransfer: `mdi:bank-transfer`, Barcode: `mdi:barcode`, + BarcodeScan: `mdi:barcode-scan`, + Basket: `mdi:basket-outline`, Calculator: `mdi:calculator-variant-outline`, Cart: `mdi:cart-outline`, CartCheck: `mdi:cart-check`, + CartPlus: `mdi:cart-plus`, Cash: `mdi:cash`, + Cash100: `mdi:cash-100`, CashMultiple: `mdi:cash-multiple`, + CashPlus: `mdi:cash-plus`, CashRegister: `mdi:cash-register`, + Coins: `lucide:coins`, Coupon: `mdi:ticket-percent-outline`, CreditCard: `mdi:credit-card-outline`, CreditCardCheck: `mdi:credit-card-check-outline`, + CreditCardMultiple: `mdi:credit-card-multiple-outline`, + CreditCardOff: `mdi:credit-card-off-outline`, + CreditCardRefund: `mdi:credit-card-refund-outline`, + CreditCardWireless: `mdi:credit-card-wireless-outline`, Currency: `mdi:currency-usd`, CurrencyBitcoin: `mdi:currency-btc`, + CurrencyEthereum: `mdi:currency-eth`, CurrencyEuro: `mdi:currency-eur`, CurrencyPound: `mdi:currency-gbp`, + CurrencyRuble: `mdi:currency-rub`, + CurrencyRupee: `mdi:currency-inr`, + CurrencyYen: `mdi:currency-jpy`, + CurrencyYuan: `mdi:currency-cny`, + Finance: `mdi:finance`, Gift: `mdi:gift-outline`, + GiftOpen: `mdi:gift-open-outline`, HandCoins: `lucide:hand-coins`, + Invoice: `mdi:invoice-text-outline`, + InvoiceList: `mdi:invoice-list-outline`, Percent: `mdi:percent-outline`, + PiggyBank: `mdi:piggy-bank-outline`, + PointOfSale: `mdi:point-of-sale`, QRCode: `mdi:qrcode`, QRCodeScan: `mdi:qrcode-scan`, Receipt: `mdi:receipt-text-outline`, ReceiptCheck: `mdi:receipt-text-check-outline`, Refund: `mdi:cash-refund`, + Safe: `mdi:safe`, Sale: `mdi:sale-outline`, Shopping: `mdi:shopping-outline`, ShoppingBag: `lucide:shopping-bag`, @@ -1183,17 +1516,24 @@ export const iconify_icons = { Store: `mdi:store-outline`, Storefront: `mdi:storefront-outline`, Ticket: `mdi:ticket-outline`, + TicketCheck: `lucide:ticket-check`, + TicketConfirmation: `mdi:ticket-confirmation-outline`, Tickets: `lucide:tickets`, + TruckCheck: `mdi:truck-check-outline`, TruckDelivery: `mdi:truck-delivery-outline`, + TruckFast: `mdi:truck-fast-outline`, Wallet: `mdi:wallet-outline`, WalletCards: `lucide:wallet-cards`, + WalletPlus: `mdi:wallet-plus-outline`, Warehouse: `mdi:warehouse`, // weather & nature Bee: `mdi:bee`, Bird: `mdi:bird`, + Butterfly: `mdi:butterfly-outline`, Cactus: `mdi:cactus`, Cat: `mdi:cat`, Cloudy: `mdi:weather-cloudy`, + Clover: `mdi:clover`, Dog: `mdi:dog`, Fish: `mdi:fish`, Fog: `mdi:weather-fog`, @@ -1202,42 +1542,70 @@ export const iconify_icons = { Humidity: `mdi:water-percent`, Hurricane: `mdi:weather-hurricane`, Leaf: `mdi:leaf`, + MoonFull: `mdi:moon-full`, + MoonNew: `mdi:moon-new`, Mountain: `mdi:image-filter-hdr`, Mushroom: `mdi:mushroom-outline`, + Owl: `mdi:owl`, PartlyCloudy: `mdi:weather-partly-cloudy`, + Paw: `mdi:paw-outline`, + PineTree: `mdi:pine-tree`, Pouring: `mdi:weather-pouring`, + Rabbit: `mdi:rabbit-variant-outline`, Rain: `mdi:weather-rainy`, + Rainbow: `lucide:rainbow`, Snowflake: `mdi:snowflake`, Sunset: `mdi:weather-sunset`, + Thunderstorm: `mdi:weather-lightning-rainy`, Tornado: `mdi:weather-tornado`, + Tsunami: `mdi:tsunami`, + Turtle: `mdi:turtle`, Volcano: `mdi:volcano-outline`, + WeatherHazy: `mdi:weather-hazy`, + WeatherLightning: `mdi:weather-lightning`, + WeatherNightPartlyCloudy: `mdi:weather-night-partly-cloudy`, + WeatherPartlyLightning: `mdi:weather-partly-lightning`, + WeatherPartlyRainy: `mdi:weather-partly-rainy`, + WeatherSnowy: `mdi:weather-snowy`, + Wheat: `lucide:wheat`, Wind: `mdi:weather-windy`, // activities + Badminton: `mdi:badminton`, Baseball: `mdi:baseball`, Basketball: `mdi:basketball`, Bike: `mdi:bike`, Bowling: `mdi:bowling`, + Boxing: `mdi:boxing-glove`, Campfire: `mdi:campfire`, Cards: `mdi:cards-outline`, Chess: `mdi:chess-knight`, // a peak with a summit flag; the only climbing-adjacent glyph in the installed sets Climbing: `material-symbols:mountain-flag-outline`, Coffee: `mdi:coffee-outline`, + Cricket: `mdi:cricket`, + Dance: `mdi:dance-ballroom`, Dice: `mdi:dice-multiple-outline`, Dumbbell: `mdi:dumbbell`, + Fishing: `mdi:hook`, Football: `mdi:football`, Golf: `mdi:golf`, Hiking: `mdi:hiking`, Hockey: `mdi:hockey-sticks`, Kayaking: `mdi:kayaking`, Meditation: `mdi:meditation`, + Parachute: `mdi:parachute-outline`, + Rugby: `mdi:rugby`, Run: `mdi:run`, + RunFast: `mdi:run-fast`, Skateboard: `mdi:skateboard`, Ski: `mdi:ski`, + Snowboard: `mdi:snowboard`, Soccer: `mdi:soccer`, Surfing: `mdi:surfing`, Swim: `mdi:swim`, + TableTennis: `mdi:table-tennis`, Tennis: `mdi:tennis`, + Tent: `lucide:tent`, Volleyball: `mdi:volleyball`, Walk: `mdi:walk`, WeightLifter: `mdi:weight-lifter`, @@ -1387,4 +1755,133 @@ export const iconify_icons = { WifiCog: `lucide:wifi-cog`, Window: `mdi:window-closed-variant`, WindTurbine: `mdi:wind-turbine`, + // transport + AirplaneLanding: `mdi:airplane-landing`, + AirplaneTakeoff: `mdi:airplane-takeoff`, + Ambulance: `mdi:ambulance`, + BusStop: `mdi:bus-stop`, + Caravan: `mdi:caravan`, + CarBattery: `mdi:car-battery`, + CarElectric: `mdi:car-electric-outline`, + CarEmergency: `mdi:car-emergency`, + CarKey: `mdi:car-key`, + CarSide: `mdi:car-side`, + CarWash: `mdi:car-wash`, + ChargingStation: `mdi:ev-station`, + Engine: `mdi:engine-outline`, + FireTruck: `mdi:fire-truck`, + Forklift: `mdi:forklift`, + Garage: `mdi:garage-open-variant`, + GasStation: `mdi:gas-station-outline`, + Gondola: `mdi:gondola`, + Helicopter: `mdi:helicopter`, + Highway: `mdi:highway`, + Moped: `mdi:moped`, + Motorbike: `mdi:motorbike`, + RocketShip: `mdi:rocket`, + Sailboat: `mdi:sail-boat`, + Scooter: `mdi:scooter`, + Seatbelt: `mdi:seatbelt`, + Steering: `mdi:steering`, + Submarine: `mdi:submarine`, + Subway: `mdi:subway-variant`, + Taxi: `mdi:taxi`, + Tire: `mdi:tire`, + Tractor: `mdi:tractor`, + TrafficCone: `mdi:traffic-cone`, + TrainCar: `mdi:train-car`, + Tram: `mdi:tram`, + Truck: `mdi:truck-outline`, + Tunnel: `mdi:tunnel-outline`, + UFO: `mdi:ufo-outline`, + Van: `mdi:van-utility`, + // maps & gis + Buffer: `gis:buffer`, + CadastreMap: `gis:cadastre-map`, + CompassRose: `mdi:compass-rose`, + ContourMap: `gis:contour-map`, + Crosshairs: `mdi:crosshairs`, + CrosshairsGPS: `mdi:crosshairs-gps`, + CrosshairsOff: `mdi:crosshairs-off`, + EarthArrowRight: `mdi:earth-arrow-right`, + EarthNetwork: `gis:earth-network`, + EarthPlus: `mdi:earth-plus`, + FlagFinish: `gis:flag-finish`, + FlagStart: `gis:flag-start`, + GlobeModel: `mdi:globe-model`, + Latitude: `mdi:latitude`, + LayerStack: `gis:layer-stack`, + Longitude: `mdi:longitude`, + MapCheck: `mdi:map-check-outline`, + MapLegend: `mdi:map-legend`, + MapMarkerAccount: `mdi:map-marker-account-outline`, + MapMarkerAlert: `mdi:map-marker-alert-outline`, + MapMarkerCheck: `mdi:map-marker-check-outline`, + MapMarkerCircle: `mdi:map-marker-circle`, + MapMarkerDistance: `mdi:map-marker-distance`, + MapMarkerMinus: `mdi:map-marker-minus-outline`, + MapMarkerMultiple: `mdi:map-marker-multiple-outline`, + MapMarkerOff: `mdi:map-marker-off-outline`, + MapMarkerPath: `mdi:map-marker-path`, + MapMarkerPlus: `mdi:map-marker-plus-outline`, + MapMarkerQuestion: `mdi:map-marker-question-outline`, + MapMarkerRadius: `mdi:map-marker-radius-outline`, + MapMarkerStar: `mdi:map-marker-star-outline`, + MapMinus: `mdi:map-minus`, + MapPlus: `mdi:map-plus`, + MapRoute: `gis:map-route`, + MapSearch: `mdi:map-search-outline`, + MeasureArea: `gis:measure-area`, + MeasureLine: `gis:measure-line`, + NorthArrow: `gis:north-arrow`, + POI: `gis:poi`, + PolygonPoint: `gis:polygon-pt`, + Position: `gis:position`, + ShapeFile: `gis:shape-file`, + StoryMap: `gis:story-map`, + // shapes & math + Alpha: `mdi:alpha`, + Axis: `mdi:axis`, + AxisXArrow: `mdi:axis-x-arrow`, + AxisYArrow: `mdi:axis-y-arrow`, + AxisZArrow: `mdi:axis-z-arrow`, + Beta: `mdi:beta`, + CardsClub: `mdi:cards-club-outline`, + CardsDiamond: `mdi:cards-diamond-outline`, + CardsHeart: `mdi:cards-heart-outline`, + CardsSpade: `mdi:cards-spade-outline`, + CircleDouble: `mdi:circle-double`, + Cuboid: `lucide:cuboid`, + Diameter: `lucide:diameter`, + Exponent: `mdi:exponent`, + Gamma: `mdi:gamma`, + GreaterThanOrEqual: `mdi:greater-than-or-equal`, + LessThanOrEqual: `mdi:less-than-or-equal`, + Octahedron: `mdi:octahedron`, + Pyramid: `mdi:pyramid`, + Radical: `lucide:radical`, + Rectangle: `mdi:rectangle-outline`, + RhombusSplit: `mdi:rhombus-split-outline`, + Shapes: `lucide:shapes`, + Spline: `lucide:spline`, + SquareDashed: `lucide:square-dashed`, + SquareRounded: `mdi:square-rounded-outline`, + Tangent: `lucide:tangent`, + Torus: `lucide:torus`, + // accessibility + AccessibilityNew: `material-symbols:accessibility-new`, + AccessibleForward: `material-symbols:accessible-forward`, + AudioDescription: `mdi:audio-video`, + BabyChangingTable: `mdi:human-baby-changing-table`, + Blind: `material-symbols:blind`, + ClosedCaptionDisabled: `material-symbols:closed-caption-disabled-outline`, + EarHearingLoop: `mdi:ear-hearing-loop`, + EarOff: `lucide:ear-off`, + EyePlus: `mdi:eye-plus-outline`, + FontSizeDecrease: `mdi:format-font-size-decrease`, + FontSizeIncrease: `mdi:format-font-size-increase`, + HearingDisabled: `material-symbols:hearing-disabled`, + HumanCane: `mdi:human-cane`, + HumanWhiteCane: `mdi:human-white-cane`, + PersonStanding: `lucide:person-standing`, } as const From 86f97adbedaa9bf2b7bf5df45852a5ba4358abc1 Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Tue, 25 Aug 2026 09:47:19 +0100 Subject: [PATCH 03/11] Let wide CodeExample scroll inside itself instead of widening the page --- src/lib/CodeExample.svelte | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/CodeExample.svelte b/src/lib/CodeExample.svelte index eb8d6c5a..5adbc57f 100644 --- a/src/lib/CodeExample.svelte +++ b/src/lib/CodeExample.svelte @@ -121,6 +121,8 @@ flex-direction: column; margin: var(--code-example-margin, 1em auto); position: relative; + /* a wide example scrolls inside its own pre/preview instead of widening the page */ + min-width: 0; } div.code-example.code-above > pre { order: -1; From 5eb4a5e304de6660de5625c9010952c74a49eac7 Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Tue, 25 Aug 2026 09:47:42 +0100 Subject: [PATCH 04/11] Fix tooltip, portal and draggable attachment bugs - tooltip: re-entering during the close delay cancels the pending close; fall back to absolute positioning without the Popover API instead of throwing; Escape layer passes the key through when nothing is open; on_open_change(true) always precedes a close; triangular clip-path arrow per placement - portal: don't resurrect a node Svelte already removed with its block - draggable: relative nodes start from their insets (no jump), static nodes are promoted to relative so they move at all --- src/lib/attachments/draggable.ts | 14 +++++++--- src/lib/attachments/float.ts | 5 +++- src/lib/attachments/tooltip.ts | 31 +++++++++++++--------- tests/vitest/attachments/draggable.test.ts | 18 +++++++++++++ tests/vitest/attachments/portal.test.ts | 11 ++++++++ tests/vitest/attachments/tooltip.test.ts | 19 +++++++++++++ 6 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/lib/attachments/draggable.ts b/src/lib/attachments/draggable.ts index debb4b71..b52d8e9d 100644 --- a/src/lib/attachments/draggable.ts +++ b/src/lib/attachments/draggable.ts @@ -1,7 +1,7 @@ import type { Attachment } from 'svelte/attachments' import { clamp } from '../utils' import type { AnchorRect } from './float' -import { follow_pointer, is_primary_press } from './shared' +import { css_px, follow_pointer, is_primary_press } from './shared' export interface DraggableOptions { handle_selector?: string @@ -49,11 +49,17 @@ export const draggable = dragging = true // A fixed node is placed in viewport coordinates, which is what its rect reports; - // everything else is placed against its offset parent. + // an absolute one against its offset parent. A relative (or static, promoted so + // left/top take effect) node is already in flow, so offsetLeft would double its + // position: continue from its current insets instead. + const styles = getComputedStyle(node) const origin = - getComputedStyle(node).position === `fixed` + styles.position === `fixed` ? node.getBoundingClientRect() - : { left: node.offsetLeft, top: node.offsetTop } + : styles.position === `absolute` + ? { left: node.offsetLeft, top: node.offsetTop } + : { left: css_px(styles.left) || 0, top: css_px(styles.top) || 0 } + if (styles.position === `static`) node.style.position = `relative` initial.left = origin.left initial.top = origin.top diff --git a/src/lib/attachments/float.ts b/src/lib/attachments/float.ts index 91baa396..1eacb0aa 100644 --- a/src/lib/attachments/float.ts +++ b/src/lib/attachments/float.ts @@ -129,9 +129,12 @@ export const portal = node.before(anchor) target.append(node) return () => { + // Svelte removes a destroyed block's nodes before running teardown, so a node no + // longer in the target must not be put back: its home is still live markup. + if (node.parentNode !== target) anchor.remove() // Original parent already gone: replaceWith would be a no-op and strand the // node inside the target, outliving the markup that owns it. - if (anchor.parentNode) anchor.replaceWith(node) + else if (anchor.parentNode) anchor.replaceWith(node) else node.remove() } } diff --git a/src/lib/attachments/tooltip.ts b/src/lib/attachments/tooltip.ts index 2cb96099..37ab4cd7 100644 --- a/src/lib/attachments/tooltip.ts +++ b/src/lib/attachments/tooltip.ts @@ -103,11 +103,11 @@ const TOOLTIP_CSS_VARS = [ // its own name. const CLOSE_REASON = { pointer: `pointer`, focus: `blur` } as const -const OPPOSITE_PLACEMENT: Record = { - top: `bottom`, - bottom: `top`, - left: `right`, - right: `left`, +const ARROW_PLACEMENT: Record = { + top: [`bottom`, 135], + bottom: [`top`, 315], + left: [`right`, 45], + right: [`left`, 225], } const handled_tooltip_events = new WeakSet() @@ -210,11 +210,12 @@ const sync_arrow_styles = ( ? 0 : css_px_or(styles.borderTopWidth, 0) const arrow_side = (arrow_px + border_width) * Math.SQRT2 - arrow.style.cssText = `position: absolute; box-sizing: border-box; width: ${arrow_side}px; height: ${arrow_side}px; pointer-events: none; z-index: -1; background: ${fill_color}; border: ${border_width}px solid ${border_color}; transform: rotate(45deg);` + const [inset_side, rotation_deg] = ARROW_PLACEMENT[placement] + arrow.style.cssText = `position: absolute; box-sizing: border-box; width: ${arrow_side}px; height: ${arrow_side}px; pointer-events: none; background: ${fill_color}; border: ${border_width}px solid ${border_color}; clip-path: polygon(0 0, 100% 0, 100% 100%); transform: rotate(${rotation_deg}deg);` const set = (property: string, value: string) => arrow.style.setProperty(property, value) set(vertical ? `left` : `top`, `${cross_axis_center - arrow_side / 2}px`) - set(OPPOSITE_PLACEMENT[placement], `${-arrow_side / 2}px`) + set(inset_side, `${-arrow_side / 2}px`) } const remember_and_strip_title = ( @@ -638,14 +639,15 @@ const create_tooltip_manager = (doc: Document, on_empty: () => void) => { if (active?.open && !active.trigger.isConnected) hide_active(`visibility`) }) - // Fill the recycled surface and put it on screen. Top-layer mode requires the Popover - // API; fixed and absolute remain explicit alternatives for consumers that need them. + // Fill the recycled surface and put it on screen. Top-layer mode degrades to absolute + // positioning where the Popover API is missing rather than throwing on every hover. const mount_surface = (trigger: HTMLElement, options: TooltipOptions) => { surface.replaceChildren(content_el) if (options.show_arrow !== false) surface.append(arrow_el) surface.hidden = false if (!surface.isConnected) doc.body.append(surface) - if ((options.strategy ?? `top-layer`) === `top-layer`) { + const top_layer = (options.strategy ?? `top-layer`) === `top-layer` + if (top_layer && typeof surface.showPopover === `function`) { surface.setAttribute(`popover`, `manual`) surface.showPopover({ source: trigger }) } else surface.removeAttribute(`popover`) @@ -686,19 +688,22 @@ const create_tooltip_manager = (doc: Document, on_empty: () => void) => { stop_open_effects = [ auto_update_position(opening.trigger, surface, position_active), register_escape_layer((event) => { - if (!active?.open) return true + if (!active?.open) return false event.preventDefault() event.stopPropagation() request_close(`escape`, true) return true }), ] - position_active() - if (active !== opening || !opening.open) return + // Before positioning, which may hide again and must then report a close that + // follows an open rather than one out of nowhere. options.on_open_change?.(true, { trigger: opening.trigger, reason }) + position_active() } const request_open = (reason: `pointer` | `focus` | `controlled`): void => { + // Re-entering during the close delay supersedes the pending close. + clear_close_timeout() if (!active || active.phase === `dismissed` || active.open) return clear_open_timeout() const { options } = active.registration diff --git a/tests/vitest/attachments/draggable.test.ts b/tests/vitest/attachments/draggable.test.ts index a9ceb556..b8d4608f 100644 --- a/tests/vitest/attachments/draggable.test.ts +++ b/tests/vitest/attachments/draggable.test.ts @@ -131,6 +131,24 @@ describe(`draggable`, () => { }, ) + it.each([`relative`, `static`] as const)( + `drags an in-flow %s node from its insets, not its offset`, + (position) => { + const element = create_element(`div`, { position, left: `5px` }) + Object.defineProperties(element, { + offsetLeft: { value: 25, configurable: true }, + offsetTop: { value: 35, configurable: true }, + }) + attach_draggable(element) + element.dispatchEvent(pointer_event(`pointerdown`, 0, 0)) + globalThis.dispatchEvent(pointer_event(`pointermove`, 10, 10)) + + expect(element.style.position).toBe(`relative`) + expect([element.style.left, element.style.top]).toEqual([`15px`, `10px`]) + globalThis.dispatchEvent(pointer_event(`pointerup`, 10, 10)) + }, + ) + it(`ignores element bounds that generate no box`, () => { const parent = create_element() mock_rect(parent, { left: 0, top: 0, width: 0, height: 0 }) diff --git a/tests/vitest/attachments/portal.test.ts b/tests/vitest/attachments/portal.test.ts index 6a86260e..13e42293 100644 --- a/tests/vitest/attachments/portal.test.ts +++ b/tests/vitest/attachments/portal.test.ts @@ -53,6 +53,17 @@ describe(`portal`, () => { expect(target.childNodes).toHaveLength(0) }) + it(`does not resurrect a node its block already removed`, () => { + const { home, target, node } = setup() + const cleanup = portal(target)(node) + + node.remove() // Svelte tears the block's DOM down before running teardown + cleanup?.() + + expect(node.parentElement).toBeNull() + expect(home.innerHTML).toBe(``) // anchor gone too + }) + it(`restores into a detached home rather than dropping the node`, () => { const { home, target, node } = setup() const cleanup = portal(target)(node) diff --git a/tests/vitest/attachments/tooltip.test.ts b/tests/vitest/attachments/tooltip.test.ts index 53c17aa8..689729d1 100644 --- a/tests/vitest/attachments/tooltip.test.ts +++ b/tests/vitest/attachments/tooltip.test.ts @@ -298,6 +298,12 @@ describe(`tooltip manager`, () => { expect(tooltip_el.hidden).toBe(false) surface_pointer(`pointerleave`) + vi.advanceTimersByTime(50) + pointer_over(element) // back onto the trigger inside the delay cancels the close + vi.advanceTimersByTime(100) + expect(tooltip_el.hidden).toBe(false) + + pointer_out(element) vi.advanceTimersByTime(100) expect(tooltip_el.hidden).toBe(true) }) @@ -744,6 +750,7 @@ describe(`tooltip manager`, () => { expect(content_el.style.overflowY).toBe(`auto`) expect(tooltip_el.querySelectorAll(`[class^="custom-tooltip-arrow"]`)).toHaveLength(1) expect(arrow.style.border).toBe(`2px solid rgb(4, 5, 6)`) + expect(arrow.style.clipPath).toBe(`polygon(0 0, 100% 0, 100% 100%)`) expect(Number(tooltip_el.style.left.replace(/px$/u, ``))).toBeLessThan(860) expect(Number(arrow.style.left.replace(/px$/u, ``))).toBeGreaterThan(150) }) @@ -818,6 +825,18 @@ describe(`tooltip manager`, () => { expect([tooltip_el.hidden, tooltip_el.style.display]).toEqual([true, `none`]) }) + it(`falls back to absolute positioning without the Popover API`, () => { + cleanups.push(stub_prop(HTMLElement.prototype, `showPopover`, undefined)) + const { element } = register_tooltip(`No popover`, { strategy: `top-layer` }) + pointer_over(element) + const tooltip_el = visible_tooltip() + + expect(tooltip_el.hasAttribute(`popover`)).toBe(false) + expect(tooltip_el.style.position).toBe(`absolute`) + pointer_out(element) + expect(tooltip_el.hidden).toBe(true) + }) + it(`propagates a top-layer Popover API failure`, () => { const show_error = new Error(`showPopover failed`) cleanups.push( From 243b4b43a3adc49186e4c4381f84e358a2ed0339 Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Tue, 25 Aug 2026 09:47:48 +0100 Subject: [PATCH 05/11] Fix CodeEditor selection mapping, Shift+Tab focus loss and resync snapshot - model.transact maps the selection through edits when none is given instead of throwing past the new end - Shift+Tab on an unindented line no longer moves focus out of the editor - resync snapshots revision/text at enqueue time so queued edits apply cleanly - DOM selection is only re-set when it changed, so IME composition survives - plain Map/Set for constant lookup tables --- src/lib/code-editor/CodeEditor.svelte | 6 ++++- src/lib/code-editor/highlight-client.ts | 10 ++++---- src/lib/code-editor/languages.ts | 6 ++--- src/lib/code-editor/model.ts | 18 ++++++++++++++- .../code-editor-highlight-client.test.ts | 23 +++++++++++++++---- tests/vitest/code-editor-model.test.ts | 11 +++++++++ tests/vitest/code-editor.svelte.test.ts | 3 +++ 7 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/lib/code-editor/CodeEditor.svelte b/src/lib/code-editor/CodeEditor.svelte index dabe4c3b..4e4f216d 100644 --- a/src/lib/code-editor/CodeEditor.svelte +++ b/src/lib/code-editor/CodeEditor.svelte @@ -162,8 +162,11 @@ const sync_dom_from_model = (update: EditorUpdate): void => { const area = textarea if (!area || local_model_update) return + const { anchor, head } = selection_of(area) + const moved = anchor !== update.selection.anchor || head !== update.selection.head if (update.transaction) area.value = model.text() - set_dom_selection(area, update.selection) + // Re-selecting an unchanged range would still cancel an active IME composition. + if (update.transaction || moved) set_dom_selection(area, update.selection) area.scrollTop = scroll_top area.scrollLeft = scroll_left } @@ -533,6 +536,7 @@ selection_end: area.selectionEnd, } if (event.key === `Tab`) { + event.preventDefault() // a no-op dedent must not move focus apply_command( event, (event.shiftKey ? dedent_selection : indent_selection)(state, indent), diff --git a/src/lib/code-editor/highlight-client.ts b/src/lib/code-editor/highlight-client.ts index 4055bac1..bd2ed6ea 100644 --- a/src/lib/code-editor/highlight-client.ts +++ b/src/lib/code-editor/highlight-client.ts @@ -93,16 +93,14 @@ export const create_highlight_client = (options: HighlightClientOptions) => { const pending = queue .splice(queue_head) .filter((task) => task.kind !== `resync` && task.kind !== `edit`) + // Snapshot now: edits queued behind this task expect the backend at this revision. + const args = { docId: doc_id, revision: model.revision, text: model.text() } const task: QueuedTask = { kind: `resync`, run: async () => { - const revision = model.revision + const { revision } = args try { - const applied = await backend.set_text({ - docId: doc_id, - revision, - text: model.text(), - }) + const applied = await backend.set_text(args) if (applied !== revision) throw new Error( `Backend resync revision mismatch: expected ${revision}, received ${applied}`, diff --git a/src/lib/code-editor/languages.ts b/src/lib/code-editor/languages.ts index bdcc02ad..dd5497fa 100644 --- a/src/lib/code-editor/languages.ts +++ b/src/lib/code-editor/languages.ts @@ -1,8 +1,6 @@ // Filename-driven defaults used by CodeEditor keyboard commands. Backends remain the // source of truth for grammar detection and the language label. -import { SvelteMap, SvelteSet } from 'svelte/reactivity' - const COMMENT_TOKEN_GROUPS: readonly (readonly [string, string])[] = [ [ `#`, @@ -28,12 +26,12 @@ const COMMENT_TOKEN_BASENAMES = `.bash_profile .bashrc .dockerignore .env .gitat const words = (list: string) => list.trim().split(/\s+/) -const token_by_extension = new SvelteMap( +const token_by_extension = new Map( COMMENT_TOKEN_GROUPS.flatMap(([token, extensions]) => words(extensions).map((extension): [string, string] => [extension, token]), ), ) -const hash_comment_basenames = new SvelteSet(words(COMMENT_TOKEN_BASENAMES)) +const hash_comment_basenames = new Set(words(COMMENT_TOKEN_BASENAMES)) export const line_comment_token = (filename: string): string | null => { const basename = filename.toLowerCase().split(/[\\/]/u).at(-1) ?? `` diff --git a/src/lib/code-editor/model.ts b/src/lib/code-editor/model.ts index a939f5dc..3c497faa 100644 --- a/src/lib/code-editor/model.ts +++ b/src/lib/code-editor/model.ts @@ -195,6 +195,22 @@ interface HistoryGroup { const same_selection = (left: EditorSelection, right: EditorSelection): boolean => left.anchor === right.anchor && left.head === right.head const copy_selection = (selection: EditorSelection): EditorSelection => ({ ...selection }) +// Edits are sequential (each in the coordinates left by the previous one), so an offset +// inside a replaced range lands after its replacement and later offsets shift once. +const map_offset = (offset: number, edits: readonly TextEdit[]): number => { + for (const { from, to, insert } of edits) { + if (offset <= from) break + offset = offset <= to ? from + insert.length : offset + insert.length - (to - from) + } + return offset +} +const map_selection = ( + { anchor, head }: EditorSelection, + edits: readonly TextEdit[], +): EditorSelection => ({ + anchor: map_offset(anchor, edits), + head: map_offset(head, edits), +}) const merge_typed_records = ( previous: HistoryRecord, next: HistoryRecord, @@ -374,7 +390,7 @@ export const create_editor_model = (init: EditorModelInit): EditorModel => { if (add_to_history && !Number.isFinite(timestamp)) throw new Error(`Invalid history timestamp=${timestamp}`) const source = options.source ?? `external` - const next_selection = options.selection ?? selection + const next_selection = options.selection ?? map_selection(selection, edits) const { transaction, record, cost } = apply( edits, next_selection, diff --git a/tests/vitest/code-editor-highlight-client.test.ts b/tests/vitest/code-editor-highlight-client.test.ts index e501461c..8aa24597 100644 --- a/tests/vitest/code-editor-highlight-client.test.ts +++ b/tests/vitest/code-editor-highlight-client.test.ts @@ -1,6 +1,6 @@ import { create_highlight_client } from '$lib/code-editor/highlight-client' import { create_editor_model } from '$lib/code-editor/model' -import type { ApplyEditsArgs, EditorBackend } from '$lib/code-editor/types' +import type { ApplyEditsArgs, EditorBackend, SetTextArgs } from '$lib/code-editor/types' import { afterEach, expect, test, vi } from 'vite-plus/test' const OPEN_RESULT = { language: `typescript`, highlightable: true, editable: true } @@ -83,14 +83,27 @@ test.each([`reject`, `wrong revision`] as const)( await current.client.open() edit_model(current, 0, `a`) edit_model(current, 1, `b`) + const resync_result = Promise.withResolvers() + current.backend.set_text = vi.fn((args: SetTextArgs) => { + current.resyncs.push(args) + return resync_result.promise + }) if (failure === `reject`) first_result.reject(new Error(`desync`)) else first_result.resolve(99) + await vi.waitFor(() => expect(current.resyncs).toHaveLength(1)) + // An edit made while the resync is in flight must apply on top of the snapshot. + edit_model(current, 2, `c`) + resync_result.resolve(2) await current.client.settled() - expect(current.resyncs).toEqual([ - { docId: `doc`, revision: 2, text: current.model.text() }, - ]) - expect(current.backend.apply_edits).toHaveBeenCalledOnce() + expect(current.resyncs).toEqual([{ docId: `doc`, revision: 2, text: `abone\ntwo` }]) + expect(current.backend.apply_edits).toHaveBeenCalledTimes(2) + expect(current.backend.apply_edits).toHaveBeenLastCalledWith( + expect.objectContaining({ + baseRevision: 2, + edits: [{ from: 2, to: 2, insert: `c` }], + }), + ) expect(current.errors).not.toHaveBeenCalled() }, ) diff --git a/tests/vitest/code-editor-model.test.ts b/tests/vitest/code-editor-model.test.ts index 522b15e5..3da86f77 100644 --- a/tests/vitest/code-editor-model.test.ts +++ b/tests/vitest/code-editor-model.test.ts @@ -176,6 +176,17 @@ test.each([ expect(() => model.transact(edits)).toThrow(/Invalid edit/u) expect(model.text()).toBe(`abc`) }) +test(`omitted selections map through sequential edits`, () => { + const model = create_editor_model({ uri: `memory:map`, text: `abcdef` }) + model.set_selection({ anchor: 1, head: 5 }) + model.transact([ + { from: 0, to: 2, insert: `XYZ` }, + { from: 4, to: 5, insert: `` }, + ]) + expect([model.text(), model.selection]).toEqual([`XYZcef`, { anchor: 3, head: 5 }]) + model.transact([{ from: 0, to: 6, insert: `` }]) + expect(model.selection).toEqual({ anchor: 0, head: 0 }) +}) test(`invalid resulting selections leave the model unchanged`, () => { const model = create_editor_model({ uri: `memory:invalid-selection`, text: `abc` }) expect(() => diff --git a/tests/vitest/code-editor.svelte.test.ts b/tests/vitest/code-editor.svelte.test.ts index 2e79f3a7..4ed268fc 100644 --- a/tests/vitest/code-editor.svelte.test.ts +++ b/tests/vitest/code-editor.svelte.test.ts @@ -136,6 +136,9 @@ test(`native input, selection, history, commands, and backend deltas share the m expect(textarea.value.startsWith(` // \n const`)).toBe(true) expect(model.dirty).toBe(true) expect(on_update).toHaveBeenCalled() + textarea.setSelectionRange(textarea.value.length, textarea.value.length) + const no_op_dedent = press_key(textarea, `Tab`, { shiftKey: true }) + expect([no_op_dedent.defaultPrevented, model.revision]).toEqual([true, 11]) const parent_escape = vi.fn(() => true) const unregister = register_escape_layer(parent_escape) onTestFinished(unregister) From b2c718f9c6df263b903a817b1f386e8164ea0595 Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Tue, 25 Aug 2026 09:47:55 +0100 Subject: [PATCH 06/11] Decode HTML entities in build-time heading ids; plain Map/Set for bookkeeping mdsvex escapes &, <, { } in headings, so ids contained 'amp' and char codes and disagreed with the client-side slug. Clipboard timers and the recent-list dedupe set are not UI state and no longer use reactive collections. --- src/lib/clipboard.svelte.ts | 22 ++++++++--------- src/lib/heading-anchors.ts | 36 +++++++++++++++++++++------- src/lib/storage.ts | 3 +-- tests/vitest/heading-anchors.test.ts | 4 ++++ 4 files changed, 42 insertions(+), 23 deletions(-) diff --git a/src/lib/clipboard.svelte.ts b/src/lib/clipboard.svelte.ts index 2f351130..6fd8292b 100644 --- a/src/lib/clipboard.svelte.ts +++ b/src/lib/clipboard.svelte.ts @@ -1,5 +1,4 @@ -import { untrack } from 'svelte' -import { SvelteMap, SvelteSet } from 'svelte/reactivity' +import { SvelteSet } from 'svelte/reactivity' // Headless "recently copied" state for UIs that render their own copy affordances, where // CopyButton's markup would be in the way: a table of values each with its own checkmark, @@ -22,17 +21,16 @@ export const create_clipboard_feedback = ( on_error?: (error: unknown, text: string) => void, ): ClipboardFeedback => { const copied = new SvelteSet() - const timers = new SvelteMap>() + // plain Map: timer bookkeeping is not UI state and must not subscribe callers + const timers = new Map>() - // Timer bookkeeping must not subscribe an effect that calls clear(). - const clear = (key?: string): void => - untrack(() => { - for (const timer_key of key === undefined ? timers.keys() : [key]) { - clearTimeout(timers.get(timer_key)) - timers.delete(timer_key) - copied.delete(timer_key) - } - }) + const clear = (key?: string): void => { + for (const timer_key of key === undefined ? [...timers.keys()] : [key]) { + clearTimeout(timers.get(timer_key)) + timers.delete(timer_key) + copied.delete(timer_key) + } + } const copy = async (text: string, key: string = text): Promise => { try { diff --git a/src/lib/heading-anchors.ts b/src/lib/heading-anchors.ts index ea4e8af3..a14bdb75 100644 --- a/src/lib/heading-anchors.ts +++ b/src/lib/heading-anchors.ts @@ -187,6 +187,27 @@ const get_static_id_attr = (attrs: string): string | undefined => { return undefined } +const NAMED_ENTITIES: Record = { + amp: `&`, + lt: `<`, + gt: `>`, + quot: `"`, + apos: `'`, + nbsp: ` `, +} + +// mdsvex escapes `&`, `<`, `{` and friends in text, so `Using {foo}` arrives as +// `Using {foo}`; slugging the raw source would bake `123` into the id. +const decode_entities = (html: string): string => + html.replaceAll( + /&(?:#x(?[0-9a-f]+)|#(?\d+)|(?[a-z]+));/giu, + (entity, hex?: string, dec?: string, name?: string) => { + if (name) return NAMED_ENTITIES[name.toLowerCase()] ?? entity + const code_point = hex ? Number.parseInt(hex, 16) : Number(dec) + return code_point <= 0x10ffff ? String.fromCodePoint(code_point) : entity + }, + ) + const extract_math_sources = (inner: string): string => inner.replaceAll(html_string_expression_regex, (expression, json: string) => { let html: unknown @@ -197,13 +218,7 @@ const extract_math_sources = (inner: string): string => } if (typeof html !== `string`) return expression const tex = katex_annotation_regex.exec(html)?.groups?.tex - return ( - tex - ?.replaceAll(`<`, `<`) - .replaceAll(`>`, `>`) - .replaceAll(`&`, `&`) - .replaceAll(/[{}]/gu, ``) ?? expression - ) + return tex ? decode_entities(tex).replaceAll(/[{}]/gu, ``) : expression }) // Preserve Unicode letters and marks, normalize equivalent spellings to NFC, and turn @@ -236,8 +251,11 @@ export function heading_ids() { const insertions: TextInsertion[] = [] const get_heading_id = (inner: string): string | null => { - const text = strip_svelte_expressions( - extract_math_sources(inner).replaceAll(/<[^>]+>/gu, ``), + // decode last so `<b>` stays text rather than becoming a stripped tag + const text = decode_entities( + strip_svelte_expressions( + extract_math_sources(inner).replaceAll(/<[^>]+>/gu, ``), + ), ).trim() if (!text) return null diff --git a/src/lib/storage.ts b/src/lib/storage.ts index 4a1f3755..695f42af 100644 --- a/src/lib/storage.ts +++ b/src/lib/storage.ts @@ -1,7 +1,6 @@ // Best-effort localStorage: disabled/private/full stores become no-ops so in-memory UI // state still works. Also provides persisted choices and MRU lists. -import { SvelteSet } from 'svelte/reactivity' import { clamp_integer, is_object } from './utils' // Single catch-all for storage failures; callers retain working state in memory. @@ -76,7 +75,7 @@ export const create_recent_list = (config: RecentListConfig) => { load: (): T[] => { const parsed: unknown = storage_get_json(storage_key, []) if (!Array.isArray(parsed)) return [] - const seen_keys = new SvelteSet() + const seen_keys = new Set() return parsed .filter(is_valid) .filter((item) => { diff --git a/tests/vitest/heading-anchors.test.ts b/tests/vitest/heading-anchors.test.ts index 03310d1e..ea837f1c 100644 --- a/tests/vitest/heading-anchors.test.ts +++ b/tests/vitest/heading-anchors.test.ts @@ -108,6 +108,10 @@ describe(`heading_ids preprocessor`, () => { `

Using someFunction

`, `

Using someFunction

`, ], + // entities decode to what the browser renders (mdsvex escapes `&`, `<`, `{`) + [`

Foo & Bar

`, `

Foo & Bar

`], + [`

Using {foo}

`, `

Using {foo}

`], + [`

<b>x 😀

`, `

<b>x 😀

`], ])(`%s → %s`, (input: string, expected: string) => { expect(preprocess(input).code).toBe(expected) }) From c213cc0ababc7e98907482830cdc75acda30d56a Mon Sep 17 00:00:00 2001 From: Janosh Riebesell Date: Tue, 25 Aug 2026 09:48:00 +0100 Subject: [PATCH 07/11] Fix focus, dismissal and re-render bugs in Nav, MultiSelect, Dialog and more - Nav: submenu arrow keys work from links (one handler on the dropdown, wraps, Home/End), Escape returns focus to the toggle; no desktop flash on phones during hydration; finger-sized burger; scrollable mobile menu - MultiSelect: autoActiveFirstOption only while open; Escape/Tab stop propagating only while open - Dialog: backdrop click closes without native closedby support - CopyButton global: coexist with a second instance instead of looping, unmount buttons for removed blocks, copy live content - Toast: dismissing a focused toast no longer leaves the next one paused - FileDetails: highlight each file once instead of re-requesting siblings - DraggablePane: closing returns focus to the toggle - Toc: Escape passes through when nothing is open - Popover: consumer ontoggle is chained, not overwritten --- src/lib/CopyButton.svelte | 77 ++++++++--------- src/lib/Dialog.svelte | 16 ++-- src/lib/DraggablePane.svelte | 2 + src/lib/FileDetails.svelte | 15 ++-- src/lib/MultiSelect.svelte | 8 +- src/lib/Nav.svelte | 86 ++++++++----------- src/lib/Popover.svelte | 2 +- src/lib/SettingsSearch.svelte | 3 +- src/lib/Toast.svelte | 6 +- src/lib/Toc.svelte | 10 ++- tests/vitest/CopyButton.test.ts | 46 +++++++--- tests/vitest/Dialog.svelte.test.ts | 3 +- tests/vitest/DraggablePane.test.ts | 12 +++ tests/vitest/FileDetails.svelte.test.ts | 19 ++++ tests/vitest/MultiSelect.a11y.svelte.test.ts | 12 ++- .../MultiSelect.keyboard.svelte.test.ts | 24 ++++++ tests/vitest/Nav.test.ts | 15 ++-- tests/vitest/toast.svelte.test.ts | 20 +++++ tests/vitest/toc.svelte.test.ts | 11 +++ 19 files changed, 251 insertions(+), 136 deletions(-) diff --git a/src/lib/CopyButton.svelte b/src/lib/CopyButton.svelte index 478a9358..7a442a1e 100644 --- a/src/lib/CopyButton.svelte +++ b/src/lib/CopyButton.svelte @@ -12,7 +12,7 @@ let { content = ``, - state = $bindable(`ready`), + state: copy_state = $bindable(`ready`), disabled = false, reset_ms = 2000, on_copy_success = (_content: string) => {}, @@ -44,49 +44,52 @@ } = $props() const copy_button_selector = `[data-sms-copy]` - const action_labels = $derived({ ...labels, pending: labels[state] }) + const action_labels = $derived({ ...labels, pending: labels[copy_state] }) $effect(() => { if (!global && !global_selector) return type MountedCopyButton = Parameters[0] - const mounted_copy_buttons: { pre: HTMLElement; component: MountedCopyButton }[] = [] + type Entry = { + code: Element + props: { content: string } + component: MountedCopyButton + } + const mounted_copy_buttons: Entry[] = [] const apply_copy_buttons = () => { + // release buttons whose code block left the document; re-read the rest so the + // button copies what the block shows now, not what it showed when mounted + for (const entry of mounted_copy_buttons.splice(0)) { + if (!entry.code.isConnected) void unmount(entry.component) + else { + entry.props.content = entry.code.textContent ?? `` + mounted_copy_buttons.push(entry) + } + } const style = `position: absolute; top: 6pt; inset-inline-end: 6pt; ${ rest.style ?? `` }` const skip_sel = skip_selector ?? as for (const code of document.querySelectorAll(global_selector ?? `pre > code`)) { const pre = code.parentElement - if (!pre) continue - const existing_copy_button = pre.querySelector(copy_button_selector) - const already_mounted = mounted_copy_buttons.some((entry) => entry.pre === pre) - // If a stale button from a previous effect pass still exists, remove it synchronously - // so this pass can mount a fresh button with updated props/callbacks. - if ( - existing_copy_button && - (!already_mounted || existing_copy_button.localName !== as) - ) { - existing_copy_button.remove() - } - if (existing_copy_button?.isConnected) continue + // Any existing copy button wins, including one from a second global instance: + // replacing it would have both instances swap buttons in an endless observer loop. + if (!pre || pre.querySelector(copy_button_selector)) continue if (skip_sel && pre.querySelector(skip_sel)) continue - const mounted_copy_button = mount(Self, { - target: pre, - props: { - content: code.textContent ?? ``, - as, - labels, - disabled, - reset_ms, - on_copy_success, - on_copy_error, - ...rest, - style, - }, + const props = $state({ + content: code.textContent ?? ``, + as, + labels, + disabled, + reset_ms, + on_copy_success, + on_copy_error, + ...rest, + style, }) - mounted_copy_buttons.push({ pre, component: mounted_copy_button }) + const component = mount(Self, { target: pre, props }) + mounted_copy_buttons.push({ code, props, component }) } } @@ -95,30 +98,26 @@ observer.observe(document.body, { childList: true, subtree: true }) return () => { observer.disconnect() - for (const { pre, component } of mounted_copy_buttons) { - // unmount() is async; remove marker node now to avoid blocking remount on next effect run. - pre.querySelector(copy_button_selector)?.remove() - void unmount(component) - } + for (const { component } of mounted_copy_buttons) void unmount(component) } }) const handle_action_state = (next_state: ActionState): void => { - if (next_state !== `pending`) state = next_state + if (next_state !== `pending`) copy_state = next_state } {#snippet copy_content({ state: action_state, disabled }: ActionButtonContent)} - {@const copy_state = action_state === `pending` ? state : action_state} - {@const { text, icon } = labels[copy_state]} - {@render copy_children?.({ state: copy_state, icon, text, disabled })} + {@const shown_state = action_state === `pending` ? copy_state : action_state} + {@const { text, icon } = labels[shown_state]} + {@render copy_children?.({ state: shown_state, icon, text, disabled })} {/snippet} {#if !(global || global_selector)} navigator.clipboard.writeText(content)} - {state} + state={copy_state} disabled={disabled || !content} {reset_ms} {as} diff --git a/src/lib/Dialog.svelte b/src/lib/Dialog.svelte index f9b4f3d4..5b34d322 100644 --- a/src/lib/Dialog.svelte +++ b/src/lib/Dialog.svelte @@ -34,9 +34,7 @@ const effective_closedby = $derived( closedby ?? (close_on_escape ? (close_on_backdrop ? `any` : `closerequest`) : `none`), ) - const custom_backdrop_dismiss = $derived( - closedby === undefined && close_on_backdrop && !close_on_escape, - ) + const dismiss_on_backdrop = $derived(closedby ? closedby === `any` : close_on_backdrop) let focus_origin: HTMLElement | SVGElement | null = null let pending_close_via: DialogCloseVia | null = null let backdrop_press_started = false @@ -76,9 +74,15 @@ backdrop_press_started = event.isPrimary && is_dialog_backdrop_event(surface, event) } const track_backdrop_release = (event: MouseEvent) => { - if (backdrop_press_started && is_dialog_backdrop_event(surface, event)) { - if (custom_backdrop_dismiss) close(`pointer`) - else if (effective_closedby === `any`) pending_close_via = `pointer` + if ( + backdrop_press_started && + dismiss_on_backdrop && + is_dialog_backdrop_event(surface, event) + ) { + // Browsers without `closedby` support leave the surface open; those with it have + // already light-dismissed it and only need the reason recorded before `close` fires. + if (surface?.open) close(`pointer`) + else pending_close_via = `pointer` } backdrop_press_started = false } diff --git a/src/lib/DraggablePane.svelte b/src/lib/DraggablePane.svelte index 216d5760..4b065f20 100644 --- a/src/lib/DraggablePane.svelte +++ b/src/lib/DraggablePane.svelte @@ -110,6 +110,8 @@ resize_edges.includes(edge) ? `${resize_gutter_px}px` : null const close_pane = (via: CloseVia) => { + // display: none drops focus on the body; hand it back to the toggle instead + if (pane?.contains(document.activeElement)) toggle_btn?.focus() open = false on_close?.({ via }) } diff --git a/src/lib/FileDetails.svelte b/src/lib/FileDetails.svelte index 6b8503a5..a2c75a95 100644 --- a/src/lib/FileDetails.svelte +++ b/src/lib/FileDetails.svelte @@ -1,5 +1,5 @@ diff --git a/src/lib/MultiSelect.svelte b/src/lib/MultiSelect.svelte index 4deafb2e..99a59f5a 100644 --- a/src/lib/MultiSelect.svelte +++ b/src/lib/MultiSelect.svelte @@ -968,7 +968,9 @@ (!is_user_message_active && (current_option === undefined || is_disabled(current_option))) || (filter_changed && !option_changed) - if (autoActiveFirstOption && should_auto_activate) { + // only while open: a collapsed combobox with an active option would select it on + // Enter instead of reopening, and point aria-activedescendant at a hidden row + if (autoActiveFirstOption && open && should_auto_activate) { const first_enabled_idx = rendered_options.findIndex( (candidate) => !is_disabled(candidate), ) @@ -1435,7 +1437,9 @@ highlighted_idx = null if (event.key === `Escape` || event.key === `Tab`) { - event.stopPropagation() + // a closed dropdown has nothing to dismiss, so the key belongs to the enclosing + // dialog/pane + if (open) event.stopPropagation() close_and_clear(event) } else if (event.key === `Enter`) { event.stopPropagation() diff --git a/src/lib/Nav.svelte b/src/lib/Nav.svelte index c6c2fa0e..eff70a21 100644 --- a/src/lib/Nav.svelte +++ b/src/lib/Nav.svelte @@ -10,7 +10,7 @@ import { click_outside, focus_trap, tooltip } from './attachments/index' import Icon from './Icon.svelte' import type { NavRoute, NavRouteObject } from './types' - import { chain_handlers } from './utils' + import { chain_handlers, step_focus } from './utils' type NavLinkRouteObject = NavRouteObject & { href: string } @@ -71,9 +71,9 @@ let is_open = $state(false) let hovered_dropdown = $state(null) let pinned_dropdown = $state(null) - let focused_item_index = $state(-1) let is_touch_device = $state(false) - let viewport_width = $state(Infinity) + // Start from the real width on the client so hydration doesn't flash the desktop nav on phones + let viewport_width = $state(globalThis.innerWidth ?? Infinity) let is_mobile = $derived(viewport_width <= breakpoint) let hide_timeout: ReturnType | null = null // `$props.id()` survives hydration; a random uuid would mismatch aria-controls @@ -107,7 +107,6 @@ is_open = false hovered_dropdown = null pinned_dropdown = null - focused_item_index = -1 } // Query the submenu links / toggle button of the dropdown for a given route href @@ -124,7 +123,6 @@ const is_opening = pinned_dropdown !== href pinned_dropdown = is_opening ? href : null hovered_dropdown = is_opening ? href : null - focused_item_index = is_opening && focus_first ? 0 : -1 if (is_opening && focus_first) { setTimeout(() => dropdown_links(href)[0]?.focus(), 0) } @@ -149,44 +147,27 @@ if (event.key === `Escape`) close_menus() } - function handle_dropdown_keydown( - event: KeyboardEvent, - href: string, - sub_routes: string[], - ) { - const { key } = event - - if (key === `Enter` || key === ` `) { - event.preventDefault() - toggle_dropdown(href, true) - return - } - - const is_dropdown_open = hovered_dropdown === href || pinned_dropdown === href - // Arrow key navigation within open dropdown - if (is_dropdown_open && (key === `ArrowDown` || key === `ArrowUp`)) { - event.preventDefault() - const direction = key === `ArrowDown` ? 1 : -1 - focused_item_index = Math.max( - 0, - Math.min(sub_routes.length - 1, focused_item_index + direction), - ) - dropdown_links(href)[focused_item_index]?.focus() - } + const is_dropdown_open = (href: string) => + hovered_dropdown === href || pinned_dropdown === href - // Open dropdown with ArrowDown when closed - if (!is_dropdown_open && key === `ArrowDown`) { - event.preventDefault() - toggle_dropdown(href, true) - } + function handle_toggle_keydown(event: KeyboardEvent, href: string) { + const { key } = event + const opens = + key === `Enter` || key === ` ` || (key === `ArrowDown` && !is_dropdown_open(href)) + if (!opens) return + event.preventDefault() + toggle_dropdown(href, true) } - function handle_dropdown_item_keydown(event: KeyboardEvent, href: string) { + // On the whole dropdown, so arrows keep working after focus has moved from the toggle + // onto a link and Escape hands focus back to the toggle from anywhere inside. + function handle_dropdown_keydown(event: KeyboardEvent, href: string) { + if (!is_dropdown_open(href)) return if (event.key === `Escape`) { event.preventDefault() close_menus() dropdown_toggle(href)?.focus() - } + } else step_focus(event, [...dropdown_links(href)]) } function is_current(path: string | undefined) { @@ -266,12 +247,6 @@ if (target instanceof Element && target.closest(`[data-dropdown-toggle]`)) return open_dropdown(href) } - const dropdown_item_keydown_handler = (parent_href: string) => - chain_handlers( - (event: KeyboardEvent) => handle_dropdown_item_keydown(event, parent_href), - link_props?.onkeydown, - ) - function get_external_attrs(route: NavRouteObject) { if (!route.external) return {} return { target: `_blank`, rel: `noopener noreferrer` } @@ -367,13 +342,15 @@ (route) => route !== parsed_route.href, )} {@const is_pinned = pinned_dropdown === parsed_route.href} - {@const is_dropdown_open = hovered_dropdown === parsed_route.href || is_pinned} + {@const dropdown_open = is_dropdown_open(parsed_route.href)}
open_dropdown(parsed_route.href, true)} onmouseleave={() => schedule_hide(parsed_route.href, is_pinned)} + onkeydown={(event: KeyboardEvent) => + handle_dropdown_keydown(event, parsed_route.href)} onfocusin={dropdown_focusin_handler(parsed_route.href)} onfocusout={(event: FocusEvent) => { if ( @@ -414,21 +391,21 @@ {/if}
open_dropdown(parsed_route.href, true)} @@ -464,7 +441,6 @@ aria-current={is_current(child_href)} style={`${child_formatted.style}; ${link_props?.style ?? ``}`} onclick={link_click_handler({ href: child_href })} - onkeydown={dropdown_item_keydown_handler(parsed_route.href)} {@attach child_tooltip} > {@html child_formatted.label} @@ -689,16 +665,20 @@ inset-inline-start: 1rem; flex-direction: column; justify-content: space-around; + /* 1.4rem bars inside a ~2.4rem hit area: a finger-sized target without moving the bars */ width: 1.4rem; height: 1.4rem; + box-sizing: content-box; + padding: 0.5rem; + margin: -0.5rem; background: transparent; - padding: 0; z-index: var(--nav-toggle-btn-z-index, 10); } .burger span { width: 100%; height: 0.18rem; - background-color: var(--text); + /* hosts that don't define --text still get visible bars */ + background-color: var(--text, currentColor); border-radius: 8pt; transition: opacity 0.2s linear, @@ -732,10 +712,16 @@ visibility 0.3s ease; z-index: var(--nav-mobile-z-index, 2); flex-direction: column; + /* one scrollable column: with several submenus expanded the menu outgrows a phone + screen, and wrapping would spill entries into a second column off the panel */ + flex-wrap: nowrap; align-items: stretch; justify-content: start; gap: 0.2em; max-width: 90vw; + max-height: calc(100dvh - 4rem); + overflow-y: auto; + overscroll-behavior: contain; border-radius: 6pt; } nav.mobile .menu.open { diff --git a/src/lib/Popover.svelte b/src/lib/Popover.svelte index bc6f3fab..7cb03521 100644 --- a/src/lib/Popover.svelte +++ b/src/lib/Popover.svelte @@ -230,7 +230,7 @@ {role} aria-label={rest[`aria-label`] ?? (rest[`aria-labelledby`] ? undefined : `Popover`)} class={[`popover`, rest.class]} - ontoggle={handle_native_toggle} + ontoggle={chain_handlers(handle_native_toggle, rest.ontoggle)} {@attach show_native_popover} {@attach float({ anchor, placement, align, offset, padding, match_width, strategy })} {@attach native_dismiss diff --git a/src/lib/SettingsSearch.svelte b/src/lib/SettingsSearch.svelte index 1f3a7662..fd34be46 100644 --- a/src/lib/SettingsSearch.svelte +++ b/src/lib/SettingsSearch.svelte @@ -1,7 +1,6 @@