zotero/scss/components/_item-tree.scss
Bogdan Abaev fff76fcbfc Best Match: hybrid lexical and semantic search
Best Match becomes a ranked search over everything in a library: its
items' metadata, notes and annotations, and the full text of its
attachments. A lexical engine (Zotero.Lexical) always ranks; with
semantic search enabled, the embedding engine ranks too and the two are
fused, so an item can match by its words, by its meaning, or -- ranking
highest -- by both. Matched passages are shown under their items, read
in the item pane, and opened in the reader at the passage. Attachment
vectors can come from the dataserver instead of being computed here.

1. Lexical engine (Zotero.Lexical, fulltext.js)

A word-level FTS5 index, ftindex.fulltextItemText (plus a CJK 2-gram
twin and a state table), holds each item's searchable text in per-type
columns: title and abstract for regular items, note text for notes, the
marked passage and comment for annotations. Regular items and
annotations index inline in their save transaction; notes are written
by the existing stale-flag queue alongside the trigram tables; a
backfill queue covers pre-existing items and joins the startup and
background drains. The trigram index answers substrings and inflates
counts, so it can't tell "fall" from "rainfall" or weigh a word by its
rarity; word-level FTS answers both with index probes.

Ranking is FTS5's BM25 over that index and the existing content index:
a query parses into word, phrase and CJK-run terms joined by OR, so a
document missing a word still ranks below one that has them all; term
weight comes from rarity in the user's own library, with no stoplist.
Consecutive words are added as phrase terms, item-text columns are
weighted (title 6, abstract 4, annotation 2, note 1), and an
attachment's full text counts only where enough of the query's terms
occur within 200 tokens of each other (FTS5 NEAR; 75% of them, so all
of them up to three), so one rare word can't carry a long document to
the top. Scores are divided by the most the expression could earn, so
both indexes report the 0-1 share of the query a document carries.

2. Fusion (Zotero.BestMatch)

Both engines score every candidate and their rankings are fused with
Reciprocal Rank Fusion; results are the union of the engines' matches.
Each engine's tail is cut against its own strongest match before fusion
(search.bestMatchMargin, percent, default 50, in the Advanced pane): a
library on one subject needs a tighter margin to separate a specific
answer from the field, a varied one hardly needs it. The Relevance bar
shows the item's strongest single piece of evidence, whichever engine
found it.

While the semantic index is still building, or semantic search is off,
Best Match degrades to lexical ranking rather than showing nothing, so
the quick-search mode is always offered and the "index not ready" state
is gone. Advanced Search's bestMatch condition scores through the same
facade. A temporary pref, search.bestMatchEngine (hybrid, lexical,
semantic), selects the engine for testing.

3. Attachments, structured text and chunks (Zotero.SDT)

Attachments are indexed on the structured text the document worker
extracts (PDF, EPUB, snapshot), cached as a pack per attachment. How a
pack is cut into chunks is moved to the structured-document-text
module. The chunker lives there so that the client, the dataserver's
indexer and anything else that embeds a document cut it the same way
and produce rows that can be exchanged. Each chunk carries an anchor --
page rects for PDFs, selectors for EPUBs and snapshots -- that locates
its text in the file across extractor versions, so a row cut elsewhere
stays usable when a local re-cut would block differently. Anchors are
stored as deflated JSON; chunk text is not stored, it's read back from
the pack.

The document-worker build bundles the chunker next to the pack reader
(structured-document-text.js and structured-document-text-chunker.js),
and Zotero requires both; on the main thread the chunker only cuts
plain text and reports its version. A large book takes hundreds of ms
to inflate and cut, so cutting (sdt.getChunks) and reading anchors back
with their reader positions (sdt.readAnchors) run in the document
worker, with no fallback here. A worker failure, or a chunker version
that disagrees with the bundled one, comes back as a failed cut rather
than a verdict on the attachment. On a worker error, the document
worker manager fails every pending request and starts a fresh worker
for the next one, instead of leaving the queue stuck behind an
unanswered request. MIN_CHUNKER_VERSION forces a re-cut of attachments
cut by an older chunker.

4. Model, vectors and calibration

One model, bekko-embedding-v1-a25m: multilingual, 8192-token window,
Matryoshka-trained so its 384-dimensional output is cut to 256. Two
runtime tokenizer defects are corrected -- the leading word marker the
runtime fails to add, and whitespace runs tokenized unlike the
reference; the model `revision` tracks changes that alter vectors.

