Skip to content

Client-side migration: unified renderer window with self-contained sign-in#651

Open
wikirby wants to merge 32 commits intomasterfrom
user/wikirby/ClientSideMigration
Open

Client-side migration: unified renderer window with self-contained sign-in#651
wikirby wants to merge 32 commits intomasterfrom
user/wikirby/ClientSideMigration

Conversation

@wikirby
Copy link
Copy Markdown

@wikirby wikirby commented Mar 26, 2026

Summary

  • Unified renderer window replaces the injected Mithril sidebar with a standalone renderer.html (content iframe + sidebar) opened by the service worker
  • Self-contained sign-in — renderer shows MSA/OrgId sign-in panel when not authenticated; worker opens OAuth popup; no more clipperInject.ts injection in the primary flow
  • Client-side capture pipeline — standalone contentCaptureInject.ts performs full DOM cleaning (master-compatible DomUtils baseline + enhancements: hidden element preservation, position neutralization, shadow DOM flattening, lazy image resolution)
  • Full-page screenshot — per-viewport captureVisibleTab with sidebar cropping, JPEG 95% stitching in renderer
  • Article mode — Readability via dynamic import in renderer, ONML cleanup for OneNote page styling
  • Bookmark mode — og:description/og:image extraction from content-frame DOM
  • Region/crop mode — standalone regionOverlay.ts with Shadow DOM instruction bar, crosshair overlay, multi-region thumbnails, i18n strings
  • Direct OneNote save — worker builds multipart form and POSTs directly to OneNote API (no server proxy)
  • i18n / accessibility / contrast — sign-in panel i18n, WCAG AA contrast fixes, focus outlines, ARIA roles (role="option", role="combobox", role="progressbar", role="alert", role="dialog"), keyboard navigation, aria-live announcements, <html lang> attribute
  • Section picker — auto-fetches fresh notebooks from OneNote API, falls back to localStorage cache
  • Port safetysafeSend() wrapper prevents disconnected port errors

Key new/modified files

File Description
src/renderer.html Unified window: content-frame + preview-frame + sidebar
src/scripts/renderer.ts All sidebar logic: modes, capture, save, i18n, a11y
src/styles/renderer.less LESS styles for sidebar, modes, region thumbnails
src/scripts/extensions/contentCaptureInject.ts Standalone DOM capture content script
src/scripts/extensions/regionOverlay.ts Crosshair overlay for region selection
src/scripts/extensions/webExtensionBase/webExtensionWorker.ts Window lifecycle, capture loop, save, sign-in handlers
gulpfile.js Build targets for new standalone scripts
docs/ Migration docs, unified window plan, i18n/a11y plan

Test plan

  • Full page screenshot capture on various sites (scrolling, fixed headers, responsive layouts)
  • Article mode extraction via Readability
  • Bookmark mode with og:image/og:description
  • Region capture with multi-region thumbnails
  • Sign-in / sign-out flow
  • Section picker notebook refresh
  • Save to OneNote across all modes
  • Keyboard navigation and screen reader accessibility
  • Edge and Chrome targets

🤖 Generated with Claude Code

wikirby and others added 24 commits March 6, 2026 04:05
Replace two server-side OneNote APIs with client-side implementations:

1. Article Extraction: Replace augmentation API with @mozilla/readability
   - Local Readability.js parsing instead of server POST
   - FullPage as default clip mode (no domain whitelist)

2. Full Page Screenshot: Replace DomEnhancer API with renderer window
   - Scroll-capture via captureVisibleTab in focused popup window
   - Canvas stitching with DPR-aware overlap detection
   - Binary MIME part upload (no base64 overhead)
   - URL rewriting for images, stylesheets, srcset, CSS url()
   - Fixed/sticky position neutralization
   - Mode-switch cancel/retry mechanism
   - Session storage cleanup

Known limitations:
- External CSS not inlined (next priority)
- Renderer window must stay visible (captureVisibleTab)
- Canvas height capped at 16384px, storage quota 10MB
- Test infrastructure uses deprecated PhantomJS

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
CSS caching pipeline: content script extracts CSS from document.styleSheets
(CSSOM), passes via PageInfo.stylesheetCache through the communicator to the
clipper UI, which stores it in chrome.storage.session. The renderer reads
cached CSS and replaces <link> tags with <style> blocks. For cross-origin
sheets (SecurityError), the renderer fetches CSS directly via fetch().

Renderer iframe isolation: page content renders inside an iframe to prevent
CSS conflicts between renderer styles and captured page styles.

