1import { exampleStateFor } from './examples.js'2import { importEditableSvgFile } from './editable-svg-input.js'3import {4 filterLauncherCatalog,5 launcherCatalogGroups,6 launcherEntryFor,7} from './launcher-catalog.js'8import { formatIconMarkup } from './format-icons.js'9import { createLauncherPreviewRenderer } from './launcher-previews.js'10import { createLocalDocuments } from './local-documents.js'11import { DEFAULT_DIAGRAM_TYPE } from './state.js'1213const systemColorScheme = matchMedia('(prefers-color-scheme: dark)')14const localDocuments = createLocalDocuments()15const previewRenderer = createLauncherPreviewRenderer()16document.documentElement.dataset.theme = systemColorScheme.matches ? 'dark' : 'light'17document.querySelector('meta[name="theme-color"]').content = systemColorScheme.matches ? '#18191b' : '#ffffff'1819const requestedType = new URLSearchParams(location.search).get('type') ?? ''20let recents = localDocuments.loadRecents()21let selected = recents[0]22 ? { kind: 'recent', id: recentId(recents[0]) }23 : { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }24let activeGroup = 'all'25let listMode = false2627document.querySelector('#app').innerHTML = `28 <main class="launcher-shell">29 <header class="launcher-header">30 <a class="launcher-brand" href="/" aria-label="Diagram.zip home"><img src="/icon.svg?v=4" alt=""><span>diagram.zip</span></a>31 <span class="header-spacer"></span>32 <a class="launcher-docs" href="https://docs.diagram.zip/" aria-label="Documentation" title="Documentation">33 <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M3.5 5.5c3.25-.75 6-.1 8.5 2v12c-2.5-2.1-5.25-2.75-8.5-2V5.5Zm17 0c-3.25-.75-6-.1-8.5 2v12c2.5-2.1 5.25-2.75 8.5-2V5.5Z"/><path d="M12 7.5v12"/></svg>34 </a>35 </header>36 <div class="launcher-layout">37 <aside class="launcher-rail" aria-label="Launcher navigation">38 <div class="launcher-rail-scroll">39 <h2>Start</h2>40 <button type="button" data-launcher-action="new"><span class="launcher-action-icon">+</span><span>New diagram</span><kbd>⌘N</kbd></button>41 <button type="button" data-launcher-action="open"><span class="launcher-action-icon">⌑</span><span>Open file…</span><kbd>⌘O</kbd></button>42 <button type="button" data-launcher-action="paste"><span class="launcher-action-icon">▤</span><span>Paste source</span><kbd>⌘V</kbd></button>43 <h2>Formats</h2>44 <div id="launcher-groups"></div>45 <h2>Recent</h2>46 <div class="launcher-recents" id="launcher-recents"></div>47 </div>48 <div class="launcher-storage" id="launcher-storage"></div>49 </aside>50 <section class="launcher-catalog-pane" aria-label="Diagram formats">51 <div class="launcher-filter-bar">52 <label class="launcher-filter">53 <svg viewBox="0 0 24 24" aria-hidden="true"><circle cx="11" cy="11" r="7"/><path d="m16.5 16.5 4 4"/></svg>54 <span class="sr-only">Filter formats</span>55 <input id="launcher-filter" type="search" placeholder="Filter formats" autocomplete="off" spellcheck="false">56 <kbd>/</kbd>57 </label>58 <div class="launcher-view-toggle" role="group" aria-label="Format view">59 <button type="button" data-view="grid" aria-pressed="true">Grid</button>60 <button type="button" data-view="list" aria-pressed="false">List</button>61 </div>62 </div>63 <div class="launcher-catalog-scroll" id="launcher-catalog"></div>64 </section>65 <aside class="launcher-detail" id="launcher-detail" aria-live="polite"></aside>66 </div>67 </main>68 <input id="launcher-file" type="file" accept=".svg,image/svg+xml" hidden>69 <dialog class="paste-dialog" id="paste-dialog" aria-labelledby="paste-title">70 <form class="dialog-card" id="paste-form">71 <div class="dialog-header"><h1 id="paste-title">Paste diagram source</h1><button class="icon-button" type="button" data-paste-cancel aria-label="Close">×</button></div>72 <p class="dialog-copy">Paste source and we’ll pick the most likely format. You can correct it before creating the diagram.</p>73 <div class="details-fields">74 <label><span>Source</span><textarea id="paste-source" required spellcheck="false"></textarea></label>75 <label><span>Format</span><select id="paste-type"></select></label>76 </div>77 <div class="dialog-actions"><button class="primary-action" type="submit">Create diagram</button></div>78 </form>79 </dialog>80`8182const filterInput = document.querySelector('#launcher-filter')83filterInput.value = requestedType84for (const entry of filterLauncherCatalog()) document.querySelector('#paste-type').add(new Option(entry.label, entry.id))8586document.querySelector('[data-launcher-action="new"]').addEventListener('click', selectDefaultFormat)87document.querySelector('[data-launcher-action="open"]').addEventListener('click', () => document.querySelector('#launcher-file').click())88document.querySelector('[data-launcher-action="paste"]').addEventListener('click', openPasteDialog)89document.querySelector('#launcher-file').addEventListener('change', openEditableFile)90document.querySelector('[data-paste-cancel]').addEventListener('click', () => document.querySelector('#paste-dialog').close())91document.querySelector('#paste-form').addEventListener('submit', createPastedDiagram)92document.querySelector('#paste-source').addEventListener('input', event => {93 const detected = detectDiagramType(event.target.value)94 if (detected) document.querySelector('#paste-type').value = detected95})96filterInput.addEventListener('input', () => {97 activeGroup = 'all'98 const matches = visibleFormats()99 if (matches.length) selected = { kind: 'format', id: matches[0].id }100 render()101})102document.querySelector('#launcher-groups').addEventListener('click', event => {103 const button = event.target.closest('[data-group]')104 if (!button) return105 activeGroup = button.dataset.group106 filterInput.value = ''107 const first = visibleFormats()[0]108 if (first) selected = { kind: 'format', id: first.id }109 render()110})111document.querySelector('#launcher-recents').addEventListener('click', event => {112 const button = event.target.closest('[data-recent-id]')113 if (!button) return114 selected = { kind: 'recent', id: button.dataset.recentId }115 render()116})117document.querySelector('#launcher-catalog').addEventListener('click', event => {118 const button = event.target.closest('[data-format-id]')119 if (!button) return120 selected = { kind: 'format', id: button.dataset.formatId }121 render()122})123document.querySelector('.launcher-view-toggle').addEventListener('click', event => {124 const button = event.target.closest('[data-view]')125 if (!button) return126 listMode = button.dataset.view === 'list'127 render()128})129document.querySelector('#launcher-detail').addEventListener('click', handleDetailAction)130document.querySelector('#launcher-storage').addEventListener('click', event => {131 if (!event.target.closest('[data-clear-recents]')) return132 if (!confirm('Clear the recent list and delete its local-only diagrams from this browser? Published edit links will be kept.')) return133 localDocuments.clearRecents()134 recents = []135 selected = { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }136 render()137})138139window.addEventListener('keydown', event => {140 if (document.querySelector('#paste-dialog').open) return141 const primary = event.metaKey || event.ctrlKey142 const key = event.key.toLowerCase()143 const typing = /^(input|textarea|select)$/i.test(document.activeElement?.tagName)144 if (event.key === '/' && !typing) {145 event.preventDefault()146 filterInput.focus()147 } else if (primary && key === 'n') {148 event.preventDefault()149 selectDefaultFormat()150 } else if (primary && key === 'o') {151 event.preventDefault()152 document.querySelector('#launcher-file').click()153 } else if (primary && key === 'v' && !typing) {154 event.preventDefault()155 openPasteDialog()156 } else if (event.key === 'Escape' && filterInput.value) {157 event.preventDefault()158 filterInput.value = ''159 render()160 } else if (event.key.startsWith('Arrow') && !typing) {161 event.preventDefault()162 moveFormatSelection(event.key)163 } else if (event.key === 'Enter' && !typing) {164 event.preventDefault()165 activateSelection()166 }167})168169render()170if (requestedType) filterInput.focus()171window.addEventListener('pagehide', () => previewRenderer.dispose(), { once: true })172173function render() {174 renderGroups()175 renderRecents()176 renderCatalog()177 renderDetail()178 renderStorage()179 for (const button of document.querySelectorAll('[data-view]')) {180 button.setAttribute('aria-pressed', String((button.dataset.view === 'list') === listMode))181 }182}183184function renderGroups() {185 const root = document.querySelector('#launcher-groups')186 root.replaceChildren(groupButton('all', 'All', filterLauncherCatalog().length))187 for (const group of launcherCatalogGroups) root.append(groupButton(group.id, group.label, group.items.length))188}189190function groupButton(id, label, count) {191 const button = document.createElement('button')192 button.type = 'button'193 button.dataset.group = id194 button.className = 'launcher-group'195 button.dataset.selected = String(activeGroup === id)196 button.innerHTML = '<span></span><small></small>'197 button.querySelector('span').textContent = label198 button.querySelector('small').textContent = count199 return button200}201202function renderRecents() {203 const root = document.querySelector('#launcher-recents')204 root.replaceChildren()205 if (!recents.length) {206 const empty = document.createElement('p')207 empty.className = 'launcher-empty'208 empty.textContent = 'Nothing on this device yet'209 root.append(empty)210 return211 }212 for (const entry of recents) {213 const button = document.createElement('button')214 button.type = 'button'215 button.dataset.recentId = recentId(entry)216 button.dataset.selected = String(selected.kind === 'recent' && selected.id === recentId(entry))217 button.innerHTML = '<span class="recent-glyph"></span><span class="recent-copy"><strong></strong><small></small></span><span class="recent-dot" hidden></span>'218 button.querySelector('.recent-glyph').textContent = formatMonogram(entry.type)219 button.querySelector('strong').textContent = entry.title || 'Locked diagram'220 button.querySelector('small').textContent = `${entry.mode === 'locked' ? 'encrypted' : diagramLabel(entry.type)} · ${relativeTime(entry.updatedAt)}`221 button.querySelector('.recent-dot').hidden = !entry.dirty222 root.append(button)223 }224}225226function renderCatalog() {227 const root = document.querySelector('#launcher-catalog')228 root.dataset.view = listMode ? 'list' : 'grid'229 root.replaceChildren()230 const visible = new Set(visibleFormats().map(entry => entry.id))231 if (!visible.size) {232 const empty = document.createElement('div')233 empty.className = 'launcher-no-match'234 const title = document.createElement('strong')235 title.textContent = `No format called “${filterInput.value}”`236 const copy = document.createElement('p')237 copy.textContent = `Diagram.zip renders ${filterLauncherCatalog().length} formats. Clear the filter to see them all.`238 const clear = document.createElement('button')239 clear.type = 'button'240 clear.textContent = 'Clear filter'241 clear.addEventListener('click', () => { filterInput.value = ''; filterInput.focus(); render() })242 empty.append(title, copy, clear)243 root.append(empty)244 return245 }246 for (const group of launcherCatalogGroups) {247 const items = group.items.filter(entry => visible.has(entry.id))248 if (!items.length) continue249 const heading = document.createElement('div')250 heading.className = 'launcher-catalog-heading'251 heading.innerHTML = '<h2></h2><span></span>'252 heading.querySelector('h2').textContent = group.label253 root.append(heading)254 const grid = document.createElement('div')255 grid.className = 'launcher-format-grid'256 for (const entry of items) {257 const button = document.createElement('button')258 button.type = 'button'259 button.dataset.formatId = entry.id260 button.dataset.selected = String(selected.kind === 'format' && selected.id === entry.id)261 button.innerHTML = '<span class="format-icon"></span><span class="format-copy"><strong></strong><small></small></span>'262 button.querySelector('.format-icon').innerHTML = formatIconMarkup(entry.id)263 button.querySelector('strong').textContent = entry.label264 button.querySelector('small').textContent = entry.description265 grid.append(button)266 }267 root.append(grid)268 }269}270271function renderDetail() {272 const root = document.querySelector('#launcher-detail')273 root.replaceChildren()274 const recent = selected.kind === 'recent' ? recents.find(entry => recentId(entry) === selected.id) : null275 if (recent) renderRecentDetail(root, recent)276 else renderFormatDetail(root, launcherEntryFor(selected.id) ?? launcherEntryFor(DEFAULT_DIAGRAM_TYPE))277}278279function renderFormatDetail(root, entry) {280 const state = exampleStateFor(entry.id)281 root.append(detailPreview(entry, state), detailBody({282 title: entry.label,283 description: entry.description,284 tags: [entry.groupLabel, ...entry.extensions],285 facts: [['Extension', entry.extensions.join(', ') || 'text'], ['Starts from', state.meta.title || `${entry.label} example`]],286 source: state.source,287 }), detailFooter([288 ['create', 'Create', true],289 ]))290}291292function renderRecentDetail(root, entry) {293 const document = entry.kind === 'local' ? localDocuments.loadLocalDocument(entry.localId) : null294 const title = entry.title || 'Locked diagram'295 root.append(detailPreview(launcherEntryFor(entry.type), document?.state, {296 unavailableLabel: entry.mode === 'locked' ? 'Encrypted preview' : 'Open to load preview',297 }), detailBody({298 title,299 tags: [entry.kind === 'alias' ? 'published' : 'local only', entry.dirty ? 'unsaved' : '', entry.mode === 'locked' ? 'locked' : diagramLabel(entry.type)].filter(Boolean),300 facts: [['Edited', relativeTime(entry.updatedAt)], ['Size', entry.bytes ? formatBytes(entry.bytes) : 'remote'], ['Stored', entry.kind === 'local' ? 'this browser only' : 'published link']],301 source: document?.state.source ?? (entry.mode === 'locked' ? 'Encrypted source is never stored in recents.' : 'Open this diagram to load its latest published source.'),302 }), detailFooter([303 ['open', 'Open', true],304 ...(entry.kind === 'local' ? [['duplicate', 'Duplicate', false]] : []),305 ['delete', 'Remove', false],306 ]))307}308309function detailPreview(entry, state, { unavailableLabel = 'Preview unavailable' } = {}) {310 return createPreviewFrame({311 className: 'launcher-detail-preview',312 state,313 alt: state ? `${entry?.label ?? 'Diagram'} template preview` : '',314 unavailableLabel,315 })316}317318function createPreviewFrame({ className, state, alt, unavailableLabel = 'Preview unavailable' }) {319 const frame = document.createElement('span')320 frame.className = `launcher-preview-frame ${className}`321 frame.dataset.previewState = state ? 'loading' : 'unavailable'322 frame.innerHTML = `323 <span class="launcher-preview-placeholder" aria-hidden="true">324 <svg viewBox="0 0 48 32" focusable="false">325 <rect x="3" y="5" width="13" height="8" rx="2"></rect>326 <rect x="32" y="19" width="13" height="8" rx="2"></rect>327 <path d="M16 9h7a5 5 0 0 1 5 5v5h4m-7-2 3 3 3-3"></path>328 </svg>329 <small></small>330 </span>331 <img draggable="false" hidden>332 `333 frame.querySelector('img').alt = alt334 frame.querySelector('small').textContent = state ? 'Rendering preview…' : unavailableLabel335 if (state) loadPreviewImage(frame, state)336 return frame337}338339async function loadPreviewImage(frame, state) {340 if (frame.dataset.previewStarted) return341 const image = frame.querySelector('img')342 frame.dataset.previewStarted = 'true'343 try {344 image.src = await previewRenderer.render(state, {345 appearance: systemColorScheme.matches ? 'dark-transparent' : 'light-transparent',346 priority: true,347 })348 if (!image.isConnected) return349 await image.decode().catch(() => {})350 if (!image.isConnected) return351 image.hidden = false352 frame.dataset.previewState = 'ready'353 } catch {354 if (!image.isConnected) return355 frame.dataset.previewState = 'error'356 frame.querySelector('.launcher-preview-placeholder small').textContent = 'Preview unavailable'357 }358}359360function detailBody({ title, description = '', tags = [], facts = [], source = '' }) {361 const body = document.createElement('div')362 body.className = 'launcher-detail-body'363 const heading = document.createElement('h1')364 heading.textContent = title365 body.append(heading)366 if (description) {367 const copy = document.createElement('p')368 copy.textContent = description369 body.append(copy)370 }371 const tagList = document.createElement('div')372 tagList.className = 'launcher-tags'373 for (const value of tags) {374 const tag = document.createElement('span')375 tag.textContent = value376 tagList.append(tag)377 }378 body.append(tagList)379 const factsList = document.createElement('dl')380 for (const [term, value] of facts) {381 const dt = document.createElement('dt'); dt.textContent = term382 const dd = document.createElement('dd'); dd.textContent = value383 factsList.append(dt, dd)384 }385 body.append(factsList)386 const snippet = document.createElement('pre')387 snippet.textContent = source.slice(0, 1200)388 body.append(snippet)389 return body390}391392function detailFooter(actions) {393 const footer = document.createElement('footer')394 footer.className = 'launcher-detail-footer'395 for (const [action, label, primary] of actions) {396 const button = document.createElement('button')397 button.type = 'button'398 button.dataset.detailAction = action399 button.className = primary ? 'primary-action' : 'secondary-action'400 button.textContent = label401 footer.append(button)402 }403 return footer404}405406function renderStorage() {407 const count = recents.length408 const bytes = recents.reduce((total, entry) => total + (entry.bytes || 0), 0)409 const root = document.querySelector('#launcher-storage')410 root.replaceChildren()411 const summary = document.createElement('span')412 summary.textContent = count ? `${count} diagram${count === 1 ? '' : 's'} - ${formatBytes(bytes)} on this device` : 'Nothing stored on this device yet'413 root.append(summary)414 if (count) {415 const clear = document.createElement('button')416 clear.type = 'button'417 clear.dataset.clearRecents = ''418 clear.textContent = 'Clear recents…'419 root.append(clear)420 }421}422423function visibleFormats() {424 const matches = new Set(filterLauncherCatalog(filterInput?.value ?? requestedType).map(entry => entry.id))425 return launcherCatalogGroups.flatMap(group => group.items).filter(entry => matches.has(entry.id) && (activeGroup === 'all' || entry.groupId === activeGroup))426}427428function handleDetailAction(event) {429 const button = event.target.closest('[data-detail-action]')430 if (!button) return431 const action = button.dataset.detailAction432 const recent = selected.kind === 'recent' ? recents.find(entry => recentId(entry) === selected.id) : null433 if (action === 'create') return navigateToFormat(selected.id)434 if (!recent) return435 if (action === 'open') return openRecent(recent)436 if (action === 'duplicate' && recent.kind === 'local') return duplicateLocal(recent)437 if (action === 'delete') removeRecent(recent)438}439440function activateSelection() {441 const recent = selected.kind === 'recent' ? recents.find(entry => recentId(entry) === selected.id) : null442 if (recent) openRecent(recent)443 else navigateToFormat(selected.id)444}445446function navigateToFormat(type) {447 const url = new URL('/', location.origin)448 url.searchParams.set('type', type)449 location.assign(`${url.pathname}${url.search}`)450}451452function openRecent(entry) {453 if (entry.kind === 'local') location.assign(`/?doc=${encodeURIComponent(entry.localId)}`)454 else location.assign(`/d/${entry.aliasId}${entry.mode !== 'locked' && entry.type ? `?type=${encodeURIComponent(entry.type)}` : ''}`)455}456457function duplicateLocal(entry) {458 const document = localDocuments.loadLocalDocument(entry.localId)459 if (!document) return460 const title = `${document.state.meta?.title || entry.title || 'Untitled'} copy`461 const state = { ...document.state, meta: { ...document.state.meta, title } }462 const localId = localDocuments.saveLocalDocument({ ...document, state, title, dirty: true })463 if (localId) location.assign(`/?doc=${encodeURIComponent(localId)}`)464}465466function removeRecent(entry) {467 const label = entry.kind === 'local' ? 'Delete this local-only diagram? This cannot be undone.' : 'Remove this published diagram from recents? The published link will keep working.'468 if (!confirm(label)) return469 if (entry.kind === 'local') localDocuments.deleteLocalDocument(entry.localId)470 else localDocuments.removeRecent({ aliasId: entry.aliasId })471 recents = localDocuments.loadRecents()472 selected = recents[0] ? { kind: 'recent', id: recentId(recents[0]) } : { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }473 render()474}475476async function openEditableFile(event) {477 const file = event.target.files?.[0]478 event.target.value = ''479 if (!file) return480 try {481 const state = await importEditableSvgFile(file)482 const localId = localDocuments.saveLocalDocument({ state, detailsSource: null, fileSnapshot: null, drafts: {}, dirty: true })483 if (!localId) throw new Error('The file could not be stored on this device.')484 location.assign(`/?doc=${encodeURIComponent(localId)}`)485 } catch (error) {486 alert(error instanceof Error ? error.message : 'Could not open this editable SVG.')487 }488}489490function openPasteDialog() {491 const dialog = document.querySelector('#paste-dialog')492 document.querySelector('#paste-source').value = ''493 document.querySelector('#paste-type').value = DEFAULT_DIAGRAM_TYPE494 dialog.showModal()495 document.querySelector('#paste-source').focus()496}497498function createPastedDiagram(event) {499 event.preventDefault()500 const type = document.querySelector('#paste-type').value501 const source = document.querySelector('#paste-source').value502 if (!source.trim()) return503 const state = { ...exampleStateFor(type), source, meta: { title: '', description: '' } }504 const localId = localDocuments.saveLocalDocument({ state, detailsSource: null, fileSnapshot: null, drafts: {}, dirty: true })505 if (!localId) return alert('This diagram could not be stored on this device.')506 location.assign(`/?doc=${encodeURIComponent(localId)}`)507}508509function detectDiagramType(source) {510 const value = source.trim()511 if (!value) return null512 if (/^@startuml\b/m.test(value)) return 'plantuml'513 if (/^(?:flowchart|graph|sequenceDiagram|classDiagram|stateDiagram|gantt)\b/m.test(value)) return 'mermaid'514 if (/^(?:di)?graph\s+[\w"]*\s*\{/m.test(value)) return 'graphviz'515 if (/^workspace\b/m.test(value)) return 'structurizr'516 if (/^\s*<mxfile\b|<diagram\b/.test(value)) return 'diagramsnet'517 if (/^\s*<\?xml[\s\S]*<definitions\b/.test(value)) return 'bpmn'518 if (/^\s*\{[\s\S]*"(?:signal|head|config)"\s*:/.test(value)) return 'wavedrom'519 if (/^\s*(?:Table|Project|Enum)\b/m.test(value)) return 'dbml'520 return null521}522523function moveFormatSelection(key) {524 const formats = visibleFormats()525 if (!formats.length) return526 const current = Math.max(0, formats.findIndex(entry => selected.kind === 'format' && entry.id === selected.id))527 const columns = listMode ? 1 : Math.max(1, Math.floor(document.querySelector('#launcher-catalog').clientWidth / 175))528 const delta = key === 'ArrowRight' ? 1 : key === 'ArrowLeft' ? -1 : key === 'ArrowDown' ? columns : -columns529 const index = (current + delta + formats.length) % formats.length530 selected = { kind: 'format', id: formats[index].id }531 render()532 document.querySelector(`[data-format-id="${formats[index].id}"]`)?.scrollIntoView({ block: 'nearest' })533}534535function selectDefaultFormat() {536 activeGroup = 'all'537 filterInput.value = ''538 selected = { kind: 'format', id: DEFAULT_DIAGRAM_TYPE }539 render()540 document.querySelector(`[data-format-id="${DEFAULT_DIAGRAM_TYPE}"]`)?.focus()541}542543function recentId(entry) {544 return entry.kind === 'local' ? `local:${entry.localId}` : `alias:${entry.aliasId}`545}546547function diagramLabel(type) {548 return launcherEntryFor(type)?.label ?? type ?? 'Diagram'549}550551function formatMonogram(type) {552 return (launcherEntryFor(type)?.label ?? '?').replace(/[^A-Za-z0-9]/g, '').slice(0, 2).toUpperCase()553}554555function relativeTime(timestamp) {556 const elapsed = Math.max(0, Date.now() - Number(timestamp || 0))557 const minute = 60_000558 const hour = 60 * minute559 const day = 24 * hour560 if (elapsed < minute) return 'just now'561 if (elapsed < hour) return `${Math.floor(elapsed / minute)}m ago`562 if (elapsed < day) return `${Math.floor(elapsed / hour)}h ago`563 if (elapsed < 7 * day) return `${Math.floor(elapsed / day)}d ago`564 return new Date(timestamp).toLocaleDateString()565}566567function formatBytes(bytes) {568 if (bytes < 1024) return `${bytes} B`569 return `${(bytes / 1024).toFixed(bytes < 10 * 1024 ? 1 : 0)} KB`570}