Stored vectors are centered on the model's mean and quantized to int8
(the shared mean would otherwise spend most of the 8 bits), 256 bytes a
row, and scored by cosine in SQL. The mean, the score floor and the
bar's ceiling are measured by Calibration.record() over a corpus of
triples -- a query, a passage that answers it, a near miss from the same
field (resource/embeddings-calibration-corpus.json) -- and pasted into
the model config; the floor sits at the 95th percentile of near-miss
scores, the ceiling at the median of matches.

5. Indexing runs (Zotero.Embeddings.Indexing)

Nothing is queued. An item change or a finished sync kicks a run, and a
kick that lands during a run makes it go again. A run first indexes
locally the items, notes and annotations saved since the index last
looked at them -- a stamp other than the item's clientDateModified, or
a save in the last 5 s -- reading their text from the tables, then
takes every eligible attachment through five steps. Each step is one
pass over the outstanding attachments, 50 at a time by itemID as
full-text sync pages, and finds its own work in the index, so a run cut
short resumes where it was:

- reconcile: what's stored still holds. A file that's gone, or rows cut
  by a chunker before MIN_CHUNKER_VERSION, lose everything;
- extract: every attachment's text cached as a pack, the server's
  included, so no preview waits on an extraction.
- fetch: the server is asked for everything that's its.
- cut: this client's attachments are cut into pending rows with anchors.
  An attachment the worker couldn't cut is left as it is for a later
  run, since a plain-text fallback would stand in for the file for good;
  only an extraction failure falls back to plain text. Rows adopted for
  a file that has since arrived wait the same way.
- embed: every pending row gets a vector, pooled across attachments and
  pages.

The attachment work gives way to a kick, so a just-edited item is
searchable without waiting behind the library's documents, and to a
sync in progress. Items are loaded without caching and never more than
a page at a time. Startup and Resume clear the attachment stamps for a
full pass. Text too short to say anything is skipped: under two words
of title and abstract, under three of anything else.