Shadow DOM handling: flattenShadowDomSlots() detects shadow hosts via
element.shadowRoot on the live document (browser consumes <template shadowroot>
during parsing), hides non-button [slot] content since shadow roots are lost
during cloneNode(true).

Additional fixes:
- removeUnsupportedHrefs: use getAttribute("href") instead of linkElement.href
  (DOM property doesn't resolve on cloned documents, was stripping all
  relative-URL <link> tags)
- inlineHiddenElements: preserve display:none state from live page
- Sticky sidebar height capping to prevent grid layout stretching
- Viewport-height min-height reset (use 0, not auto)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Block user interaction (keydown, mousedown, wheel, touchstart) on the
iframe document to prevent accidental scrolling during capture.

Removed experimental features that caused regressions:
- Pixel-level blank space cropping (caused delay, row-by-row getImageData)
- Pre-adjustment height reporting (caused repeated elements on all pages)
- Sticky sidebar maxHeight cap (unnecessary with CSS grid-template-rows:auto)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pping

Detect when scrollY stops changing during capture (caused by fixed→absolute
conversion inflating scrollHeight) and stop capturing instead of looping to
the 16384px safety cap. Measure content height before position conversions
and use it to crop blank space from the stitched image. Also fix a race
condition where cleanup() async-removed screenshot keys that were immediately
re-set.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Block click, pointerdown, contextmenu, and selectstart events in addition
to existing keydown/mousedown/wheel/touchstart — prevents user interaction
with the renderer window during capture. Also add stopPropagation for
defense in depth.

