PageSourceSearch

https://firmendo-test.netlify.app/firmenkreditkarte.js

js firmendo-test.netlify.app collected 2026-10-03 10:44:18 UTC 21,436 bytes, 463 lines download raw bytes

1const products = [
2  {
3    name: 'moss Free', priceNote: 'Kostenlos · Echter Kreditrahmen bis 2,5 Mio. € · 60 Tage Zahlungsziel', base: 0, cat: 'Fintech', highlight: true,
4    suitable: ['GmbH & UG','AG','GbR','eK','Gründer'],
5    cardType: 'Kreditkarte', rating: 4.7,
6    cashbackRate: 0, cashbackLabel: '—',
7    limit: 'bis 2,5 Mio. €',
8    employeeCards: 'unbegrenzt', employeeCardsSort: 999,
9    datev: true, belegerfassung: true,
10    affiliateUrl: 'https://getmoss.com/?ref=firmendo',
11    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/moss.svg" alt="moss Logo" loading="lazy" decoding="async">'
12  },
13  {
14    name: 'Finom Solo', priceNote: 'Kostenlos · Visa Debitkarte · bis 3% Cashback', base: 0, cat: 'Fintech', highlight: false,
15    suitable: ['Freiberufler','Einzelunternehmen','Gründer'],
16    cardType: 'Debitkarte', rating: 4.5,
17    cashbackRate: 3, cashbackLabel: 'bis 3 %',
18    limit: 'Debit',
19    employeeCards: 'solo', employeeCardsSort: 1,
20    datev: true, belegerfassung: true,
21    affiliateUrl: 'https://finom.co/?ref=firmendo',
22    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/finom.svg" alt="Finom Logo" loading="lazy" decoding="async">'
23  },
24  {
25    name: 'Vivid Business', priceNote: 'Kostenlos · Visa Debitkarte · bis 4% Cashback', base: 0, cat: 'Online-Bank', highlight: false,
26    suitable: ['Freiberufler','Einzelunternehmen','GmbH & UG','Gründer'],
27    cardType: 'Debitkarte', rating: 4.8,
28    cashbackRate: 4, cashbackLabel: 'bis 4 %',
29    limit: 'Debit',
30    employeeCards: 'bis 50', employeeCardsSort: 50,
31    datev: false, belegerfassung: true,
32    affiliateUrl: 'https://vivid.money/de-de/business/?ref=firmendo',
33    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/vivid-money.png" alt="Vivid Money Logo" loading="lazy" decoding="async">'
34  },
35  {
36    name: 'Revolut Business Free', priceNote: 'Kostenlos · Mastercard Debitkarte · Multi-Währung', base: 0, cat: 'Fintech', highlight: false,
37    suitable: ['Freiberufler','Einzelunternehmen','GmbH & UG','Gründer'],
38    cardType: 'Debitkarte', rating: 4.4,
39    cashbackRate: 0, cashbackLabel: '—',
40    limit: 'Debit',
41    employeeCards: 'ja', employeeCardsSort: 10,
42    datev: false, belegerfassung: true,
43    affiliateUrl: 'https://www.revolut.com/business/?ref=firmendo',
44    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/revolut.svg" alt="Revolut Logo" loading="lazy" decoding="async">'
45  },
46  {
47    name: 'Qonto Business', priceNote: 'Ab 9 €/Monat · Mastercard Debitkarte · DATEV-Export', base: 9, cat: 'Fintech', highlight: false,
48    suitable: ['Freiberufler','Einzelunternehmen','GmbH & UG','AG','GbR','Gründer'],
49    cardType: 'Debitkarte', rating: 4.6,
50    cashbackRate: 0, cashbackLabel: '—',
51    limit: 'Debit',
52    employeeCards: 'bis 30', employeeCardsSort: 30,
53    datev: true, belegerfassung: true,
54    affiliateUrl: 'https://qonto.com/de?ref=firmendo',
55    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/qonto.png" alt="Qonto Logo" loading="lazy" decoding="async">'
56  },
57  {
58    name: 'American Express Business Gold', priceNote: '216 €/Jahr · Charge-Karte · Membership Rewards', base: 216, cat: 'Kreditkarten-Anbieter', highlight: false,
59    suitable: ['Freiberufler','Einzelunternehmen','GmbH & UG','AG','GbR'],
60    cardType: 'Kreditkarte', rating: 4.6,
61    cashbackRate: 0, cashbackLabel: 'Membership Rewards',
62    limit: 'variabel',
63    employeeCards: 'Corporate Cards', employeeCardsSort: 999,
64    datev: false, belegerfassung: false,
65    affiliateUrl: 'https://americanexpress.com/de/?ref=firmendo',
66    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/american-express.svg" alt="American Express Logo" loading="lazy" decoding="async">'
67  },
68  {
69    name: 'N26 Business', priceNote: 'Kostenlos · Mastercard Debitkarte · 0,1% Cashback', base: 0, cat: 'Online-Bank', highlight: false,
70    suitable: ['Freiberufler','Einzelunternehmen'],
71    cardType: 'Debitkarte', rating: 4.2,
72    cashbackRate: 0.1, cashbackLabel: '0,1 %',
73    limit: 'Debit',
74    employeeCards: 'solo', employeeCardsSort: 1,
75    datev: false, belegerfassung: false,
76    affiliateUrl: 'https://n26.com/de-de/business?ref=firmendo',
77    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/n26.svg" alt="N26 Logo" loading="lazy" decoding="async">'
78  },
79  {
80    name: 'Kontist Premium', priceNote: '9 €/Monat · Visa Debitkarte · Steuerautomatisierung', base: 9, cat: 'Fintech', highlight: false,
81    suitable: ['Freiberufler','Einzelunternehmen'],
82    cardType: 'Debitkarte', rating: 4.3,
83    cashbackRate: 0, cashbackLabel: '—',
84    limit: 'Debit',
85    employeeCards: 'solo', employeeCardsSort: 1,
86    datev: true, belegerfassung: true,
87    affiliateUrl: 'https://kontist.com/?ref=firmendo',
88    logo: '<img class="provider-logo-img u-logo-media-fit" src="../images/mini-logos/kontist.png" alt="Kontist Logo" loading="lazy" decoding="async">'
89  }
90];
91
92const YES = `<svg class="icon-yes" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>`;
93const NO  = `<svg class="icon-no"  width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
94const YES_SMALL = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="var(--green)" stroke-width="3" stroke-linecap="round"><polyline points="20 6 9 17 4 12"/></svg>`;
95const NO_SMALL  = `<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
96
97let activeFilters = { cashback: false, employees: false, datev: false, kreditrahmen: false };
98let sortKey = 'base', sortAsc = true;
99
100/* ========== CUSTOM DROPDOWN ========== */
101function fdGetValue(id) {
102  const el = document.getElementById(id);
103  return el ? (el.dataset.value || '') : '';
104}
105function fdSetValue(id, value, label) {
106  const el = document.getElementById(id);
107  if (!el) return;
108  el.dataset.value = value;
109  const txt = el.querySelector('.fd-trigger-text');
110  if (txt && label !== undefined) txt.textContent = label;
111  // Update selected state in the dropdown menu.
112  el.querySelectorAll('.fd-option').forEach(o => {
113    o.classList.toggle('selected', o.dataset.value === value);
114  });
115  // Highlight triggers for filters only.
116  if (id === 'fd-sort') {
117    el.classList.remove('has-value');
118  } else {
119    el.classList.toggle('has-value', value !== '');
120  }
121}
122function fdInit() {
123  document.querySelectorAll('.fd').forEach(fd => {
124    const trigger = fd.querySelector('.fd-trigger');
125    const menu = fd.querySelector('.fd-menu');
126    if (!trigger || !menu) return;
127    trigger.addEventListener('click', (e) => {
128      e.stopPropagation();
129      // close others
130      document.querySelectorAll('.fd.open').forEach(o => {
131        if (o !== fd) o.classList.remove('open');
132      });
133      fd.classList.toggle('open');
134      trigger.setAttribute('aria-expanded', fd.classList.contains('open') ? 'true' : 'false');
135    });
136    menu.querySelectorAll('.fd-option').forEach(opt => {
137      opt.addEventListener('click', (e) => {
138        e.stopPropagation();
139        const val = opt.dataset.value || '';
140        const label = opt.textContent.trim();
141        fdSetValue(fd.id, val, label);
142        fd.classList.remove('open');
143        trigger.setAttribute('aria-expanded', 'false');
144        // Trigger appropriate handler
145        if (fd.id === 'fd-sort') applySort();
146        else applyFilters();
147      });
148    });
149  });
150  // Close on outside click
151  document.addEventListener('click', () => {
152    document.querySelectorAll('.fd.open').forEach(o => {
153      o.classList.remove('open');
154      const t = o.querySelector('.fd-trigger');
155      if (t) t.setAttribute('aria-expanded', 'false');
156    });
157  });
158  // Close on Escape
159  document.addEventListener('keydown', (e) => {
160    if (e.key === 'Escape') {
161      document.querySelectorAll('.fd.open').forEach(o => o.classList.remove('open'));
162    }
163  });
164}
165
166function toggleFilter(key, btn) {
167  activeFilters[key] = !activeFilters[key];
168  btn.classList.toggle('active', activeFilters[key]);
169  renderTable();
170}
171
172function applyFilters() { renderTable(); }
173
174function applySort() {
175  const val = fdGetValue('fd-sort') || 'base-asc';
176  const [key, dir] = val.split('-');
177  sortKey = key;
178  sortAsc = dir === 'asc';
179  document.querySelectorAll('th.sortable').forEach(t => {
180    t.classList.remove('asc','desc');
181    if (t.dataset.key === key) t.classList.add(sortAsc ? 'asc' : 'desc');
182  });
183  renderTable();
184}
185
186function resetFilters() {
187  activeFilters = { cashback: false, employees: false, datev: false, kreditrahmen: false };
188  document.querySelectorAll('.toggle-btn').forEach(b => b.classList.remove('active'));
189  fdSetValue('fd-suit', '', 'Alle Rechtsformen');
190  fdSetValue('fd-type', '', 'Alle Typen');
191  fdSetValue('fd-budget', '', 'Alle Preise');
192  fdSetValue('fd-sort', 'base-asc', 'Preis: aufsteigend');
193  sortKey = 'base'; sortAsc = true;
194  document.querySelectorAll('th.sortable').forEach(t => t.classList.remove('asc','desc'));
195  const defaultTh = document.querySelector('th.sortable[data-key="base"]');
196  if (defaultTh) defaultTh.classList.add('asc');
197  renderTable();
198}
199
200function updateFilterCount() {
201  const suit = fdGetValue('fd-suit');
202  const type = fdGetValue('fd-type');
203  const budget = fdGetValue('fd-budget');
204  let count = 0;
205  if (suit) count++;
206  if (type) count++;
207  if (budget) count++;
208  Object.values(activeFilters).forEach(v => { if (v) count++; });
209  const badge = document.getElementById('filterCountBadge');
210  const cnt = document.getElementById('filterCount');
211  if (count > 0) {
212    badge.classList.remove('hidden');
213    cnt.textContent = count;
214  } else {
215    badge.classList.add('hidden');
216  }
217}
218
219function renderTable() {
220  const suit = fdGetValue('fd-suit');
221  const type = fdGetValue('fd-type');
222  const budget = fdGetValue('fd-budget');
223
224  let data = products.filter(p => {
225    if (suit && !p.suitable.includes(suit)) return false;
226    if (type === 'Debitkarte' && p.cardType !== 'Debitkarte') return false;
227    if (type === 'Kreditkarte' && p.cardType !== 'Kreditkarte') return false;
228    if (type === 'Prepaid' && p.cardType !== 'Prepaid') return false;
229    if (budget === 'free' && p.base > 0) return false;
230    if (budget === 'low' && (p.base <= 0 || p.base > 10)) return false;
231    if (budget === 'mid' && (p.base <= 10 || p.base > 50)) return false;
232    if (budget === 'premium' && p.base < 50) return false;
233    if (activeFilters.cashback && !(p.cashbackRate > 0)) return false;
234    if (activeFilters.employees && (p.employeeCardsSort <= 1 || p.employeeCards === 'solo')) return false;
235    if (activeFilters.datev && !p.datev) return false;
236    if (activeFilters.kreditrahmen && p.cardType !== 'Kreditkarte') return false;
237    return true;
238  });
239
240  if (sortKey) {
241    data.sort((a, b) => {
242      let av = a[sortKey];
243      let bv = b[sortKey];
244      if (typeof av === 'string') av = av.toLowerCase();
245      if (typeof bv === 'string') bv = bv.toLowerCase();
246      if (av === bv) return 0;
247      return sortAsc ? (av > bv ? 1 : -1) : (av < bv ? 1 : -1);
248    });
249  }
250
251  document.getElementById('cnt').textContent = data.length;
252  const totalEl = document.getElementById('cntTotal');
253  if (totalEl) totalEl.textContent = products.length;
254
255  updateFilterCount();
256  renderDesktopTable(data);
257  renderMobileCards(data);
258}
259
260function renderDesktopTable(data) {
261  const tbody = document.getElementById('tbody');
262  if (data.length === 0) {
263    tbody.innerHTML = `<tr><td colspan="8"><div class="empty-state">
264      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
265      <p>Keine Anbieter gefunden. Bitte Filter anpassen.</p></div></td></tr>`;
266    return;
267  }
268
269  tbody.innerHTML = data.map(p => {
270    const priceStr = p.base === 0
271      ? `<span class="td-price-amount free">0 &euro;</span>`
272      : `<span class="td-price-amount">${p.base % 1 === 0 ? p.base : p.base.toFixed(2).replace('.',',')} &euro;</span><span class="td-price-period">/ ${p.base >= 100 ? 'Jahr' : 'Monat'}</span>`;
273    const cardBadgeClass = p.cardType === 'Kreditkarte' ? 'kredit' : p.cardType === 'Prepaid' ? 'prepaid' : 'debit';
274    // Cashback: numeric uses green, Rewards (Amex) uses purple, none uses gray.
275    const cashbackCell = p.cashbackRate > 0
276      ? `<span class="cashback-val">${p.cashbackLabel}</span>`
277      : (p.cashbackLabel && p.cashbackLabel !== '—'
278        ? `<span class="cashback-val rewards">${p.cashbackLabel}</span>`
279        : `<span class="cashback-none">&mdash;</span>`);
280    // Kreditrahmen: debit uses gray, concrete limits use orange, variable uses purple.
281    const limitCell = p.limit === 'Debit'
282      ? `<span class="td-limit-value debit">Debit</span>`
283      : p.limit === '—'
284        ? `<span class="td-limit-value debit">&mdash;</span>`
285        : p.limit === 'variabel'
286          ? `<span class="td-limit-value variabel">variabel</span>`
287          : `<span class="td-limit-value credit">${p.limit}</span>`;
288    // Employee cards: unlimited/corporate uses orange, numeric values use blue.
289    const empCell = p.employeeCards === 'solo'
290      ? `<span class="td-employees-value none" title="Nur Einzelkonto, keine Mitarbeiterkarten">Einzelkonto</span>`
291      : p.employeeCardsSort >= 999
292        ? `<span class="td-employees-value premium">${p.employeeCards}</span>`
293        : (p.employeeCardsSort > 0
294          ? `<span class="td-employees-value">${p.employeeCards}</span>`
295          : `<span class="td-employees-value none">&mdash;</span>`);
296    const buchhaltungCell = `<div class="buchhaltung-stack">
297      <span class="buchhaltung-item ${p.datev ? '' : 'off'}">DATEV</span>
298      <span class="buchhaltung-item ${p.belegerfassung ? '' : 'off'}">Belege</span>
299    </div>`;
300
301    return `
302    <tr class="${p.highlight ? 'highlight' : ''}">
303      <td>
304        <div class="td-anbieter u-table-provider-row">
305          <div class="td-logo u-logo-dynamic-bg u-bg-white">${p.logo}</div>
306          <div class="u-stack-start-gap-3 u-min-0">
307            ${p.highlight ? '<span class="highlight-badge">Empfehlung</span>' : ''}
308            <div class="td-name">${p.name}</div>
309            <div class="td-cat">${p.cat}</div>
310          </div>
311        </div>
312      </td>
313      <td class="td-price">
314        <div class="price-wrap">${priceStr}${p.priceNote ? `<div class="price-tooltip">${p.priceNote}</div>` : ''}</div>
315      </td>
316      <td class="td-center"><span class="card-badge ${cardBadgeClass}">${p.cardType}</span></td>
317      <td class="td-center">${cashbackCell}</td>
318      <td class="td-center">${limitCell}</td>
319      <td class="td-center">${empCell}</td>
320      <td class="td-center">${buchhaltungCell}</td>
321      <td class="td-cta">
322        <div class="td-cta-stack">
323          <a href="${p.affiliateUrl}" target="_blank" rel="noopener sponsored" class="btn-table${p.highlight ? ' highlighted' : ''}">
324            Zum Anbieter
325            <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
326          </a>
327        </div>
328      </td>
329    </tr>`;
330  }).join('');
331}
332
333function renderMobileCards(data) {
334  const mc = document.getElementById('mobileCards');
335  if (!mc) return;
336  if (data.length === 0) {
337    mc.innerHTML = `<div class="mobile-empty">
338      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
339      <p>Keine Anbieter gefunden. Bitte Filter anpassen.</p></div>`;
340    return;
341  }
342
343  mc.innerHTML = data.map(p => {
344    const priceStr = p.base === 0
345      ? `<div class="mc-price-amount free">0 &euro;</div><div class="mc-price-period">Grundgeb&uuml;hr</div>`
346      : `<div class="mc-price-amount">${p.base % 1 === 0 ? p.base : p.base.toFixed(2).replace('.',',')} &euro;</div><div class="mc-price-period">/ ${p.base >= 100 ? 'Jahr' : 'Monat'}</div>`;
347    const cardBadgeClass = p.cardType === 'Kreditkarte' ? 'kredit' : p.cardType === 'Prepaid' ? 'prepaid' : 'debit';
348    const cashbackVal = p.cashbackRate > 0
349      ? `<span class="mc-spec-value green">${p.cashbackLabel}</span>`
350      : (p.cashbackLabel && p.cashbackLabel !== '—'
351        ? `<span class="mc-spec-value purple">${p.cashbackLabel}</span>`
352        : `<span class="mc-spec-value muted">&mdash;</span>`);
353    const limitVal = p.limit === 'Debit'
354      ? `<span class="mc-spec-value muted">Debit</span>`
355      : p.limit === '—'
356        ? `<span class="mc-spec-value muted">&mdash;</span>`
357        : p.limit === 'variabel'
358          ? `<span class="mc-spec-value purple">variabel</span>`
359          : `<span class="mc-spec-value orange">${p.limit}</span>`;
360    const empVal = p.employeeCards === 'solo'
361      ? `<span class="mc-spec-value muted">Einzelkonto</span>`
362      : p.employeeCardsSort >= 999
363        ? `<span class="mc-spec-value orange">${p.employeeCards}</span>`
364        : (p.employeeCardsSort > 0
365          ? `<span class="mc-spec-value blue">${p.employeeCards}</span>`
366          : `<span class="mc-spec-value muted">&mdash;</span>`);
367    const datevVal = p.datev
368      ? `<span class="mc-spec-value green">${YES_SMALL} DATEV</span>`
369      : `<span class="mc-spec-value muted">${NO_SMALL} DATEV</span>`;
370
371    return `
372    <div class="mc-card ${p.highlight ? 'highlight' : ''}">
373      <div class="mc-head">
374        <div class="mc-logo">${p.logo}</div>
375        <div class="mc-head-text">
376          <div class="mc-name">${p.name}</div>
377          <div class="mc-meta">
378            <span class="mc-cat">${p.cat}</span>
379            <span class="card-badge ${cardBadgeClass}">${p.cardType}</span>
380          </div>
381          ${p.highlight ? '<span class="mc-badge">Empfehlung</span>' : ''}
382        </div>
383        <div class="mc-price-main">${priceStr}</div>
384      </div>
385
386      <div class="mc-specs">
387        <div class="mc-spec">
388          <div class="mc-spec-label">Cashback</div>
389          ${cashbackVal}
390        </div>
391        <div class="mc-spec">
392          <div class="mc-spec-label">Kreditrahmen</div>
393          ${limitVal}
394        </div>
395        <div class="mc-spec">
396          <div class="mc-spec-label">Mitarbeiterkarten</div>
397          ${empVal}
398        </div>
399        <div class="mc-spec">
400          <div class="mc-spec-label">Buchhaltung</div>
401          ${datevVal}
402        </div>
403      </div>
404
405      <div class="mc-footer">
406        <div class="mc-footer-note">${p.priceNote || ''}</div>
407        <a href="${p.affiliateUrl}" target="_blank" rel="noopener sponsored" class="mc-cta">
408          Zum Anbieter
409          <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg>
410        </a>
411      </div>
412    </div>`;
413  }).join('');
414}
415
416document.addEventListener('DOMContentLoaded', () => {
417  fdInit();
418  document.querySelectorAll('th.sortable').forEach(th => {
419    th.addEventListener('click', () => {
420      const key = th.dataset.key;
421      if (sortKey === key) sortAsc = !sortAsc;
422      else { sortKey = key; sortAsc = true; }
423      document.querySelectorAll('th.sortable').forEach(t => { t.classList.remove('asc','desc'); });
424      th.classList.add(sortAsc ? 'asc' : 'desc');
425      // Sync fd-sort dropdown
426      const matchingVal = `${key}-${sortAsc ? 'asc' : 'desc'}`;
427      const fdSort = document.getElementById('fd-sort');
428      if (fdSort) {
429        const matchingOpt = fdSort.querySelector(`.fd-option[data-value="${matchingVal}"]`);
430        if (matchingOpt) {
431          fdSetValue('fd-sort', matchingVal, matchingOpt.textContent.trim());
432        }
433      }
434      renderTable();
435    });
436  });
437  // Set default sort indicator
438  const defaultTh = document.querySelector('th.sortable[data-key="base"]');
439  if (defaultTh) defaultTh.classList.add('asc');
440  renderTable();
441
442  // Scroll detection inside DOMContentLoaded for reliable measurements.
443  const _tw = document.getElementById('tableWrap');
444  const _sw = document.getElementById('tableScrollWrap');
445  const _hint = document.getElementById('scrollHint');
446  if (_tw && _sw) {
447    const checkScroll = () => {
448      const hasScroll = _tw.scrollWidth > _tw.clientWidth + 2;
449      const atStart = _tw.scrollLeft <= 2;
450      const atEnd = _tw.scrollLeft + _tw.clientWidth >= _tw.scrollWidth - 8;
451      _sw.classList.toggle('scrollable', hasScroll);
452      _sw.classList.toggle('at-start', atStart);
453      _sw.classList.toggle('at-end', atEnd);
454      if (_hint) _hint.classList.toggle('visible', hasScroll && !atEnd);
455    };
456    _tw.addEventListener('scroll', checkScroll, { passive: true });
457    window.addEventListener('resize', checkScroll);
458    setTimeout(checkScroll, 50);
459    setTimeout(checkScroll, 300);
460    setTimeout(checkScroll, 800);
461    checkScroll();
462  }
463});

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.