The index is one table of rows (itemID, chunkIndex, embedding, anchor)
and one of per-item state (itemIndexState: sourceKey, contentHash,
extractor, clientDateModified, and the item's standing with the server);
counts are derived from rows. What a run is built on is declared beside
it: Sources (what each kind of item offers and what text it yields),
Progress (what the preferences pane reads, recomputed on a clock),
Store (the only code that writes the tables), Runtime (the machine's
say: a token budget per engine call halved under memory pressure, a
memory floor to start at all, the engine restarted to give memory back,
half the optimal thread count unless the prefs pane is open or the
system idle, and main-thread work paced against idle time), and
Diagnostics (rates and shape summaries of what Indexing records). The
engine, which the runtime terminates when idle, is checked and
recreated before each use.

6. Sync client (Zotero.Embeddings.Sync)

Available when the account syncs (pref embeddings.sync.enabled is an
override) and no endpoint is active -- configuring one says to embed
there instead. The server is asked about a stored file in a library
syncing with Zotero Storage, in sync or to download, that it hasn't
declined. Rows arriving before their file are adopted when it does.

GET users/{userID}/embeddings?model=M&itemKey=K1,K2,... returns
  { model, items: [
      { key, status: 'success', contentHash, chunks, version,
        rows: [{ chunkIndex, embedding, anchor }] },
      { key, status: 'declined' },
      { key, status: 'pending' } ] }

- success: rows replace what's stored, if contentHash matches the file
  here (or its plain text), rows number exactly `chunks`, and each row
  is well formed; otherwise declined. Rows already held from `version`
  are kept.
- declined: cut and embedded locally in the same run.
- pending, or a key missing from the reply: left as it is and asked
  again next run.
- A reply in another model declines the batch. A failed request stops
  the pipeline; the run goes back to the server after 5 minutes, or at
  once on Resume.

GET users/{userID}/embeddings?format=versions&model=M&since=V (with
If-Modified-Since-Version) returns { version, items: { key: version },
models }. After every sync, checkLibrary() asks for what changed since
the version it recorded and marks every key whose rows aren't from the
version named to be fetched again, declined ones included. The
library's version is recorded last, so a failure repeats the delta.

Server is expected to provide raw vector (not centered, not quantized)
so it does not have to worry about mean vector config.

7. Remote endpoint (Zotero.Embeddings.Endpoint)

Passages can be embedded by a server serving the same model -- a local
llama.cpp server in the OpenAI format, or Text Embeddings Inference --
configured from Settings -> Advanced. The server is trusted only
because its vectors match the local model's, never by name: verify()
embeds fixed texts both ways and stores a typed verdict (ok,
unreachable, unauthorized, not-embeddings, width-mismatch,
low-agreement, context-too-small) keyed to the URL, model version and
format. Every batch carries a sentinel text whose local vector is
cached, so a server switched to another model or pooling is caught on
that batch; three consecutive failures skip the endpoint for the rest
of the run. Queries always embed locally. The Configure Endpoint dialog
shows the facts the server must match and a copyable llama.cpp command.

8. Previews (Zotero.BestMatch.Session, item tree, item pane)

A Session scores a query and owns the passages its results matched in.
The chunk is the unit of a match for both engines: passages come from
the index's own rows read back by anchor, or, for an unindexed item,
from the same cut applied to its structured text (only where already
extracted; generating it costs seconds) or its plain text. An item
returns at most three quoted matches, each blending the model's score
with how much of the query the passage's own words carry. The quoted
line is chosen after ranking: the sentence the lexical engine picks
where the passage says the query's words; where it only means them,
the sentence a static multilingual model (potion-multilingual-128M, via
the runtime's static-embeddings backend) finds closest -- weaker than a
dense model, far faster, and never stored.

The item tree shows matches as two-line child rows -- where the passage
is (section path, page) and the line worth reading, with the query's
words marked -- and a new query shows its results from the top. The ten
best-ranked items' previews are derived before scoring resolves; the
rest are derived while the main thread is idle, paced against what each
costs, and arrive in batches. A changed item's previews are invalidated
and re-derived; the tree is no longer refreshed as embedding progresses,
which kept freezing it mid-search. Selecting an item shows every
passage it matched in a Search Results section of the item pane;
selecting match rows shows the passages themselves, grouped by
attachment, in a pane of their own. Double-click or Enter opens the
attachment at the passage, a PDF scrolled to and highlighting the
anchor's rects.

9. Preferences

The model menu is replaced by one switch, search.bestMatch.enableSemantic
(off: lexical ranking only, nothing indexed, what's indexed kept), and
the mode-change confirmations go with it. The pane shows two progress
bars -- metadata, notes and annotations; attachments -- with the step
under way ("Preparing documents", "Syncing semantic data… X / Y",
"Generating semantic data locally for N items"), the endpoint's status
and its Configure dialog, the quality cutoff, and a diagnostics panel,
hidden by default: throughput, inference speed, padding efficiency,
batches, engine threads and restarts, process memory and CPU, chunk
size distributions.

10. Build and dependencies

The document-worker submodule gains the sdt.getChunks and
sdt.readAnchors actions and builds the chunker as a second bundle next
to the pack reader. Zotero.ML allows the static-embeddings backend and
Mozilla's model hub. The embeddings database is at version 14 and is
rebuilt on upgrade.
2026-10-08 15:10:26 -07:00

606 lines
15 KiB
SCSS

@use "sass:map";
#zotero-items-pane-container {
flex-grow: 1;
flex-shrink: 1;
min-width: $min-width-items-pane;
height: 150px;
min-height: $min-height-items-pane;
}
#zotero-items-pane {
min-width: $min-width-items-pane;
}
// Indexing-progress banner above the column header while a best-match search
// runs against a partially built embeddings index
.virtualized-table .best-match-index-banner {
flex-shrink: 0;
padding: 4px 8px;
background: var(--material-sidepane);
border-bottom: var(--material-panedivider);
color: var(--fill-secondary);
text-align: center;
}
#zotero-items-tree {
.virtualized-table-body, .drag-image-container {
padding: 4px 8px 8px;
}
// With sticky section headers, the body can't have top padding: overflow clips at the
// padding box, so rows would scroll up into the padding and show above the pinned header
// (which sticks to the content-box top). Drop the top padding so rows clip exactly where the
// header pins, and keep the gap below the column header as a margin -- outside the scroll
// area, where rows can't reach it.
.virtualized-table-body.has-sticky-section-headers {
padding-top: 0;
margin-top: 4px;
}
.virtualized-table-body {
scrollbar-color: var(--color-scrollbar) var(--color-scrollbar-background);
}
.virtualized-table-header {
padding-inline-start: 8px;
padding-inline-end: calc(8px + var(--scrollbar-width, 0px));
box-sizing: border-box;
// A clear divider below the column headers (the default faint border is lost
// against the white section-header/spacer rows below it). The header already
// carries a second 1px line via ::after, so drop that one to avoid doubling.
border-bottom: var(--material-panedivider);
&::after {
border-bottom: none;
}
.cell.hasAttachment,
.cell.numNotes {
padding: 0;
text-align: center;
}
.cell:first-child {
&::before {
content: "";
display: inline-block;
width: 8px;
height: 100%;
background: transparent;
position: absolute;
inset-inline-start: -8px;
@include state(".cell:hover") {
background: var(--material-mix-quinary);
}
@include state(".cell.dragging") {
background: var(--material-mix-quarternary);
}
}
}
.cell:last-child {
&::after {
content: "";
display: inline-block;
width: calc(var(--scrollbar-width, 0px) + 8px);
height: 100%;
background: transparent;
position: absolute;
inset-inline-end: calc(-1 * var(--scrollbar-width, 0px) - 8px);
@include state(".cell:hover") {
background: var(--material-mix-quinary);
}
@include state(".cell.dragging") {
background: var(--material-mix-quarternary);
}
}
}
}
.virtualized-table, .drag-image-container {
.row {
// Header and spacer rows are always the pane background, never striped
&.odd:not(.selected):not(.library-header-row):not(.spacer-row) {
background-color: var(--material-stripe);
}
&.even:not(.selected) {
background-color: var(--material-background);
}
&.selected {
border-radius: 0;
&.first-selected {
border-top-left-radius: 5px;
border-top-right-radius: 5px;
}
&.last-selected {
border-bottom-left-radius: 5px;
border-bottom-right-radius: 5px;
}
}
.colored-tag-swatches {
display: flex;
:root:-moz-locale-dir(rtl) & {
transform: scaleX(-1);
flex-direction: row-reverse;
}
}
.icon-item-type + .tag-swatch,
.icon-item-type + .colored-tag-swatches,
.annotation-icon + .tag-swatch,
.annotation-icon + .colored-tag-swatches,
.retracted + .tag-swatch,
.retracted + .colored-tag-swatches {
margin-inline-end: 4px;
}
.emoji + .emoji,
.emoji + .colored-tag-swatches,
.colored-tag-swatches + .emoji {
margin-inline-end: 4px;
}
.tag-swatch {
display: inline-block;
&.colored {
-moz-context-properties: fill, fill-opacity, stroke, stroke-opacity;
width: 0.92307692em;
height: 0.92307692em;
background:
url("chrome://zotero/skin/tag-circle.svg") no-repeat center/100%,
url("chrome://zotero/skin/tag-circle-border.svg") no-repeat center/0;
fill: currentcolor;
@each $colorHex, $colorVar in $tagColorsLookup {
&[data-color="#{$colorHex}"] {
fill: var($colorVar);
}
}
@include focus-states('.row.selected', '.virtualized-table:focus-within') using($color) {
@if $color =="white" {
background: url("chrome://zotero/skin/tag-circle-border.svg") no-repeat center/contain;
stroke: var(--accent-white);
}
}
}
&.colored+.colored {
background:
url("chrome://zotero/skin/tag-crescent.svg") no-repeat center/100%,
url("chrome://zotero/skin/tag-crescent-border.svg") no-repeat center/0;
// RTL: Parent is flipped, so margin can always be on logical left
margin-left: -0.38461538em;
@include focus-states('.row.selected', '.virtualized-table:focus-within') using($color) {
@if $color =="white" {
background: url("chrome://zotero/skin/tag-crescent-border.svg") no-repeat center/contain;
}
}
}
}
.attachment-progress {
appearance: none;
background-color: transparent;
background-image: conic-gradient(var(--color-accent) var(--progress), var(--fill-quarternary) var(--progress), var(--fill-quarternary) 100%);
mask-image: radial-gradient(circle at center, transparent 5px, black 6px);
border-radius: 8px;
border: none;
display: block;
height: 16px;
width: 16px;
@include state(".row.selected") {
background-image: conic-gradient(var(--color-accent-text) var(--progress), transparent var(--progress));
background-image: conic-gradient(var(--accent-white) var(--progress), #ffffff33 var(--progress), #ffffff33 100%);
@include state(".virtualized-table:not(:focus-within)") {
background-image: conic-gradient(var(--color-accent) var(--progress), var(--fill-quarternary) var(--progress), var(--fill-quarternary) 100%);
}
}
&::-moz-progress-bar {
appearance: none;
background: transparent;
}
}
// The similarity bar: the score's fraction of the model's display range
.cell.relevance {
align-items: center;
.relevance-bar {
display: block;
flex: 1;
height: 6px;
border-radius: 3px;
background: var(--fill-quarternary);
overflow: hidden;
.relevance-bar-fill {
display: block;
height: 100%;
background: var(--accent-blue);
}
// On a focused selected row the accent fill would vanish into
// the accent selection background, so switch to white like
// .attachment-progress
@include state(".row.selected") {
background: #ffffff33;
.relevance-bar-fill {
background: var(--accent-white);
}
@include state(".virtualized-table:not(:focus-within)") {
background: var(--fill-quinary);
.relevance-bar-fill {
background: var(--accent-blue);
}
}
}
}
}
}
.cell.hasAttachment {
height: 100%;
// Don't show ellipsis
text-overflow: unset;
}
.cell.primary {
.retracted {
width: 12px;
margin-inline-start: 3px;
margin-inline-end: 4px;
}
}
.cell.hasAttachment {
text-overflow: unset;
align-items: center;
display: flex;
justify-content: center;
.icon-treeitemattachmentpdf {
background-size: 10px
}
.icon-missing-file {
opacity: 0.4;
}
}
// Section header above each library's items in a cross-library selection. These
// rows are pinned to the top while their section is scrolled (see the sticky
// section header support in virtualized-table), so they're a uniform height with
// top-aligned content. The background matches the pane so the items below stripe
// against it; square corners keep the on-scroll hairline from curving at the edges.
.library-header-row {
background-color: var(--material-background);
border-radius: 0;
.cell.library-header {
display: flex;
align-items: center;
padding-bottom: 3px;
font-weight: 600;
color: var(--fill-secondary);
.icon-item-type {
margin-inline-end: 4px;
opacity: 0.8;
}
// Stands in for a missing icon (single-library summary header) so the
// label still aligns with the item titles below it
.library-header-icon-spacer {
width: 16px;
margin-inline-end: 4px;
flex-shrink: 0;
}
}
}
// The divider under a section header shows only while its section is scrolled
// under the pinned copy (see the "stuck" state in virtualized-table). Drawn as a
// box-shadow rather than a border so toggling it doesn't shift the row's content.
.virtualized-table-sticky-section-header.stuck .library-header-row {
box-shadow: inset 0 -1px 0 var(--color-panedivider);
}
// Blank whitespace row above each library header (except the first); matches the
// pane background so it reads as empty space rather than a striped row
.spacer-row {
background-color: var(--material-background);
pointer-events: none;
}
.annotation-row {
.cell {
font-size: $font-size-small;
max-width: fit-content;
// Do not italicize CJK characters
font-synthesis: none;
&.title {
flex-grow: 1;
flex-basis: 0;
max-width: fit-content;
.cell-text::before {
content: attr(q-mark-open)
}
&::after {
content: attr(q-mark-close)
}
.italics {
font-style: italic;
}
}
&.annotation-comment {
flex-grow: 2;
flex-basis: 0;
}
// The text cells size to their content, so push the relevance
// bar to the row's end, where the Relevance column sits while
// a best-match search is active (the column's width rules
// still apply -- they're injected with !important)
&.relevance {
margin-inline-start: auto;
}
}
// The tight annotation layout trades cell padding for text room, but
// the relevance bar fills its cell's content box, so dropping the
// padding would render a wider bar than every other row's. Keep the
// standard padding on that one cell so the bars match.
&.tight .cell:not(.relevance) {
padding: 0 2px 0 0;
}
}
.search-match-row {
// One cell across the row: a match shows a passage, which the
// columns say nothing about
.cell {
font-size: $font-size-small;
flex: 1 1 auto;
}
// Location above quote, beside the indent and twisty the tree adds
// to the first cell. The row's height is set from JS for exactly
// these two lines (ItemTree#_getSearchMatchRowHeight()), so
// neither may wrap.
.search-match-lines {
display: flex;
flex-direction: column;
justify-content: center;
flex: 1 1 auto;
// Without this a flex item refuses to shrink below its text,
// and nothing is ever clipped to an ellipsis
min-width: 0;
}
.search-match-location,
.cell-text {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.search-match-location {
color: var(--fill-secondary);
}
&.selected {
.search-match-location {
color: var(--color-accent-text);
opacity: 0.7;
// An unfocused selection is a grey fill with ordinary text
@include state(".virtualized-table:not(:focus-within)") {
color: var(--fill-secondary);
opacity: 1;
}
}
}
.search-match-highlight {
font-weight: 600;
}
}
.cell:not(.hasAttachment) .item-icon {
margin-inline-end: 4px;
}
.cell .annotation-icon {
-moz-context-properties: fill;
}
.numNotes, .hasAttachment {
text-align: center;
.cell-text {
text-align: center;
}
}
}
}
item-tree-menu-bar[inactive] {
max-height: 0 !important;
overflow: hidden;
}
$-itemTypesIcons: (
artwork,
attachment-epub,
attachment-epub-link,
attachment-file, // mapped to document below
attachment-link,
attachment-pdf-link,
attachment-pdf,
attachment-snapshot,
attachment-web-link,
attachment-image,
attachment-image-link,
attachment-video,
attachment-video-link,
audio-recording,
bill,
blog-post,
book-section,
book,
case,
computer-program,
conference-paper,
dataset,
dictionary-entry,
document,
email,
encyclopedia-article,
epub,
film,
forum-post,
hearing,
instant-message,
interview,
journal-article,
letter,
magazine-article,
manuscript,
map,
newspaper-article,
note,
patent,
podcast,
preprint,
presentation,
radio-broadcast,
report,
standard,
statute,
thesis,
tv-broadcast,
video-recording,
webpage
);
$-itemTypesMap: (
"attachment-file": "document"
);
$-attachmentIcons: (
attachment-epub,
attachment-epub-link,
attachment-link,
attachment-pdf-link,
attachment-pdf,
attachment-snapshot,
attachment-web-link,
attachment-file,
attachment-image,
attachment-video
);
// Non-item objects that can appear in the trash
$-trashableObjectIcons: (
"collection",
"search"
);
// Few icons that appear in color in the item tree
$-coloredIcons: (
cross: --accent-red,
tick: --accent-green,
refresh: --fill-secondary,
plus-circle: --fill-secondary,
minus-circle: --fill-secondary
);
.icon-item-type {
width: 16px;
height: 16px;
}
.icon-attachment-type {
width: 12px;
height: 12px;
padding: 1px;
box-sizing: content-box;
}
// Due to quirks of the state() mixin, we need two sets of .icon-item-type rules:
// one when the icon is within a virtualized-table, and one when it isn't. We declare
// a mixin here to avoid duplication.
@mixin -icon-item-type-rules($color) {
.icon-item-type {
// default icon, for known item types more specific selectors below will apply
@include svgicon("document", $color, "16", "item-type", true);
}
@each $itemTypeIcon in $-itemTypesIcons {
$itemType: camelCase(str-replace(str-replace($itemTypeIcon, "pdf", "PDF"), "epub", "EPUB"));
@if map.has-key($-itemTypesMap, $itemTypeIcon) {
$itemTypeIcon: map.get($-itemTypesMap, $itemTypeIcon);
}
.icon-item-type[data-item-type=#{$itemType}] {
@include svgicon($itemTypeIcon, $color, "16", "item-type", true);
}
}
}
// Universal icon rules
@include focus-states using($color) {
@include -icon-item-type-rules($color);
}
// Selection and focus aware icon rules
.focus-states-target .row {
@include focus-states using($color) {
@include -icon-item-type-rules($color);
@each $itemAttachmentIcon in $-attachmentIcons {
$itemType: camelCase(str-replace(str-replace($itemAttachmentIcon, "pdf", "PDF"), "epub", "EPUB"));
.icon-attachment-type[data-item-type=#{$itemType}] {
@include svgicon($itemAttachmentIcon, $color, "12", "item-type", false);
background-origin: content-box;
}
}
@each $icon in $-trashableObjectIcons {
.icon-css.icon-#{$icon} {
@include svgicon($icon, $color, "16", "collection-tree");
}
}
@each $icon, $colorVar in $-coloredIcons {
.icon-#{$icon} {
@include svgicon($icon, "universal", "16");
@if $color =="white" {
fill: var(--accent-white);
}
@else {
fill: var($colorVar);
}
}
}
}
}