: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; }