Refactor cleanup() to accept removeOutputKeys flag: failure/cancel paths
remove screenshot output keys from session storage, while the success path
preserves them for fullPageScreenshotHelper to read. Reorder success path
to write output before cleanup to prevent async remove/set race.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Use a fixed full-screen div (#interaction-shield) at max z-index to block
all mouse, touch, pointer, hover, drag, and context menu interactions.
Keyboard and wheel events still blocked via JS listeners since they don't
respect z-index. Removes per-event blocking from both host page and iframe.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Instead of accumulating all viewport captures in session storage (4-8MB)
and stitching in fullPageScreenshotHelper, each capture is now sent back
to the renderer via port for incremental drawing onto a hidden canvas.
The renderer produces a single final JPEG and stores it in session storage.

- Captures switched to PNG (lossless) — single JPEG encode at finalize
- stitchImages() and ScrollData removed from fullPageScreenshotHelper
- Session storage holds only HTML + one final JPEG (~1-2MB total)
- Overlap/DPR calculation moved to renderer drawCapture handler
- drawComplete ack prevents memory buildup from queued captures

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Reflect current architecture: incremental canvas stitching in renderer,
PNG captures via port, scroll stall detection, content height cropping,
interaction shield overlay, and simplified fullPageScreenshotHelper.
Update data flow diagram, key decisions, and known issues sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…dling

Replace interaction shield div with pointer-events:none on iframe CSS —
simpler, no compositing layer. Add imageSmoothingEnabled=false on stitch
canvas for pixel-perfect drawing. Lock renderer window size via resize
event listener. Handle port disconnect (user closes renderer) to prevent
clipper from spinning indefinitely. Switch final output to JPEG 95%.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…us renderer

- offscreenCommunicator: use chrome API directly instead of WebExtension.browser
  which is only initialized in service worker context (fixes Article mode crash)
- renderer: strip <link rel="preload" as="script"> and modulepreload tags that
  trigger CSP violations on extension pages
- worker: re-focus renderer window before each capture via windows.update to
  handle user clicking away mid-capture

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…LESS styles

Renderer window now includes a branded sidebar (322px) alongside the content
iframe. During capture, sidebar shows localized progress ("Capturing 3 of 5").
After capture, shows preview with Save/Close buttons.

- renderer.html: flexbox layout with content iframe + sidebar panel
- renderer.less: new LESS file using OneNote brand colors, compiled via gulp
- renderer.ts: sidebar progress updates, sidebar pixel cropping from captures,
  preview phase with scrollable image, localized strings from session storage
- fullPageScreenshotHelper.ts: passes fullPageStrings map with i18n labels
- webExtensionWorker.ts: window width = content + sidebar, passes totalViewports
- strings.json: new keys for IncrementalProgress and Saving
- gulpfile.js: compiles renderer.less alongside clipper.less

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…save flow

Complete Phase 2 implementation of the unified clipper window:
- Mode buttons (Full Page, Article, Bookmark, Region) with sidebar controls
- Article mode: Readability extraction directly from content-frame DOM
- Bookmark mode: og:image/description metadata extraction from DOM
- Section picker from cached localStorage notebooks
- Title, note, source URL fields with i18n from localStorage
- Save flow: worker builds multipart form, POSTs to OneNote API
- Post-save: "View in OneNote" button with page URL from API response
- Error display with expandable diagnostics (correlation ID, date, status)
- Sidebar auto-hides for signed-in users (hideUi via inject communicator)
- Window stays open after capture for mode switching
- Duplicate window prevention (focus existing on re-click)
- Tab navigation detection closes renderer window
- UI lock during save (all inputs disabled during API round-trip)
- Cancel + Clip side-by-side button row
- Modal-like focus retention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…gn-in flow

Sign-out: renderer → worker → uiCommunicator.showSignInPanel → clipper.tsx
resets state + clipperInject.showUi → sign-in panel appears. User info
footer pinned to sidebar bottom with email + sign-out link.

Section refresh: auto-fetches fresh notebooks from OneNote API on renderer
open (direct fetch with Bearer auth). Token expiration check uses relative
accessTokenExpiration with lastUpdated offset.

Region capture: standalone regionOverlay.ts injected into original tab via
scripting.executeScript. Crosshair overlay with canvas hole-punch selection.
Coords sent as JSON string (required by offscreen.ts message handler).
Worker captures tab as JPEG 95%, sends via port. Renderer crops with DPR
handling. Multi-region: thumbnails with × remove buttons, + Add another
region button. Regions cached across mode switches. Each region stored as
separate session storage key to avoid size limits.

Post-sign-in: clipper.tsx detects SignInAttempt updateReason, hides sidebar,
starts capture → opens renderer. Non-signed-in guard prevents renderer
opening without auth. Full-page preview restored from session storage when
switching back from region mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… polish

- Remove fullPageStrings legacy i18n passthrough from session storage
- Resolve fullPageScreenshotHelper promise on finalizeComplete instead of
  leaving it pending indefinitely
- Move save URL from session storage to port message (message.url) so all
  save parameters flow through the port; only fullPageFinalImage and
  regionImage_N remain in session storage (too large for port messages)
- Cache fullPageDataUrl in page variable for instant mode switching
  (no async session storage read needed)
- Session storage cleanup: worker cleanup() removes all fullPage*/regionImage*
  keys on window close; helper cleanup removes source data after finalize
- Readability: dynamic import pattern added (browserify still bundles inline;
  ready for bundler upgrade)
- Region overlay: hide scrollbar via CSS style injection instead of
  overflow:hidden (preserves scroll during selection); remove-all-regions
  stays in region mode with add button instead of snapping to fullpage
- Update plan doc with resolved tech debt items

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Worker opens renderer window directly on button click (bypasses
clipperInject.ts injection entirely). Renderer shows MSA/OrgId sign-in
overlay when not signed in; transitions to capture mode after OAuth.

Key changes:
- New contentCaptureInject.ts: standalone content script reads page DOM
  + stylesheets, sends to worker via chrome.runtime.sendMessage
- Worker: openRendererWindow() checks isUserLoggedIn, handles signIn/
  signOut port messages, content capture listener
- Renderer: sign-in overlay, signInResult/signOutComplete handlers,
  custom section picker (ul/li with scrollable dropdown), UI lock
  during capture, Close button, anti-maximize via chrome.windows API
- Region overlay: selection border drawn on canvas (no separate div),
  overflow:hidden on root, no scrollbar manipulation
- Sign-out keeps renderer open (shows sign-in overlay), full state
  reset including notebook cache and stale capture data

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Renderer telemetry: imports Funnel/LogMethods/Session enums, sends
  LogDataPackage via port to worker's logger (Invoke, AuthAttempted,
  AuthSignInCompleted/Failed, ClipAttempted, SignOut, session lifecycle)
- Worker: telemetry port handler routes to parseAndLogDataPackage;
  save handler refreshes token via auth.updateUserInfoData before API call
