Skip to content

feat: show a live info pane beside the results - #162

Draft
TechNapoleon wants to merge 19 commits into
baairon:mainfrom
TechNapoleon:pr2-info-pane
Draft

feat: show a live info pane beside the results#162
TechNapoleon wants to merge 19 commits into
baairon:mainfrom
TechNapoleon:pr2-info-pane

Conversation

@TechNapoleon

@TechNapoleon TechNapoleon commented Aug 17, 2026

Copy link
Copy Markdown
Contributor

Stacked on #161 — please review that one first. This branch is based on it, so #161's commits
appear in this diff until it merges. Opened as a draft for that reason; I'll mark it ready as soon
as #161 lands.


What and why

#161 made the metadata available and put it behind Enter. That still means opening a row to find out
whether it's the one you want. This puts the answer beside the list, on whatever the cursor is on.

torlink running with the info pane open beside the results list: poster art in coloured half-blocks, with title, year, rating, runtime, genres, director, cast and plot for the highlighted row

The pane follows the cursor. i toggles it, opens it up to read and scroll. The poster is drawn with half-block characters, which is the best a terminal can do with text.

  • The pane shows title, year, rating, runtime, episode, genres, director, cast and plot for the
    row under the cursor. i toggles it; it auto-hides below 92 columns, where the list needs every
    column it has.
  • Poster art as half-blocks. No terminal graphics protocol survives Ink's renderer — it repaints
    whole frames, so a sixel written into a cell is overwritten or measured as text on the next pass.
    So the art is text: with the upper pixel as foreground and the lower as background, which fits
    two pixel rows per terminal row and comes out square against a cell's 1:2 shape.
  • focuses the pane and /esc returns. Focused, the list falls back to its minimum width,
    the pane spends the difference, and the whole card scrolls under a window.
  • Above 113 columns the focused pane splits — poster left, card right — and guarantees eight card
    rows visible without scrolling, which is the difference between knowing a description exists and
    reading one.

Both new keys are wired into both halves of keymap.ts, and keymap.test.ts asserts it. i and
were unbound before, so nothing is retrained. The previewOpen / setPreviewOpen Store fields
have their makeStore entries in the previews script.

Everything degrades to the text card: a URL off the host allowlist, a body over a megabyte, a WebP
served with a .jpg name, a truncated download, a decoder that gives up, a pane too short — each is
a null and a card that renders exactly as it does without art.

A note on two preview images. preview/browse.svg and preview/downloads.svg change in this PR
and the changes are unrelated to this feature. Both were last committed before the f Filter
footer hint and the "Highlight Selected Items" (#130) row styling landed, so they were already stale
on main; npm run previews regenerates all four from the real components and corrects them.
Running it on a clean checkout of main reproduces the identical diff.

Checklist

  • npm run typecheck is clean
  • npm test passes
  • New logic has a test (vitest; mock node built-ins for platform code)
  • If I added a key, I updated both HELP_GROUPS and footerHints in src/ui/keymap.ts
  • If I added a Store field, I updated makeStore in scripts/render-previews-impl.tsx
  • OS-touching code works on Windows, macOS, and Linux — nothing here touches the OS; the pane is layout arithmetic and text
  • One concern, with a Conventional Commits title

62 test files, 637 tests. The suite was also run under saturated CPU, because a test that only passes
on an idle machine isn't passing: the hook tests now wait on the fact rather than on a fixed sleep,
and the two exhaustive layout sweeps carry an explicit timeout, since at thousands of configurations
each they can exceed vitest's 5 s default on a loaded runner.

Search results arrive as scene and fansub release names, which carry the
title fused to quality, source, codec and group tags. Any metadata lookup
has to recover a plain title first, so this lands the reduction on its own
as a pure, import-free module ahead of the code that will consume it.

The parser is total by construction: it runs once per visible row during a
search, so it returns an empty title rather than throwing, and leaves the
decision about whether a title is worth querying to the caller. Sanitizing
for the terminal stays at the render boundary.

Three rules earn their odd shapes from real sampled names. Dots convert to
spaces on a tie with spaces, not only when they outnumber them, because a
name with equal counts is a dotted scene name whose title contains spaces,
and leaving the dots in fuses title and junk tokens. The bare "Show - 01"
fansub form backfills its season from the text before the marker, since that
form carries an episode only while the season sits just ahead of it.

The third is the trailing release group, which is taken only when the text
ahead of it corroborates a scene release — a junk token, a year, or an
episode marker. A trailing "-GROUP" and an ordinary hyphenated title are the
same shape, so matching on shape alone truncates "Spider-Man" to a title of
"Spider", and likewise "Ant-Man" and "X-Men". Those feed a title search,
where the truncation either misses or matches the wrong film, and a
junk-vocabulary guard does not help because a real title word is not
vocabulary. Requiring corroboration fails in the safe direction: a minimal
name keeps a slightly dirty title rather than a truncated one. Separator
normalisation runs ahead of the group strip to make the check possible at
all, since corroboration reads the name as tokens and a dotted scene name is
a single token until the dots become spaces.
A search row shows a release name and nothing else, which is the least
useful thing a torrent carries. Cinemeta is keyless and IMDb-keyed, so it
buys a title, year, rating, plot and poster with no account to create and no
key to ship in a public binary.

Two rules shape the code. The client is called from a render path, so every
mapper is total and every request fails soft to null or []: a dead provider
must cost a poster, not the TUI. And the IMDb id is remote input that ends up
in a URL path, so it is validated on the way out, not on the way in — the
same guard stripControl() applies to text bound for the terminal.

Totality has to hold in the helpers rather than rely on a caller's
try/catch, because searchUrl and metaUrl are exported and called directly.
encodeURIComponent throws URIError on an unpaired surrogate, and a lone
surrogate is remotely reachable: JSON.parse produces one from a "\ud800"
escape in a tracker payload. Half an astral character carries no meaning, so
it is dropped before encoding — one code path and one return shape, instead
of a swallowed error and a second, undocumented result. The lookbehind is
what catches an unpaired low surrogate as well; a plain \uD800-\uDFFF class
would mangle well-formed emoji, which the tests pin.

Remote fields are capped before they are cleaned rather than after, so a
hostile description does not pay the full cost of a code-point walk before
being cut down, and an oversized cast entry or genre never reaches a word
wrapper. The blank check runs on the capped string, so a field padded with
leading spaces up to the cap answers undefined rather than a placeholder.

The matcher abstains rather than guesses. A provider's search endpoint ranks
by popularity, not relevance, so "The Matrix" comes back with its sequels
attached; the score has to be positively convincing before a poster is
shown, because a wrong poster is worse than no poster and the user cannot
tell it is wrong.

Nothing imports this yet.
Metadata for a search row is looked up per selected row, so the same title is
otherwise re-fetched every time the user scrolls back onto it. A module-level
cache with a 30 minute TTL and an in-flight map collapses that: one film's
four YTS quality rows, and the same film arriving from three trackers, resolve
to a single entry and a single request. Misses are cached too, because
scrolling repeatedly past an unmatched row should stop costing anything after
the first pass.

The cache key carries the kind as well as the id. The same IMDb id can arrive
from a movies feed and from a TV feed that misclassifies it, and the two need
different Cinemeta URLs; Cinemeta answers HTTP 200 with its "unknown id" stub
for a wrong-type lookup rather than 404ing, so keying on the id alone lets one
feed's stub poison the other's entry for a whole TTL.

An in-flight request owns its own controller and counts the callers waiting on
it, rather than handing every joiner the first caller's promise. Two hooks can
sit on one row — the detail view and the info pane — and a shared promise
cancelled by whichever caller left first would tell the other there was no
metadata, with no way for it to notice: its effect deps have not changed. A
caller that cancels gets its own null immediately, and the request is aborted
only once nobody is left.

An aborted lookup deliberately does not write the cache: a cancelled request
is indistinguishable from a genuine miss at that point, and recording it would
pin "no metadata" on the row for the whole TTL because the user scrolled past
before the answer arrived. Negative entries carry their own two minute TTL,
since at this layer a dead network and a title the provider genuinely does not
have are the same empty answer, and the longer TTL would leave a session that
started before DNS settled without metadata long after the network came back.

YTS, EZTV and The Pirate Bay already know the IMDb id, so those rows skip the
search-and-guess round trip entirely — apibay carries it on both category
feeds, which makes it the largest source that can. The field is optional and
every other adapter is untouched.

A row is refused before the network when its source is Games-only, so FitGirl
never queries at all, and when its release name carries no Latin-script title
to search with — Nyaa's Chinese fansub names parse to a long but entirely
unsearchable title, which a length check would wave through at one wasted
request per visible row.
normalizeImdbId and imdbFromNumeric are generic IMDb id validators, not
Cinemeta-specific, but living in cinemeta.ts made the lower sources/ layer
import the metadata client just to sanitise a field of its own payload. Move
both to a neutral src/meta/imdbId.ts and repoint yts, eztv, piratebay and
cinemeta itself at it.

Pure move: no behaviour or signature change, and the tests move with the
functions, assertion for assertion.

eztv and yts gain adapter-level tests for their id extraction while their
imports are being touched, following piratebay's precedent of exporting its
mapping function for exactly that purpose. The validators themselves are
covered in imdbId.test.ts; what these pin is the wiring, without a network
call.
The metadata pipeline built up to here — release parser, Cinemeta client,
cache, hook — has had no visible surface. The detail view opened with Enter
is the natural first home for it: rating, genres, director, cast and plot
render below the existing Magnet row when metadata resolves, and render
nothing extra otherwise, so a miss or a never-queried row looks exactly like
the view did before.

The rows are budgeted against the panel's real height rather than simply
appended. At a realistic terminal size the panel's fixed, overflow-hidden
content box has no slack once metadata sits on top of the existing torrent
facts, and handing Ink more rows than fit does not clip cleanly — Yoga's
flexbox shrink squeezes arbitrary rows to zero height instead, which drops
facts and can hide the download/copy/export hint row entirely, the only
on-screen way to discover those keys.

So the available rows are computed before rendering, and rating, genres,
director and cast are admitted in that priority order, with every row after
the first that does not fit dropped rather than leaving a gap partway through
the block. Plot is exempt from that cutoff and takes whatever is left over,
capped with a forced ellipsis when it does not fully fit — the one row
allowed to show less than it has.
Counting UTF-16 code units undercounts a CJK character — one unit, two
terminal columns — by half. Nyaa is an anime index and Cinemeta returns
Japanese, Korean and Chinese cast and plot text routinely, so the detail
panel's row budget has to measure display width or it overflows on a common
path rather than an edge case: rows dropped from the torrent facts, and the
action hint fused with stray metadata.

So the line-count and truncation math runs on an East Asian Wide/Fullwidth
range table, and raw string slicing gives way to code-point-safe wrapping and
ellipsizing, which also stops a surrogate-pair emoji being split in half.

The table has to carry astral ranges as well as BMP ones, and that is easy to
miss: an astral character costs two UTF-16 code units, which happens to match
its two-column display width, so a length-based count got it right by
accident. Iterating by code point removes the coincidence, and without the
astral entries every emoji would measure one column — the same undercount the
mechanism exists to prevent, moved from CJK text to emoji.

One range needs care rather than breadth. The U+231A-U+2B55 span contains
this app's own box-drawing border characters alongside genuinely wide symbols,
so it is enumerated by known-wide code point rather than swept wholesale; a
span-wide rule measures the panel's own chrome as double-width and corrupts
every frame regardless of content.

The helpers live in their own module because the detail panel is not the only
view that needs them — the info pane beside the results list wants the same
accounting, and a second copy of the width table is a second thing to get
wrong when a new emoji block or CJK range has to be added.
Results.tsx carries the results list, the detail panel and their key handling,
and had grown past 650 lines. The panel's row-budget planner is the part with
the least to do with rendering: a pure function over a metadata object and a
row count, with its own priority order and its own prefix-cut degradation
rule. Both are worth reading — and testing — without a render in the way.

Pure move. No behaviour change, and the tests that cover the budget through
the rendered panel are untouched, so they still pin it end to end.
Both files stood a fixed `tick(ms)` sleep in for "the mocked promise settled
and Ink flushed a frame". That guess holds on an idle machine and stops
holding on a busy one, where these tests fail reliably — reading LOADING
where the next row's title should already be, or counting one lookup where
the row change should have forced a second. A test that only passes on an
idle machine is not passing; CI runners are small and shared.

Every positive fact now waits on itself with `vi.waitFor`, which retries
until the fact is true rather than until a guessed interval expires.

The sleeps that remain are the ones a waitFor cannot express. A purely
negative step — the late resolution changed nothing, the re-sorted row did
not ask again — would satisfy a waitFor on its first attempt and prove
nothing, so it keeps a real sleep that gives the wrong behaviour room to
appear. So do the debounces, which are wall-clock facts by construction; a
sleep can only overshoot them on a loaded machine, which is the harmless
direction. Each is commented with which case it is.

Every converted wait was checked to need more than one attempt, so none of
them resolves on the frame the mount already showed.
The results list can say how big a release is and how well it is seeded, but
never what it actually is — which is the question a user scrolling twelve
near-identical release names is really asking. Answering it meant opening the
detail view on every candidate.

The pane follows the cursor and answers it in place: title, year, rating,
runtime, episode, genres, director and cast, on the row under the cursor. It
auto-hides below 92 columns, where the list needs every column it has, and
`i` toggles it anywhere else. The key is wired into both halves of keymap.ts,
so it appears in the footer and on the `?` sheet.

Fitting is measured before anything renders, not left to Ink: a panel that
overflows is clipped by squeezing rows, which drops and fuses lines elsewhere
in the block rather than cutting the one that overflowed.
The info pane can say what a release is in words, but a poster is the thing
the eye recognises before it reads anything, and Cinemeta already hands us a
URL for one. No terminal graphics protocol survives Ink's renderer — it
repaints whole frames, so a sixel written into a cell is overwritten,
mispositioned or measured as text on the next pass — so the art is drawn as
text: U+2580 with the upper pixel as the foreground and the lower one as the
background, which fits two pixel rows in one terminal row and comes out
square against a cell's own 1:2 shape.

jpeg-js is the one new dependency, pure JS with nothing under it. Both poster
hosts are covered: the gate for this work was decoding a real progressive
JPEG, because m.media-amazon.com serves baseline while images.metahub.space
serves progressive even when asked for ?format=jpeg, and a decoder that only
handled the first would have silently dropped every title that never went
through the catalog. Both renditions are inlined as base64 constants in the
tests rather than committed as fixtures.

The cache key carries the pane's cell budget, not just the URL, and both
resize directions are pinned on the rendered frame. Narrowing at least fails
visibly, since the pane refuses a grid larger than its budget and loses its
art; widening has no such backstop, and an undersized grid fits, draws, and
leaves a poster that quietly no longer matches the pane it is in.

Everything degrades to the text card that exists today. A URL off the host
allowlist, a body over a megabyte, a WebP served with a .jpg name, a
truncated download, a decoder that gives up, a pane too short, a grid left
over from a wider pane mid-resize: each is a null and a card that renders
exactly as it does without art. This is a React render path, so the
alternative to failing soft is an unmounted TUI.
The pane's vertical budget was the binding constraint: the poster came out
8x6 at a 130x26 terminal and only reached its intended 24x18 at height 44 and
above, and no split of a fixed pane fixes that. Rather than permanently trade
text rows for art rows, the pane becomes a place the keyboard can go.

Region gains "preview", filling the slot where → in the results list was a
no-op, so the existing horizontal model simply runs one column further:
sidebar ↔ list ↔ pane. Focused, the list falls back to MIN_LIST_WIDTH and the
pane spends the difference, the art is sized from the width and its own aspect
instead of from the rows the text card left over, and the whole card scrolls
under a window with a dim line saying which way there is more of it. Both new
keys are wired into HELP_GROUPS and footerHints alongside `i`.

Focus can never land on a pane that is not on screen: the results view is the
only place that knows the width tier, the `i` toggle and the section at once,
so it reports that up and one clamped step function answers for every
horizontal key — which also walks focus back out when a resize or a toggle
takes the pane away while it is holding the keyboard. That single function is
also what the rescue effect calls, so the recovery path cannot disagree with
the keys.

The focused pane stops widening once it has nothing left to spend width on:
the art is capped whatever the pane does, and a text measure past the
mid-fifties reads worse rather than better. At a 160-column content width that
hands the list 99 columns instead of leaving it at its 52-column minimum with
truncated names beside blank ones.

Games is excluded because no provider answers for it and every row would read
"No metadata"; `all` stays in, where those rows already say so one at a time.

App.test.tsx now drives the real App — boot, search, arrows, esc, tab, all
three modals, a live stdout resize and a section switch — with everything
below it stubbed so mounting starts no engine and makes no request. The four
ways this wiring can silently come apart (deleting the rescue effect,
hard-coding the → gate shut, routing ← past the list, dropping footerHints'
previewOpen argument) each fail a named test.
A literal ESC byte in a test string renders invisibly on GitHub, so a call
like `u.press` with a raw escape plus "[C" displays as `u.press("[C")` —
indistinguishable from a plain "[C", which Ink would actually deliver as
input "[C" with rightArrow false rather than as an arrow key. A maintainer
reading the diff has no way to tell the arrow-key tests are exercising the
right thing.

Add right/left/up/down to KEY in testHarness.ts, following the existing
enter/esc/ctrlU shape, and use them at the arrow-key call sites. The SGR
assertions in the poster and pane tests get the same treatment, with escaped
unicode sequences instead of raw bytes.
ansi-to-svg.ts's block-glyph branch drew only the foreground-coloured half of
a half-block character (upper for U+2580, and so on) and skipped the
background rect the general text path draws for every other run.

That is invisible for a progress bar's solid-colour fill, and the existing
previews are byte-identical either way — neither the wordmark nor the
progress bar sets a background colour on these glyphs, so the new rect never
fires for them. It matters for anything using the half-block trick, where the
background fills the cell and the foreground covers half of it: without the
rect, art drawn that way loses its bottom pixel on every row and renders as
banded stripes over the page background instead of a picture.

Paint boxFill (st.inverse ? fg : st.bg) under the glyph's own partial rect,
mirroring the existing boxFill handling in the general path one branch down.
Add a fourth preview scenario ("info") to render-previews-impl.tsx showing
the metadata pane beside the results list, with poster art and the facts
card, at a terminal width wide enough to reach the poster tier. previewLayout
needs a content width of 86 or more for art, and the other scenarios' shared
80 columns never gets there, so this one runs at 120 on its own constants
without touching them. README gains one line on the `i`/`→`/`←` keys plus the
new screenshot.

browse.svg and downloads.svg also change in this regeneration, and not
because of this feature. Both were last committed at 661f90f, before the
`f Filter` footer hint and the "Highlight Selected Items" (baairon#130) row styling
landed on main — that is, before 7b29746, the commit this branch forks from.
Running `npm run previews` on a clean checkout of main reproduces the
identical diff in both files, so the drift predates this work; regenerating
simply corrects it.
planPaneLines and the line builders around it move verbatim out of MetaPane
into src/ui/paneCard.ts, the same way the text-width helpers and the detail
panel's row planner moved out earlier: a pure module the component and its
tests read the same numbers from, with the row arithmetic now pinned without
a render.

The previews script was carrying a second, hand-rolled copy of the card and
calling posterBudget directly for it. It now draws the app's card, so the
screenshot cannot document a layout the app no longer has. Output is
byte-identical.
The plot was fetched, capped and cached but never rendered, so the pane held
a blank area under the cast credit — most of one when focused, where the card
is six rows in a window of nineteen.

planPaneLines now plans it last and outside the shared cutoff, the way the
detail panel already does: it is the one field with no natural length, so it
takes whatever the fixed-height facts above it underspent rather than being
refused whole because a two-row credit did not fit. Zero rows is a legitimate
answer on a short pane. Focused, the budget is infinite, so the whole synopsis
is built and the window scrolls it.

No spacer row above it — the pane is meant to read as one quiet block — and
the planner needs no focus flag, because an infinite budget already says
everything focus means here. The call is memoised on the row, the width and
the budget: the pane re-renders on every search tick, and the word wrapper is
linear in the plot.
A fixed 24-column cap bound the art in every configuration, so the poster
measured the same at every terminal width and at both focus states: widening
the pane bought text measure and nothing else. The tier table already caps how
wide a pane gets, so the second cap was only ever a proxy for "the art should
not eat the pane" — and fitCells does the aspect arbitration from the bounds
alone. Width is now the pane's full inner width, and a taller pane grows the
picture first, since unfocused art and plot compete for the same slack.

Focused, a poster that fitCells had to cap by rows comes back narrower than
the pane that asked for it, leaving columns empty beside it while the card
stacked underneath had one row to say anything in. So the pane spends those
columns on the card: the art takes the left column, the text flows down the
right. splitTextCols owns the decision and the component does no arithmetic of
its own; it answers null below MIN_TEXT_COLS, and null is the stacked layout
the pane has always drawn — which is also the only layout unfocused, where a
34-column pane split two ways is neither.

What bounds the text is the text measure, not the pane. MAX_TEXT_COLS wraps
the card at 56 however wide the terminal is, and a focused pane may grow to
artCols + gap + 56, so the picture is carried on top of the measure rather
than out of it. Bounding the whole pane instead would cap the card at whatever
the poster left over — fifteen columns at a 120-column terminal — and force
the stack at exactly the sizes the split exists for. The split engages from a
flat 113 terminal columns at every height, so a taller terminal is never less
likely to split than a short one.

Focusing guarantees MIN_FOCUSED_TEXT_ROWS of card on screen without scrolling
— the identity block and two rows of plot, which is the difference between
knowing a description exists and reading one. posterBudget decides which axis
pays for it: wide enough to seat the card beside the picture and the art gives
up columns while keeping the pane's whole height; otherwise it gives up rows
and the card sits underneath. seatsCardBeside settles that from dimensions
alone, before any byte is fetched, because previewLayout and posterBudget both
need the answer and deciding it from the decoded art would make the pane's
width depend on a decode that depends on the pane's width. previewLayout takes
paneInnerRows for the same reason; callers with no pane yet get the stacked
width, which is the width every caller had before.

Scrolling keeps one window over one row list: row i is art row i beside card
row i, the total is the taller column, and scrollStart slices it exactly as
before. The poster is still text and still scrolls with the pane.

The previews script now imports the poster rendition constants rather than
carrying its own copy, so the screenshot reasons from the same numbers the app
does. Results.test.tsx's usePoster mock calls the real fitCells rather than
returning a grid at the full budget width — the narrowing is what the split is
decided from, and a mock that skipped it would exercise only one of the two
layouts.
Give the preview fixture a plot and screenshot the focused split state, where
the poster and the full synopsis actually render side by side. This fixture's
credits alone saturate the unfocused pane's fixed text budget, so the
unfocused state would show no plot at all and document the feature as absent.

The script derives the split from the same shared layout functions the app
uses — previewLayout, posterBudget, fitCells, planPaneLines — and imports the
poster rendition constants rather than carrying its own copy, so a screenshot
cannot document a layout the app no longer has. It now also asserts the card
fits without scrolling, which is the guarantee the focused pane makes.

The stand-in poster ramps between colours already exported from theme.ts,
since the previews never touch the network and there is no JPEG to decode.
The poster hook's tests stood a fixed `tick(ms)` sleep in for "the mocked
promise settled and Ink flushed a frame". That guess holds on an idle machine
and stops holding on a busy one, where the test asserting the intermediate
loading state fails outright. Every positive fact now waits on itself with
`vi.waitFor`, which retries until the fact is true rather than until a guessed
interval expires, and each converted wait was checked to need more than one
attempt so none of them resolves on the frame the mount already showed.

The sleeps that remain are the ones a waitFor cannot express: a purely
negative step — the unmounted row's late response paid for no decode — would
satisfy a waitFor on its first attempt and prove nothing, so it keeps a real
sleep that gives the wrong behaviour room to appear.

The two exhaustive layout sweeps get an explicit timeout for the same reason.
At thousands of configurations each they run comfortably inside vitest's 5 s
default on an idle machine and exceed it on a loaded one, and a CI runner is
small and shared. Budgeting them deliberately is better than a global raise,
which would also hide a genuinely hung test.
Comment thread src/ui/App.test.tsx
vi.mock("./hooks/useMouseWheel", () => ({ useMouseWheel: (): void => {} }));

vi.mock("../config/config", () => ({
loadConfig: async (): Promise<unknown> => ({ downloadDir: "/tmp/torlink-tests", trackers: [] }),
@TechNapoleon

Copy link
Copy Markdown
Contributor Author

The code-scanning annotation on src/ui/App.test.tsx:24 is a false positive: /tmp/torlink-tests is a string in a mocked loadConfig return value — nothing ever opens or writes it.

Happy to switch it to path.join(os.tmpdir(), …) to match vitest.config.ts if you'd rather the annotation went away.

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.

2 participants