Something went wrong. Try again.
An atproto AppView for tracking a Magic: The Gathering collection github.com/eth0net/manaweb
appview atproto mtg rust typescript
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098:root { color-scheme: light dark; font-family: system-ui, sans-serif; line-height: 1.5;
/* Both marks the header carries, so they stay the same circle. */ --mark: 2rem;
--ink: #1a1a1a; --paper: #fdfdfc; --quiet: #6b6b6b; --edge: #e2e0dc; --warn: #a3341f;}
@media (prefers-color-scheme: dark) { :root { --ink: #ececea; --paper: #16161a; --quiet: #9a9a97; --edge: #2f2f34; --warn: #e88a70; }}
html,body { background: var(--paper); color: var(--ink); margin: 0; /* Safari bounces the viewport even where nothing overflows it, which is the one thing that still reads as a page rather than an app. */ overscroll-behavior: none; overflow: hidden;}
/* An app shell: the page itself doesn't scroll, the list inside it does. `dvh` rather than `vh` so mobile browser chrome doesn't cut it off. */main { box-sizing: border-box; display: flex; flex-direction: column; height: 100dvh; margin: 0 auto; max-width: 42rem; /* The viewport reaches under the notch and the home indicator, so the padding has to put it back. */ padding: calc(1.5rem + env(safe-area-inset-top, 0px)) calc(1rem + env(safe-area-inset-right, 0px)) calc(1rem + env(safe-area-inset-bottom, 0px)) calc(1rem + env(safe-area-inset-left, 0px));}
/* Takes the slack between the header and the bar, so the bar holds the bottom whether the view above it grows or collapses. */.column { display: flex; flex: 1; flex-direction: column; min-height: 0;}
/* Centered rather than on the baseline: the marks at either end are the same circle, and two rows of unequal text put them on different lines. */header { align-items: center; border-bottom: 1px solid var(--edge); display: flex; flex-wrap: wrap; gap: 0 1rem; justify-content: space-between; margin-bottom: 1.5rem; padding-bottom: 0.75rem;}
h1 { font-size: 1.25rem; margin: 0;}
.mark { align-items: center; color: inherit; display: flex; gap: 0.4rem; text-decoration: none;}
/* Decorative beside the name, and takes its own theme: it is an image rather than markup, so nothing here colors it. */.mark img { height: var(--mark); width: var(--mark);}
nav { display: flex; gap: 0.25rem; padding-top: 0.5rem;}
nav a { border-radius: 0.375rem; color: var(--quiet); flex: 1; padding: 0.5rem 0.25rem; text-align: center; text-decoration: none;}
nav a.here { background: var(--edge); color: var(--ink);}
/* The two ways into the catalog, marked like the tab bar but sized to their own words: they switch a view rather than a tab. */.modes { display: flex; gap: 0.25rem; margin: 0 0 0.75rem;}
.modes a { border-radius: 0.375rem; color: var(--quiet); font-size: 0.8125rem; padding: 0.25rem 0.625rem; text-decoration: none;}
.modes a.here { background: var(--edge); color: var(--ink);}
/* The route's own box, so a list inside it still measures against the page. *//* The last thing between the shell and a view that outgrows it: a list with a bounded box of its own keeps one, anything else scrolls here. */.view { display: flex; flex: 1; flex-direction: column; min-height: 0; overflow-y: auto; overscroll-behavior: contain;}
/* The scan tab is the viewfinder. Everything else about it is laid over the picture, because what is behind the words is the card being read. */.scan { background: #000; border-radius: 0.5rem; display: flex; flex: 1; flex-direction: column; min-height: 0; overflow: hidden; position: relative;}
/* Cover rather than contain: a letterboxed viewfinder reads as a photograph of a camera. What it crops is not what is scanned — the frame is taken from the video's own pixels, not from what this shows of them. */.scan video { height: 100%; inset: 0; object-fit: cover; position: absolute; width: 100%;}
.scan-idle { align-items: center; display: flex; flex: 1; flex-direction: column; gap: 0.75rem; justify-content: center; padding: 1rem;}
/* Everything you touch is at the bottom, where a thumb already is: the buttons are pressed once a card and the panel is what a mistake is undone from. */.scan-foot { bottom: 0; display: flex; flex-direction: column; left: 0; position: absolute; right: 0;}
.scan-controls { display: flex; gap: 0.5rem; padding: 0.75rem;}
.scan-controls button { flex: 1;}
/* Over the picture and reaching no further than it must. Its own colors rather than the page's: what is behind it is a camera, which is dark whatever the device is set to. */.scan-said { backdrop-filter: blur(6px); background: rgb(0 0 0 / 55%); color: #f4f4f2; display: flex; flex-direction: column; max-height: 55vh; min-height: 0; padding: 0.75rem;}
/* What the panel says scrolls; what it is pressed by does not. */.scan-panel { min-height: 0; overflow-y: auto;}
/* Beats `.scan-said p`, which would otherwise put the margin underneath. */.scan-said .scan-count { align-items: center; display: flex; gap: 0.5rem; margin: 0.25rem 0 0;}
.scan-count button { font-size: 1.125rem; line-height: 1; min-width: 3rem; padding: 0.375rem 0.75rem;}
.scan-said.solid { backdrop-filter: none; background: var(--paper); color: var(--ink);}
.scan-said p { margin: 0 0 0.5rem;}
.scan-said .quiet { color: inherit; opacity: 0.7;}
.scan-veil { float: right; font-size: 0.75rem; margin-left: 0.5rem; padding: 0.125rem 0.5rem;}
.scan-named { align-items: center; display: flex; gap: 0.5rem;}
.scan-named img { border-radius: 0.125rem; flex: none;}
.tally { font-variant-numeric: tabular-nums; margin: 0 0 0.75rem;}
/* A scanned stack, and under a fold the presses that put a wrong read right. */.scratch { list-style: none; margin: 0; padding: 0;}
.scratch > li { border-top: 1px solid var(--edge); padding: 0.5rem 0;}
.scratch-card { align-items: center; display: flex; gap: 0.5rem;}
.scratch-card img { border-radius: 0.125rem; flex: none;}
.scratch-what { display: flex; flex: 1; flex-direction: column; min-width: 0;}
/* Sits under the finish, not beside a label. */.scratch-grade { font-size: 0.8125rem; margin-top: 0.25rem; max-width: 14rem;}
.scratch-count { align-items: center; display: flex; flex: none; gap: 0.375rem;}
.scratch-count button { font-size: 1.125rem; line-height: 1; min-width: 2.5rem; padding: 0.375rem 0.5rem;}
.scratch summary { cursor: pointer; font-size: 0.875rem; opacity: 0.7;}
.scratch-finishes { display: flex; flex-wrap: wrap; gap: 0.375rem; margin: 0.5rem 0;}
.scratch-prints { display: flex; flex-direction: column; gap: 0.375rem; list-style: none; margin: 0.5rem 0; padding: 0;}
/* A printing to press, when the art match narrowed to several. */.scan-pick { background: none; border: 1px solid transparent; display: block; padding: 0.125rem 0.25rem; text-align: left; width: 100%;}
.scan-pick.chosen { border-color: currentcolor;}
.report textarea { font: inherit; margin: 0.25rem 0 0.5rem; width: 100%;}
.report-does { display: flex; gap: 0.5rem;}
.scratch-find input { margin: 0.5rem 0; width: 100%;}
.scratch-report { display: flex; flex-wrap: wrap; gap: 0.5rem;}
.scratch-prints button { text-align: left; width: 100%;}
.chosen { border-color: currentcolor; font-weight: 600;}
.breakdown { list-style: none; margin: 0; padding: 0;}
.breakdown li { border-top: 1px solid var(--edge); display: flex; gap: 0.5rem; padding: 0.5rem 0;}
.breakdown li > :first-child { flex: 1;}
.breakdown li > :last-child { font-variant-numeric: tabular-nums;}
.query { display: flex; gap: 0.5rem;}
.query input { flex: 1;}
/* Sits under the search row, so it needs the gap that row's children get. */.filter { margin-top: 0.5rem;}
select { background: var(--paper); border: 1px solid var(--edge); border-radius: 0.375rem; color: inherit; font: inherit; padding: 0.5rem;}
input[type="search"],input[type="text"],textarea { background: var(--paper); border: 1px solid var(--edge); border-radius: 0.375rem; box-sizing: border-box; color: inherit; font: inherit; padding: 0.5rem 0.75rem; resize: vertical; width: 100%;}
/* A set can hold 5,584 printings, so the list scrolls inside a bounded box rather than running the page off the bottom. `content-visibility` leaves the rows past the fold unlaid-out until they approach it, and the intrinsic size keeps the scrollbar honest meanwhile. */.results,.sets { flex: 1; list-style: none; margin: 1rem 0; min-height: 0; overflow-y: auto; overscroll-behavior: contain; padding: 0;}
/* Direct children only: a printing nested inside a result is neither its own row to skip nor anything like 6.75rem tall. */.results > li,.sets > li { content-visibility: auto;}
.results > li { border-top: 1px solid var(--edge); contain-intrinsic-size: auto 7.75rem; padding: 0.75rem 0;}
/* Art beside text. Stretch is the flex default, and it was pulling the thumbnail to whatever height the text ran to. */.card { align-items: flex-start; display: flex; gap: 0.875rem;}
.card > .art > img { aspect-ratio: 146 / 204; border-radius: 0.25rem; /* Fixed rather than filling the row: a wrapped title on one card would otherwise hand that card a larger picture than its neighbors. */ height: 6.25rem; width: auto;}
/* The two smallest things under a card share a line, which is what keeps a row the same height signed in as out. */.meta { align-items: baseline; color: var(--quiet); display: flex; flex-wrap: wrap; font-size: 0.8125rem; gap: 0.5rem; margin-top: 0.375rem;}
.step { flex: none; font-size: 0.8125rem; padding: 0.0625rem 0.375rem;}
.owned { font-variant-numeric: tabular-nums;}
.owned:empty { display: none;}
/* A minus, a count and a plus read as one control, so they sit closer to each other than to whatever follows. */.adjust { align-items: baseline; display: inline-flex; gap: 0.125rem;}
.adjust .owned { padding: 0 0.25rem;}
.results h2 { align-items: baseline; display: flex; flex-wrap: wrap; font-size: 1rem; gap: 0.375rem; margin: 0;}
.results p { color: var(--quiet); margin: 0.125rem 0 0;}
.results .print,.results .note { font-size: 0.8125rem;}
/* Power and toughness, or a planeswalker's loyalty. */.stats { font-variant-numeric: tabular-nums; margin-left: 0.375rem;}
/* A button that reads as text, since it sits inside a sentence. */.link { background: none; border: 0; color: inherit; font: inherit; padding: 0; text-decoration: underline dotted;}
/* The way into the list, pushed to the far end of the row the counts are pressed on. */.scan-count .link { font-size: 0.875rem; margin-left: auto;}
.sets > li { border-top: 1px solid var(--edge); contain-intrinsic-size: auto 2rem; display: flex; flex-wrap: wrap; gap: 0 0.5rem; justify-content: space-between; padding: 0.375rem 0;}
.sets span { color: var(--quiet); font-size: 0.8125rem; font-variant-numeric: tabular-nums;}
.tag { border: 1px solid var(--edge); border-radius: 0.25rem; color: var(--quiet); font-size: 0.6875rem; font-weight: 400; padding: 0 0.25rem; text-transform: lowercase;}
/* Written by hand rather than chosen, so it keeps the case it was given. */.tag.free { text-transform: none;}
/* One circle per symbol, in the color it stands for. Sized and spaced in `em` so a pip tracks whatever text it sits in, and nudged below the baseline so it centers on the letters rather than standing on them. */.mana { display: inline-flex; gap: 0.125em; height: 0.85em; margin-left: 0.375em; vertical-align: -0.12em;}
.mana abbr { align-items: center; aspect-ratio: 1; border-radius: 50%; color: #1a1a1a; display: inline-flex; /* The fallback letter only, so it doesn't decide the circle's size. */ font-size: 0.6rem; font-weight: 600; height: 100%; justify-content: center; overflow: hidden; position: relative; text-decoration: none;}
/* Scryfall's own symbol over the colored circle, so the circle and its letter are what shows when the image can't be fetched. */.mana img { height: 100%; inset: 0; object-fit: contain; position: absolute; width: 100%;}
/* One control rather than six buttons, bordered like the tools beside it. */.colors { align-items: center; border: 1px solid var(--edge); border-radius: 0.375rem; display: inline-flex; gap: 0.25rem; padding: 0.375rem 0.5rem;}
/* Dimmed until it is asked for, which is what makes the row read as switches rather than as a cost. */.pip { background: none; border: 0; border-radius: 50%; opacity: 0.35; padding: 0.125rem;}
.pip[aria-pressed="true"] { opacity: 1;}
/* Refused rather than unasked: lit, so it reads as a choice, and struck so it doesn't read as wanted. */.pip.refused { opacity: 1; position: relative;}
.pip.refused::after { background: var(--warn); content: ""; height: 2px; left: 10%; position: absolute; top: 50%; transform: rotate(-45deg); width: 80%;}
/* A number reads faster under its name than inside every option of a menu. */.span { align-items: center; color: var(--quiet); display: inline-flex; gap: 0.375rem;}
/* A key and the way it runs, which are one choice in two presses. */.sort { display: inline-flex;}
/* Every control in the panel sits on its own line under its heading. */.panel select { max-width: 100%;}
.sort select { border-bottom-right-radius: 0; border-top-right-radius: 0;}
.sort .way { border-bottom-left-radius: 0; border-left: 0; border-top-left-radius: 0;}
.pip .mana { height: 1.25rem; margin: 0; vertical-align: 0;}
/* A circle whatever is behind it, so the control it anchors keeps its size between an account with a picture and one without. */.avatar { align-items: center; aspect-ratio: 1; background: var(--edge); border-radius: 50%; color: var(--quiet); display: inline-flex; flex: none; font-size: 0.875rem; height: var(--mark); justify-content: center; object-fit: cover; overflow: hidden;}
/* The trigger is a row rather than a word: the picture holds the target when a handle is too long to sit beside it. */.summary:has(.avatar) { align-items: center; display: inline-flex; gap: 0.4rem;}
.handle { overflow: hidden; text-overflow: ellipsis; white-space: nowrap;}
/* A file input renders its own control and cannot be styled, so the label is the button and the input behind it is never drawn. */.file { border: 1px solid var(--edge); border-radius: 0.375rem; cursor: pointer; padding: 0.375rem 0.75rem;}
.file input { display: none;}
/* The header's own line: a plain button, so opening the panel can't reflow it. */.summary { border: 0; color: var(--quiet); font-size: 0.8125rem; font-variant-numeric: tabular-nums; padding: 0;}
dialog { background: var(--paper); border: 1px solid var(--edge); border-radius: 0.5rem; color: var(--ink); max-width: min(28rem, calc(100vw - 2rem)); padding: 0;}
dialog::backdrop { background: rgb(0 0 0 / 40%);}
.panel { font-size: 0.8125rem; font-variant-numeric: tabular-nums; padding: 1rem 1.25rem 1.25rem;}
.panel h3 { font-size: 0.875rem; letter-spacing: 0.02em; margin: 1.25rem 0 0.5rem; text-transform: uppercase;}
.panel h2 { font-size: 1rem; margin: 0 0 0.75rem;}
.panel dl { gap: 0.25rem 1rem; margin: 0;}
.panel dt { color: var(--quiet);}
.panel dd { overflow-wrap: anywhere;}
.actions { display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0.75rem 0 0.25rem;}
button { background: var(--paper); border: 1px solid var(--edge); border-radius: 0.375rem; color: inherit; cursor: pointer; font: inherit; padding: 0.25rem 0.625rem;}
button:disabled { color: var(--quiet); cursor: default;}
.update { align-items: center; border: 1px solid var(--edge); border-radius: 0.375rem; display: flex; flex-wrap: wrap; gap: 0.5rem; margin: 0 0 1rem; padding: 0.5rem 0.75rem;}
.warn { color: var(--warn);}
.quiet { color: var(--quiet);}
/* Everything aimed at the list under it, wrapping as more of them arrive: one row on a desktop, the search over its modifiers on a phone. */.tools { align-items: center; display: flex; flex-wrap: wrap; gap: 0.5rem;}
/* The one that grows, down to a basis narrow enough to keep a modifier beside it before the row breaks. */.tools input[type="search"] { flex: 1 1 12rem;}
/* Bordered like the search and the select beside it, because it is the same kind of thing: quiet text alone read as a label rather than a control. */.tool { align-items: center; display: inline-flex; gap: 0.375rem; padding: 0.5rem 0.75rem;}
.places { list-style: none; margin: 0 0 1rem; padding: 0;}
.places li { align-items: baseline; border-top: 1px solid var(--edge); display: flex; gap: 0.5rem; padding: 0.375rem 0;}
.places span { color: var(--quiet); flex: 1;}
/* The one adds land in, marked where a list has no selected state of its own. *//* Picked, in a list long enough that a press has to read from the top of it. */.sets [aria-pressed="true"] { font-weight: 600; text-decoration: none;}
/* 989 rows in a dialog, so the list scrolls under the filter rather than carrying it off the top. */.panel .sets { max-height: 50vh;}
.places [aria-current="true"] { font-weight: 600; text-decoration: none;}
.places .link { text-align: left;}
/* A label above what it names, since a select is wide and a row of two would wrap to one anyway. */.field { display: flex; flex-direction: column; gap: 0.25rem; margin: 0 0 0.75rem;}
.field label { color: var(--quiet); font-size: 0.8125rem;}
/* A box reads as one line with its name, unlike a select wide enough to want the name above it. */.check { align-items: center; display: flex; gap: 0.5rem; margin: 0 0 0.75rem;}
.check label { color: var(--quiet); font-size: 0.8125rem;}
/* A printing is chosen by looking at it, so the list leading with art needs more width than a panel of facts does. */dialog.wide { max-width: min(40rem, calc(100vw - 2rem));}
/* Art at a size worth looking at, so the modal is a grid of it rather than a list with a thumbnail down the side. */.prints { display: grid; gap: 0.875rem; grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); list-style: none; margin: 0 0 0.5rem; max-height: 60vh; overflow-y: auto; overscroll-behavior: contain; padding: 0;}
.prints li { display: flex; flex-direction: column; gap: 0.25rem;}
/* Tiles stretch to the tallest in their row, so the controls hang off the bottom rather than following however far the description wrapped. */.prints .row { margin-top: auto; padding-top: 0.25rem;}
/* The art opens the card, in the row as much as in the gallery. */.art { background: none; border: 0; cursor: zoom-in; flex: none; padding: 0;}
.prints img,.prints .noart { aspect-ratio: 146 / 204; border-radius: 0.375rem; display: block; width: 100%;}
.prints .noart { background: var(--edge);}
.prints .what { color: var(--quiet);}
/* Buttons against the right edge of every tile, so the presence of a count doesn't leave one tile's controls out of line with the next. */.row { display: flex; flex-wrap: wrap; gap: 0.375rem; justify-content: flex-end; margin: 0;}
.row .owned { margin-right: auto;}
/* One printing at reading size: the image carries the rules text. */.detail { align-items: center; display: flex; flex-direction: column; gap: 0.75rem;}
.detail img { border-radius: 0.75rem; max-height: 60vh; max-width: 100%; width: auto;}
.detail p { margin: 0; text-align: center;}
.detail .back { align-self: flex-start;}
.detail .row { align-self: stretch;}
/* Past a laptop's width no thumb reaches the bottom edge, so the bar stands up as a rail beside a column that keeps its measure. */@media (min-width: 64rem) { main { column-gap: 2.5rem; display: grid; grid-template-columns: 11rem minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); max-width: 64rem; }
header { grid-column: 1 / -1; grid-row: 1; }
nav { flex-direction: column; grid-column: 1; grid-row: 2 / -1; }
nav a { flex: none; padding: 0.5rem 0.75rem; text-align: left; }
.column { grid-column: 2; grid-row: 2 / -1; }}
/* What a file gave up on, which is a short list or the import is wrong. */.skipped { color: var(--quiet); font-size: 0.8125rem; margin: 0.75rem 0; padding-left: 1.25rem;}