- docs/unified-window-plan.md: V3 flow diagram, V2/V3 checklist,
  telemetry docs (#14-16), consistent versioning
- docs/client-side-migration.md: V3 data flow, updated CSS fidelity
  refs, JPEG 95%, V3 evolution section

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extracted DOM cleaning functions from DomUtils into contentCaptureInject
as self-contained inline functions (zero external imports). Replaces the
previous raw outerHTML approach that truncated at 2MB and missed content.

Pipeline matches old clipperInject.ts → DomUtils.getCleanDomOfCurrentPage:
clone → inline hidden elements → flatten shadow DOM → canvas→image →
base tag → image sizes → remove unwanted items (scripts, noscript,
clipper elements, non-web links, binary styles) → remove srcset →
serialize. Plus lazy image resolution (data-src → src) for sites using
loading="lazy".

Output: ~10KB standalone script, no dependency on DomUtils/Constants/
ObjectUtils modules. Ready for dead code cleanup phase.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
contentCaptureInject.ts: Align with master DomUtils.getCleanDomOfCurrentPage
pipeline (isLocalReferenceUrl, split removeUnwantedItems into 5 sub-functions,
full DOCTYPE with publicId/systemId, iframe local-ref filtering). Layer
enhancements on top: neutralizePositioning converts sticky→relative and
fixed→absolute with !important to prevent repetition in stitched captures.

renderer.ts: Remove fullPageStylesheets session storage consumption (renderer
fetches CSS directly via <link> tags). Add safeSend() wrapper for all
port.postMessage calls to handle disconnected port errors. Inject
[hidden]{display:none!important} CSS override. Position neutralization
upgraded to use !important.

webExtensionWorker.ts: Remove fullPageStylesheets storage.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
unified-window-plan.md: Mark sticky element duplication as resolved
(!important fix). Update sign-out flow to V3 (stays in renderer).
Remove stale stylesheet reference from flow diagram. Add video/streaming
embed as known limitation. Add safeSend, [hidden] CSS, position
neutralization to verification checklist.

client-side-migration.md: Remove resolved "bottom void" from remaining
issues. Add video/streaming embed limitation.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
renderer.ts: Add cleanArticleHtml() to strip ONML-unsupported elements
and style/class attributes from Readability output before caching —
ensures preview matches what gets saved to OneNote (old toOnml pipeline
parity). Fix article/bookmark preview styling to match OneNote page
layout: Segoe UI 11pt, 624px max-width (@OneNotePageWidth), left-aligned
margin instead of auto-centered.

docs: Add CSR/shadow DOM sites as known limitation (pre-existing,
shared with server-side Puppeteer). Document article mode ONML cleanup
in CSS fidelity section.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
contentCaptureInject.ts: Inline the original page's computed body
font-size onto the cloned body element. CSS reset stylesheets (e.g.,
body{font-size:75%}) may resolve differently in the renderer iframe
due to stylesheet loading order, causing text to render 25% smaller.
Captures the actual computed value and applies it with !important,
matching the pattern used for inlineHiddenElements and
neutralizePositioning.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Feedback link (OrgId users only, matching old sidebar):
- renderer.html: Smiley icon + i18n label in footer, left-aligned.
  Footer restructured: feedback left, email + sign-out stacked right.
- renderer.ts: Click handler sends openFeedback port message with
  page URL. Hidden for MSA users. i18n via WebClipper.Action.Feedback.
- renderer.less: Footer layout with flex-end alignment, stacked
  user-info-right, feedback-icon and separator styling.
- webExtensionWorker.ts: openFeedback handler builds feedback URL
  with all 6 params (LogCategory, originalUrl, clipperId, usid,
  version, type). Opens 1000x700 popup via chrome.windows.create.

Session USID and API correlation:
- Per-session USID generated in launchRenderer (cccccccc- prefix +
  v4 UUID tail, matching old logger pattern). Sent as X-UserSessionId
  header in save API calls for server-side log correlation. Same USID
  used in feedback URL for support ticket correlation.
- Refactored GUID generation into static newGuid() helper, used for
  both sessionUsid and per-request X-CorrelationId (was non-standard
  timestamp-based, now proper v4 UUID matching StringUtils.generateGuid).

Error diagnostics:
- Copy button (clipboard emoji) next to "More information" in error
  display. Uses navigator.clipboard.writeText (no extra permissions).
  Shows green checkmark on success. stopPropagation prevents toggling
  details open/close.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- i18n: wire sign-in panel, field labels, error messages to loc() using
  existing server-translated keys (zero new strings.json keys needed)
- Contrast: error color #ff6b6b→#ff9999 (WCAG AA), region btn border
  #bbb→#999 (SC 1.4.11), focus outlines 2px solid #f8f8f8
- ARIA: radiogroup/radio for mode buttons, combobox for section picker,
  role="dialog" on sign-in overlay, role="alert" on error, role="main"
  on sidebar, aria-live announcements, progressbar role
- Keyboard: arrow keys + Home/End on mode buttons and section picker,
  Escape to close dropdown, clean tab order (iframes tabindex=-1,
  Clip before Cancel), focus management on capture/sign-in transitions
- Tab order: mode buttons→title→note→section→Clip→Cancel→feedback→signout
- Focus outlines: 2px #f8f8f8 on purple, high-contrast Highlight mode
- Dynamic html lang from localStorage.locale (BCP 47)
- Removed blur re-focus handler (fought Alt+Tab, screen readers, popups)
- Keydown handler allows arrows/modifier combos for screen reader compat
- Screen reader testing: ARIA tree verified correct via edge://accessibility
  but Edge disables a11y API flags for extension popup windows (untested)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…fresh fix

Region overlay: Shadow DOM instruction bar with i18n text + "Back (Esc)"
button, CSS-isolated from page styles. Escape/Back stays in region mode
instead of switching to full page. Worker injects i18n strings via
window.__regionStrings before overlay script.

Renderer: add mode button tooltips using existing i18n keys. Remove token
expiry pre-check from notebook fetch so newly created sections appear.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@wikirby
Copy link
Copy Markdown
Author

wikirby commented Mar 26, 2026

@microsoft-github-policy-service agree company="Microsoft"

wikirby and others added 5 commits March 26, 2026 03:08
…t and image width

- Version bump to 3.11.0 across package.json and Chrome/Edge manifests
- Upgrade gulp-uglify v2→v3 (UglifyJS v3) to support ES6+ minification (Readability)
- Update preserveComments→output.comments for gulp-uglify v3 API, preserve license headers
- Remove "Clipping Page" status heading during capture (reserve "clipping" for save phase)
- Hide progress bar until first viewport capture arrives
- Fix full-page screenshot width in OneNote ONML: pass actual CSS width from renderer
  (contentPixelWidth / DPR) instead of pre-calculated window width, fixing aspect ratio

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Fix minified × character showing as "×" by declaring UTF-8 charset
- Update a11y docs: NVDA + Edge verified working after devbox reboot

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Mode panel: role="radiogroup" → role="toolbar", buttons use aria-pressed
- More natural UX: NVDA announces "toggle button, pressed" instead of "radio button"
- Arrow key navigation retained (standard toolbar pattern)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… timeout

- Add purple toolbar above article preview with three control groups:
  - Highlighter toggle: TextHighlighter injected into iframe, yellow (#fefe56)
    highlights with red circle × delete buttons (top-left corner)
  - Font family: Sans-serif (Verdana) / Serif (Georgia) toggle buttons
  - Font size: +/- buttons, 2px increments, 8px–72px range
- Font family and size applied to saved OneNote content via wrapping div style
- Highlight state preserved across mode switches (articleWorkingHtml snapshot)
- Save serializes highlights from cloned DOM (preserves live preview)
- Add 30-second save timeout via Promise.race (matches old OneNoteApi default)
- Consistent × button positioning: region thumbnails moved to top-left to match
- Header bar visible only in article mode, hidden in all other modes

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Set highlight_cursor.cur on iframe body when highlighter enabled
- Restore default cursor when disabled
- Cursor persists correctly after mode switch back to article

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
wikirby and others added 3 commits March 26, 2026 12:41
- Clip button always stays as "Clip" for re-clipping
- Success banner below buttons shows "✓ Clip Successful!" + purple
  "View in OneNote" button (opens page, closes clipper window)
- Banner clears on mode switch or re-clip
- Fix display:none CSS default override for View button

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…, bug fixes

- Section picker: notebook/section-group headings with icons, indented sections,
  keyboard nav skips headings, icons inverted white for dark background contrast
- Success banner: separate "Clip Successful!" + "View in OneNote" button below
  Clip/Cancel. Clip button stays as Clip for re-clipping. Banner clears on
  mode switch, re-clip, or section change.
- Save timeout moved to renderer (service worker setTimeout unreliable — SW
  suspends after ~30s). 30s client-side timeout with error display + retry.
- Fix: section selection no longer resets button state (was enabling Clip
  during capture). Only clears success banner + saveDone flag.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Lint fixes across new files:
- renderer.ts: null→undefined, var→let, else placement, shadowed variable
- regionOverlay.ts: single→double quotes
- webExtensionWorker.ts: var→let, tslint-disable for Chrome API null param
- domUtils.ts: single→double quotes

Service worker keepalive: renderer pings every 25s via port to prevent
MV3 service worker suspension while popup is open.

Inactivity auto-close: renderer closes after 5 minutes without mouse,
keyboard, scroll, or focus activity. Timer resets on any interaction.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant