From ded60cf32b8a2c00bca8790c0cc863a5243e2f6d Mon Sep 17 00:00:00 2001 From: pitpit Date: Thu, 4 Jun 2026 01:33:01 +0200 Subject: [PATCH 1/4] feat: refactor system rack and introduce library panel --- src/index.scss | 1 - src/ui/Library.ts | 7 +++---- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/index.scss b/src/index.scss index d296cfd..f4c80de 100644 --- a/src/index.scss +++ b/src/index.scss @@ -162,7 +162,6 @@ h1, h2, h3, h4, h5, h6 { color: $white; } } - &:focus-visible { background: $black; color: $white; diff --git a/src/ui/Library.ts b/src/ui/Library.ts index 1f2e554..0bb80b9 100644 --- a/src/ui/Library.ts +++ b/src/ui/Library.ts @@ -21,6 +21,7 @@ import ControlMeter from '../control/ControlMeter'; import MidiIn from '../control/MidiIn'; import Oscilloscope from '../control/Oscilloscope'; import Speaker from '../output/Speaker'; +import StickyNote from '../annotation/StickyNote'; type ModConstructor = new () => Mod; type Category = 'oscillator' | 'effect' | 'filter' | 'control' | 'output' | 'misc'; @@ -52,6 +53,7 @@ const PROTOS: ProtoEntry[] = [ { Ctor: Oscilloscope, label: 'scope', category: 'control' }, { Ctor: Speaker, label: 'speaker', category: 'output' }, { Ctor: MidiIn, label: 'midi', category: 'misc' }, + { Ctor: StickyNote, label: 'note', category: 'misc' }, ]; const CATEGORY_ORDER: Category[] = ['oscillator', 'effect', 'filter', 'control', 'output', 'misc']; @@ -213,7 +215,6 @@ export default class Library { } this.panelGroup?.visible(true); - if (this.animationFrameId !== null) { this.applyPanelTransform(this.animatedScreenX); return; @@ -303,7 +304,6 @@ export default class Library { rack.stage.draggable(true); rack.enableStageGestures(); }); - // Background covers the full panel width. this.panelBg = new Konva.Rect({ x: 0, @@ -458,8 +458,7 @@ export default class Library { layer.batchDraw(); }); - // Block stage pan for any click anywhere inside the panel (belt-and-suspenders - // alongside stage.draggable(false) set in open()). + // Block stage pan for any click anywhere inside the panel. this.panelGroup.on('mousedown touchstart', (e) => { if (!this.isOpen) return; e.cancelBubble = true; From 29fd09477bae508b79b61c67d46637aa7a82b0fc Mon Sep 17 00:00:00 2001 From: pitpit Date: Thu, 4 Jun 2026 08:47:07 +0200 Subject: [PATCH 2/4] fix: performances --- src/control/Knob.ts | 5 +- src/control/Oscilloscope.ts | 95 ++++++++++++++++++++++++++++++------- src/core/Mod.ts | 58 ++++++++++++++++------ 3 files changed, 121 insertions(+), 37 deletions(-) diff --git a/src/control/Knob.ts b/src/control/Knob.ts index 8e37e1e..f22e43a 100644 --- a/src/control/Knob.ts +++ b/src/control/Knob.ts @@ -180,10 +180,7 @@ export default class Knob extends Mod implements KnobMemoryConsumer { x, y, }); - if (this.innerCircle) { - this.innerCircle.draw(); - } - this.pinCircle.draw(); + this.pinCircle.getLayer()?.batchDraw(); } } diff --git a/src/control/Oscilloscope.ts b/src/control/Oscilloscope.ts index 81b7306..e54cc78 100644 --- a/src/control/Oscilloscope.ts +++ b/src/control/Oscilloscope.ts @@ -9,6 +9,8 @@ import ControlSignal from '../core/ControlSignal'; import Signals from '../core/Signals'; export default class Oscilloscope extends EffectMod { + private static readonly TARGET_FRAME_MS = 33; + private waveformLineLeft: Konva.Line | null = null; private waveformLineRight: Konva.Line | null = null; @@ -23,6 +25,12 @@ export default class Oscilloscope extends EffectMod { /** Amplitude multiplier applied to the waveform. Range [0.1, 10]. */ private amplitude: number = 0.5; + private leftPoints: number[] = []; + + private rightPoints: number[] = []; + + private lastFrameTs = 0; + constructor() { super(); this.configure([PlugType.IN, PlugType.CTRLIN, PlugType.OUT, PlugType.CTRLIN], 'scope'); @@ -74,8 +82,8 @@ export default class Oscilloscope extends EffectMod { * Find the first rising zero-crossing in the buffer, leaving at least `samples` * entries after the index. Returns 0 as fallback when no crossing is found. */ - private findTriggerIndex(data: Float32Array): number { - const searchLimit = data.length - this.samples; + private findTriggerIndex(data: Float32Array, samples: number): number { + const searchLimit = data.length - samples; for (let i = 1; i < searchLimit; i += 1) { if (data[i - 1] < 0 && data[i] >= 0) { return i; @@ -84,6 +92,33 @@ export default class Oscilloscope extends EffectMod { return 0; } + private hasInputSource(): boolean { + return this.plugs.getPlug(PlugPosition.NORTH).mod !== null; + } + + private fillPoints( + data: Float32Array, + triggerIdx: number, + samples: number, + points: number[], + padX: number, + dw: number, + midY: number, + ampScale: number, + ): number[] { + const needed = samples * 2; + if (points.length !== needed) { + points.length = needed; + } + const stepX = samples > 1 ? dw / (samples - 1) : 0; + + for (let i = 0; i < samples; i += 1) { + points[2 * i] = padX + i * stepX; + points[2 * i + 1] = midY - data[triggerIdx + i] * ampScale; + } + return points; + } + draw(group: Konva.Group): void { this.group = group; const w = group.width(); @@ -133,10 +168,14 @@ export default class Oscilloscope extends EffectMod { }); clipGroup.add(this.waveformLineRight); - this.startAnimation(group); + if (this.hasInputSource()) { + this.startAnimation(group); + } } private startAnimation(group: Konva.Group): void { + if (this.animationFrameId !== null) return; + const w = group.width(); const h = group.height(); const padX = 10; @@ -145,32 +184,47 @@ export default class Oscilloscope extends EffectMod { const dh = h - 2 * padY; const midY = padY + dh / 2; - const buildPoints = (data: Float32Array, triggerIdx: number, samples: number): number[] => { - const points: number[] = []; - for (let i = 0; i < samples; i += 1) { - points.push(padX + (i / (samples - 1)) * dw); - points.push(midY - data[triggerIdx + i] * this.amplitude * (dh / 2 - 2)); - } - return points; - }; - - const animate = () => { + const animate = (timestamp: number) => { this.animationFrameId = requestAnimationFrame(animate); if (!this.effectNode || !this.waveformLineLeft || !this.waveformLineRight) return; + if (!this.hasInputSource()) return; + if (timestamp - this.lastFrameTs < Oscilloscope.TARGET_FRAME_MS) return; + + this.lastFrameTs = timestamp; const [left, right] = (this.effectNode as ToneAnalyser).getValue() as Float32Array[]; - const samples = this.samples; + const samples = Math.max(16, Math.min(this.samples, left.length, right.length)); + const ampScale = this.amplitude * (dh / 2 - 2); - const triggerIdxLeft = this.findTriggerIndex(left); - this.waveformLineLeft.points(buildPoints(left, triggerIdxLeft, samples)); + const triggerIdxLeft = this.findTriggerIndex(left, samples); + this.waveformLineLeft.points(this.fillPoints( + left, + triggerIdxLeft, + samples, + this.leftPoints, + padX, + dw, + midY, + ampScale, + )); - const triggerIdxRight = this.findTriggerIndex(right); - this.waveformLineRight.points(buildPoints(right, triggerIdxRight, samples)); + const triggerIdxRight = this.findTriggerIndex(right, samples); + this.waveformLineRight.points(this.fillPoints( + right, + triggerIdxRight, + samples, + this.rightPoints, + padX, + dw, + midY, + ampScale, + )); group.getLayer()?.batchDraw(); }; + this.lastFrameTs = 0; this.animationFrameId = requestAnimationFrame(animate); } @@ -186,6 +240,11 @@ export default class Oscilloscope extends EffectMod { protected override onUnlinked(plugPosition: number, prev: Mod): void { if (plugPosition === PlugPosition.NORTH) { + if (this.animationFrameId !== null) { + cancelAnimationFrame(this.animationFrameId); + this.animationFrameId = null; + } + this.lastFrameTs = 0; this.waveformLineLeft?.points([]); this.waveformLineRight?.points([]); this.waveformLineLeft?.getLayer()?.batchDraw(); diff --git a/src/core/Mod.ts b/src/core/Mod.ts index ba3f509..250fa1b 100644 --- a/src/core/Mod.ts +++ b/src/core/Mod.ts @@ -132,6 +132,9 @@ export default abstract class Mod { group.on('dragstart', () => { document.body.style.cursor = 'grab'; shadow.show(); + shadowVisible = true; + shadowSlotX = this.x; + shadowSlotY = this.y; shadow.moveToTop(); group.moveToTop(); @@ -142,6 +145,21 @@ export default abstract class Mod { // to move the Mod back to this slot if dropped let targetX = this.x; let targetY = this.y; + let shadowVisible = false; + let shadowSlotX = targetX; + let shadowSlotY = targetY; + let drawScheduled = false; + + const scheduleStageDraw = (): void => { + const stage = group.getStage(); + if (!stage || drawScheduled) return; + drawScheduled = true; + + requestAnimationFrame(() => { + drawScheduled = false; + stage.batchDraw(); + }); + }; group.on('dragend', () => { document.body.style.cursor = ''; @@ -156,6 +174,7 @@ export default abstract class Mod { this.events.emit('checkDeleteZone', group.x(), group.y(), this.width * slotWidth, this.height * slotHeight, deleteResult); if (deleteResult.inDeleteZone) { shadow.hide(); + shadowVisible = false; this.events.emit('delete'); return; } @@ -199,9 +218,12 @@ export default abstract class Mod { this.events.emit('checkDeleteZone', group.x(), group.y(), this.width * slotWidth, this.height * slotHeight, moveResult); if (moveResult.inDeleteZone) { - shadow.hide(); + if (shadowVisible) { + shadow.hide(); + shadowVisible = false; + scheduleStageDraw(); + } this.events.emit('deleteZoneChange', true); - group.getStage()?.batchDraw(); return; } @@ -209,8 +231,11 @@ export default abstract class Mod { // Above the main rack (but not in delete zone): hide snap shadow if (group.y() < 0) { - shadow.hide(); - group.getStage()?.batchDraw(); + if (shadowVisible) { + shadow.hide(); + shadowVisible = false; + scheduleStageDraw(); + } return; } @@ -218,7 +243,11 @@ export default abstract class Mod { document.body.style.cursor = 'grab'; // Restore shadow visibility if it was hidden while above rack - shadow.show(); + if (!shadowVisible) { + shadow.show(); + shadowVisible = true; + scheduleStageDraw(); + } // Compute new position let x = Math.round(group.x() / slotWidth); @@ -228,22 +257,21 @@ export default abstract class Mod { if (!this.rack.isBusy(x, y, this)) { // Move the shadow to the current slot - shadow.position({ - x: padding + x * slotWidth, - y: padding + y * slotHeight, - }); + if (x !== shadowSlotX || y !== shadowSlotY) { + shadow.position({ + x: padding + x * slotWidth, + y: padding + y * slotHeight, + }); + shadowSlotX = x; + shadowSlotY = y; + scheduleStageDraw(); + } // Store the position, to move the Mod to this position // if next slot is busy targetX = x; targetY = y; - const stage = group.getStage(); - if (!stage) { - throw new Error('No Stage attached to this Konva Group'); - } - stage.batchDraw(); - this.events.emit('dragmove'); } }); From a1df41aa4e34cabbe08dd3fe1c0a1cad7f222913 Mon Sep 17 00:00:00 2001 From: pitpit Date: Thu, 4 Jun 2026 20:34:57 +0200 Subject: [PATCH 3/4] feat: add performance overlay for FPS monitoring and display --- src/index.scss | 44 ++++++++++++ src/index.ts | 4 ++ src/types.d.ts | 6 ++ src/ui/PerformanceOverlay.ts | 132 +++++++++++++++++++++++++++++++++++ vite.config.ts | 5 +- 5 files changed, 190 insertions(+), 1 deletion(-) create mode 100644 src/ui/PerformanceOverlay.ts diff --git a/src/index.scss b/src/index.scss index f4c80de..d91b2fd 100644 --- a/src/index.scss +++ b/src/index.scss @@ -218,6 +218,50 @@ h1, h2, h3, h4, h5, h6 { } } +// PERFORMANCE OVERLAY +#perf-overlay { + position: fixed; + top: 16px; + left: 16px; + z-index: 1100; + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 8px; + pointer-events: none; +} + +#perf-overlay-badge { + pointer-events: auto; + border: 2px solid $black; + border-radius: 999px; + background: rgba(255, 255, 255, 0.92); + color: $black; + font-family: 'Rubik', Helvetica, sans-serif; + font-size: 12px; + font-weight: 500; + line-height: 1; + padding: 8px 10px; + cursor: pointer; + min-width: 76px; + text-align: center; +} + +#perf-overlay-details { + pointer-events: auto; + white-space: pre; + border: 2px solid $black; + border-radius: 4px; + background: rgba(255, 255, 255, 0.96); + color: $black; + font-family: 'Courier New', Courier, monospace; + font-size: 11px; + line-height: 1.35; + padding: 10px; + min-width: 250px; + max-width: min(360px, calc(100vw - 32px)); +} + #burger-btn { display: flex; align-items: center; diff --git a/src/index.ts b/src/index.ts index 1e9ace1..5e94820 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,6 +3,7 @@ import Library from './ui/Library'; import { importRack } from './core/RackSerializer'; import BurgerMenu from './ui/BurgerMenu'; import createStickyNoteButton from './ui/StickyNoteButton'; +import PerformanceOverlay from './ui/PerformanceOverlay'; import './index.scss'; const rack = new Rack(); @@ -10,6 +11,9 @@ rack.library = new Library(rack); new BurgerMenu(rack); createStickyNoteButton(); +if (__SHOW_FPS__) { + new PerformanceOverlay(rack); +} // Expose programmatic API (used by e2e tests) (window as unknown as { synt: { importRack: (yaml: string) => void } }).synt = { importRack: (yaml: string) => { importRack(yaml, rack, { silent: true }); } }; diff --git a/src/types.d.ts b/src/types.d.ts index 994c461..d44ddd2 100644 --- a/src/types.d.ts +++ b/src/types.d.ts @@ -14,3 +14,9 @@ declare module '*.jpeg' { const content: string; export default content; } +declare module '*.scss' { + const content: string; + export default content; +} + +declare const __SHOW_FPS__: boolean; diff --git a/src/ui/PerformanceOverlay.ts b/src/ui/PerformanceOverlay.ts new file mode 100644 index 0000000..2884ad4 --- /dev/null +++ b/src/ui/PerformanceOverlay.ts @@ -0,0 +1,132 @@ +import Rack from '../core/Rack'; + +interface OverlayElements { + root: HTMLDivElement; + badge: HTMLButtonElement; + details: HTMLDivElement; +} + +export default class PerformanceOverlay { + private static readonly SAMPLE_INTERVAL_MS = 500; + + private readonly rack: Rack; + + private readonly elements: OverlayElements; + + private lastFrameTs = 0; + + private sampleStartTs = 0; + + private sampleFrames = 0; + + private fps = 0; + + private frameMs = 0; + + private rafId: number | null = null; + + private detailsVisible = false; + + constructor(rack: Rack) { + this.rack = rack; + this.elements = this.createElements(); + this.bindEvents(); + this.start(); + } + + private createElements(): OverlayElements { + const root = document.createElement('div'); + root.id = 'perf-overlay'; + + const badge = document.createElement('button'); + badge.id = 'perf-overlay-badge'; + badge.type = 'button'; + badge.textContent = 'FPS --'; + badge.setAttribute('aria-label', 'Toggle performance overlay details'); + + const details = document.createElement('div'); + details.id = 'perf-overlay-details'; + details.hidden = true; + + root.appendChild(badge); + root.appendChild(details); + document.body.appendChild(root); + + return { root, badge, details }; + } + + private bindEvents(): void { + this.elements.badge.addEventListener('click', () => { + this.detailsVisible = !this.detailsVisible; + this.elements.details.hidden = !this.detailsVisible; + }); + + window.addEventListener('keydown', (event: KeyboardEvent) => { + if (event.key.toLowerCase() !== 'd') return; + this.detailsVisible = !this.detailsVisible; + this.elements.details.hidden = !this.detailsVisible; + }); + } + + private start(): void { + this.lastFrameTs = performance.now(); + this.sampleStartTs = this.lastFrameTs; + + const tick = (timestamp: number): void => { + const delta = timestamp - this.lastFrameTs; + this.lastFrameTs = timestamp; + this.frameMs = delta; + this.sampleFrames += 1; + + const sampleElapsed = timestamp - this.sampleStartTs; + if (sampleElapsed >= PerformanceOverlay.SAMPLE_INTERVAL_MS) { + this.fps = Math.round((this.sampleFrames * 1000) / sampleElapsed); + this.sampleFrames = 0; + this.sampleStartTs = timestamp; + this.render(); + } + + this.rafId = requestAnimationFrame(tick); + }; + + this.rafId = requestAnimationFrame(tick); + } + + private render(): void { + this.elements.badge.textContent = `FPS ${String(this.fps)}`; + + if (!this.detailsVisible) return; + + const scale = this.rack.stage.scaleX(); + const x = this.rack.stage.x(); + const y = this.rack.stage.y(); + const layers = this.rack.stage.getLayers().length; + + const libraryState = this.rack.library?.getDebugState() ?? { + open: false, + scrollY: 0, + contentHeight: 0, + drawCalls: 0, + wheelEvents: 0, + pointerMoves: 0, + }; + + this.elements.details.textContent = [ + `Frame ${this.frameMs.toFixed(1)} ms`, + `Stage scale ${scale.toFixed(2)}`, + `Stage pos ${x.toFixed(0)}, ${y.toFixed(0)}`, + `Layers ${String(layers)} | Mods ${String(this.rack.mods.length)} | Notes ${String(this.rack.annotations.length)}`, + `Library ${libraryState.open ? 'open' : 'closed'} | Scroll ${libraryState.scrollY.toFixed(0)}/${libraryState.contentHeight.toFixed(0)}`, + `Library events/s wheel ${String(libraryState.wheelEvents)} pointer ${String(libraryState.pointerMoves)}`, + `Library draw/s ${String(libraryState.drawCalls)}`, + ].join('\n'); + } + + destroy(): void { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId); + this.rafId = null; + } + this.elements.root.remove(); + } +} diff --git a/vite.config.ts b/vite.config.ts index 4447ff8..cbbd9b0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -2,7 +2,7 @@ import { defineConfig } from 'vite'; import legacy from '@vitejs/plugin-legacy'; import { resolve } from 'path'; -export default defineConfig(async () => { +export default defineConfig(async ({ mode }) => { const { default: cssInjectedByJsPlugin } = await import('vite-plugin-css-injected-by-js'); return { base: '/synt/', @@ -31,5 +31,8 @@ export default defineConfig(async () => { open: true, host: '0.0.0.0' }, + define: { + __SHOW_FPS__: mode !== 'production', + }, }; }); From 5ab049416373ec95e708a666b14e6583424b8d1e Mon Sep 17 00:00:00 2001 From: pitpit Date: Thu, 4 Jun 2026 20:46:58 +0200 Subject: [PATCH 4/4] fix: lint --- .github/instructions/lint.instructions.md | 24 +++++++++++++++++++++++ README.md | 8 +++----- src/ui/PerformanceOverlay.ts | 14 ++----------- 3 files changed, 29 insertions(+), 17 deletions(-) diff --git a/.github/instructions/lint.instructions.md b/.github/instructions/lint.instructions.md index 00a45dc..7617d2e 100644 --- a/.github/instructions/lint.instructions.md +++ b/.github/instructions/lint.instructions.md @@ -81,6 +81,30 @@ items.forEach(function(item) { process(item); }); - No floating promises — always `await` or explicitly `.catch()` async calls. - No unused variables (warn). +### Preventing Unsafe Debug-State Access + +- Never read debug/diagnostic objects through untyped APIs (implicit `any` or unresolved method types). +- If a class exposes debug state, define and export an explicit interface for the return shape. +- Type the producing method return value (for example: `getDebugState(): DebugState`). +- Consume debug state only through that typed contract, not via casts like `as any`. +- If a typed contract is not available yet, prefer rendering a minimal safe fallback string/value instead of unsafe property access. + +```ts +// ✅ preferred: typed producer + typed consumer +export interface LibraryDebugState { + open: boolean; + scrollY: number; +} + +getDebugState(): LibraryDebugState { + return { open: this.isOpen, scrollY: this.scrollY }; +} + +// ❌ avoid: unresolved/untyped call chain used in template expressions +const state = rack.library?.getDebugState(); +label.text = `${state.open} ${state.scrollY}`; +``` + Project override: `@typescript-eslint/no-unused-vars` is configured as a warning (not an error), which is useful for temporary scaffolding while iterating. ## Running the Linter diff --git a/README.md b/README.md index 809b915..0693ab9 100644 --- a/README.md +++ b/README.md @@ -96,8 +96,6 @@ An HTML report is generated in `playwright-report/` after each run. - https://www.youtube.com/shorts/kY7BvhUemLE - https://teenage.engineering/store/po-33 - [ ] create duplicable/instanciable modules (to build more complex instruments) -- [ ] Add a file player Mod? -- [ ] Protect against link loop? - [X] when connecting a knob on a Mod, it should set its value to the linked Mod value (and animate) #### Oscillators & Sources @@ -183,11 +181,11 @@ Step E: Making SoundPatch the Quantizer CV Out into the 1V/Oct Input of your Wav ### UX -- [ ] redesign StickyNote to note be in the Rack anymore and be on a layer above. Save the position in yaml export and restore it. -- [ ] multi selection & multi drag'n drop for mass deletion +- [ ] multi selection & multi drag'n drop for mass deletion? - [ ] on Rack, set a "pan" cursor when the cursor is hover the stage and can be pan - [ ] Add several layers to put more Mods? - [ ] Ability to rotate Mod on Rack? +- [X] redesign StickyNote to note be in the Rack anymore and be on a layer above. Save the position in yaml export and restore it. - [X] import/export current value of the Knob in yaml - [X] extend the rotate/swipe zone of the knob to the outer circle - [X] extends the zone to click or tap the switchOn to the outter-square @@ -199,8 +197,8 @@ Step E: Making SoundPatch the Quantizer CV Out into the 1V/Oct Input of your Wav ### Codebase -- [X] Use vite preview instead of http-server for e2e tests - [ ] Enable again e2e test for firefox "AudioContext reaches running state after user gesture and Tone.start() does not reject" +- [X] Use vite preview instead of http-server for e2e tests - [X] Add a public licence - [X] Switch from Gibberish to Tone.js? - [X] Improve the src/ subtree structure to make it easier to understand diff --git a/src/ui/PerformanceOverlay.ts b/src/ui/PerformanceOverlay.ts index 2884ad4..22932bb 100644 --- a/src/ui/PerformanceOverlay.ts +++ b/src/ui/PerformanceOverlay.ts @@ -101,24 +101,14 @@ export default class PerformanceOverlay { const x = this.rack.stage.x(); const y = this.rack.stage.y(); const layers = this.rack.stage.getLayers().length; - - const libraryState = this.rack.library?.getDebugState() ?? { - open: false, - scrollY: 0, - contentHeight: 0, - drawCalls: 0, - wheelEvents: 0, - pointerMoves: 0, - }; + const libraryStatus = this.rack.library === null ? 'absent' : 'ready'; this.elements.details.textContent = [ `Frame ${this.frameMs.toFixed(1)} ms`, `Stage scale ${scale.toFixed(2)}`, `Stage pos ${x.toFixed(0)}, ${y.toFixed(0)}`, `Layers ${String(layers)} | Mods ${String(this.rack.mods.length)} | Notes ${String(this.rack.annotations.length)}`, - `Library ${libraryState.open ? 'open' : 'closed'} | Scroll ${libraryState.scrollY.toFixed(0)}/${libraryState.contentHeight.toFixed(0)}`, - `Library events/s wheel ${String(libraryState.wheelEvents)} pointer ${String(libraryState.pointerMoves)}`, - `Library draw/s ${String(libraryState.drawCalls)}`, + `Library ${libraryStatus}`, ].join('\n'); }