8f286506b2
EXPORT only ever wrote a file, and posting it meant leaving the studio, finding the file and uploading it again. The dialog now offers FACEBOOK, INSTAGRAM, REDDIT and X beside the sizes, and the render goes to the network instead of the download folder. What the browser can do decides how, since a page may not touch another origin's composer: a phone's share sheet opens the target app with the picture already attached, a desktop gets the picture on the clipboard and the site in a new tab for one paste, and an origin with no clipboard API downloads the file beside the open site. The line under the row says so before the click.
2128 lines
88 KiB
CSS
2128 lines
88 KiB
CSS
/* Workspace layout: header strip on top, then rail | chips | stage.
|
||
Under 860px the rail becomes a bottom bar and the chips a horizontal strip,
|
||
which is the same shape the phone app uses. */
|
||
|
||
.shell { display: flex; flex-direction: column; height: 100%; }
|
||
|
||
.header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 12px;
|
||
border-bottom: 1px solid var(--border-soft);
|
||
background: var(--bg-elev);
|
||
flex-wrap: wrap;
|
||
}
|
||
.brand { display: inline-flex; align-items: center; gap: 6px; font-weight: 700; letter-spacing: 0.04em; color: var(--text); text-decoration: none; }
|
||
.brand img { border-radius: 8px; }
|
||
.brand em { font-style: normal; font-weight: 400; color: var(--accent); }
|
||
/* The page name, top-left beside the mark — the same pair /admin and /profile
|
||
carry, so every screen says where the visitor is. */
|
||
.page-name { font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--text-dim); }
|
||
.header .spacer { flex: 1 1 auto; }
|
||
/* The preset the studio has open. It wears the theme's own accent rather than
|
||
the dim text colour: it is the one label up there that changes with the
|
||
look, and the same colour the open tab and the brand already carry. */
|
||
.recipe-name {
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
color: var(--accent);
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: 999px;
|
||
padding: 3px 10px;
|
||
max-width: 320px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.menu-wrap { position: relative; }
|
||
|
||
.workspace { display: flex; flex: 1 1 auto; min-height: 0; }
|
||
|
||
/* --- tool rail ---------------------------------------------------------- */
|
||
.rail {
|
||
width: var(--rail-w);
|
||
flex: 0 0 auto;
|
||
border-right: 1px solid var(--border-soft);
|
||
background: var(--bg-sunken);
|
||
display: flex;
|
||
flex-direction: column;
|
||
padding: 6px;
|
||
gap: 4px;
|
||
overflow-y: auto;
|
||
}
|
||
.rail button {
|
||
border: 1px solid transparent;
|
||
background: transparent;
|
||
border-radius: var(--radius-sm);
|
||
padding: 9px 4px;
|
||
cursor: pointer;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: 4px;
|
||
font-size: 10px;
|
||
letter-spacing: 0.06em;
|
||
color: var(--text-dim);
|
||
}
|
||
.rail button .glyph { font-size: 17px; line-height: 1; }
|
||
.rail button[aria-pressed="true"] { background: var(--accent-soft); color: var(--accent); border-color: var(--accent-line); }
|
||
.rail button:hover { color: var(--text); }
|
||
/* The build behind the page. It sits at the foot of the column, quiet enough to
|
||
read past, and wraps rather than widening the rail when a name is long. */
|
||
.rail-version {
|
||
margin-top: auto;
|
||
padding: 8px 2px 2px;
|
||
font-family: var(--mono);
|
||
font-size: 9px;
|
||
letter-spacing: 0.02em;
|
||
color: var(--text-dim);
|
||
text-align: center;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
/* --- panel columns ------------------------------------------------------- */
|
||
/* The panel is a cascade: the rail holds the tabs, then one column per open
|
||
level — the tab's chips, the open chip's sub-chips, the open ruler. A child
|
||
column never hides the column it came from, so the path reads left to right. */
|
||
.chips {
|
||
flex: 0 0 auto;
|
||
max-width: 62vw;
|
||
display: flex;
|
||
flex-direction: row;
|
||
align-items: stretch;
|
||
border-right: 1px solid var(--border-soft);
|
||
background: var(--bg);
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
.col {
|
||
width: var(--col-w);
|
||
flex: 0 0 auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
padding: 10px;
|
||
overflow-y: auto;
|
||
border-right: 1px solid var(--border-soft);
|
||
}
|
||
.col:last-child { border-right: none; }
|
||
.col-slider { width: var(--slider-w); }
|
||
/* CREATE RECIPES draws a two-up form, so its column is the wide one. */
|
||
.col-create { width: 300px; }
|
||
.col-sub { background: var(--bg-elev); }
|
||
/* RESET is the way out of an edit, not one more chip: a rule and some air keep
|
||
it apart from the tab's own buttons at the foot of the column. */
|
||
.reset-row { margin-top: 6px; padding-top: 12px; border-top: 1px solid var(--border); }
|
||
/* Two things that belong to the phone alone: the path back up under the strip,
|
||
and LIGHT's panels as a chip row. A desktop draws neither — every column is
|
||
in sight, and LIGHT's panels are the column itself. */
|
||
.crumb, .dev-chips { display: none; }
|
||
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
||
/* The rule a strip is read across (the mixer's PICK, its bands, the three
|
||
knobs): a line of its own, so it breaks the row it is written into whichever
|
||
direction that row runs — a full width wraps it even in a phone's row-shaped
|
||
column, and `height: 1px` holds it to a line in the desktop column, where the
|
||
row's main axis is the other way round. */
|
||
.chip-rule {
|
||
flex: 0 0 auto;
|
||
width: 100%;
|
||
height: 1px;
|
||
margin: 3px 0;
|
||
background: var(--border-soft);
|
||
}
|
||
/* In a column the chips stack: one chip per row, full width. */
|
||
.col .chip-row { flex-direction: column; align-items: stretch; }
|
||
.col .chip { justify-content: space-between; text-align: left; }
|
||
/* A row of one KIND of value read against each other, not a strip to scan: WB's
|
||
presets, laid out as a table of buttons instead of a wrapping row. */
|
||
.chip-row.grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 6px;
|
||
}
|
||
.chip-row.grid-3 {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||
gap: 4px;
|
||
width: 100%;
|
||
}
|
||
.chip-row.grid-3 .chip {
|
||
justify-content: center;
|
||
text-align: center;
|
||
padding: 4px 2px;
|
||
font-size: 10px;
|
||
font-weight: 400;
|
||
white-space: nowrap;
|
||
}
|
||
.chip-label {
|
||
font-weight: 400;
|
||
letter-spacing: 0.02em;
|
||
}
|
||
.dev-box-title {
|
||
font-weight: 700;
|
||
color: var(--accent, #f59e0b);
|
||
letter-spacing: 0.05em;
|
||
font-size: 11px;
|
||
}
|
||
/* LIGHT tab main panel chips: WB, TONE, PRESENCE, DETAIL & EFFECTS */
|
||
.dev-panel-head,
|
||
.dev-panel-label,
|
||
.chip[data-key="dev-wb"],
|
||
.chip[data-key="dev-tone"],
|
||
.chip[data-key="dev-presence"],
|
||
.chip[data-key="dev-effects"],
|
||
.chip[data-key^="dev-"] {
|
||
font-weight: 700;
|
||
color: var(--accent, #f59e0b);
|
||
}
|
||
.chip[data-key^="dev-"] .chip-label {
|
||
font-weight: 700;
|
||
}
|
||
/* Sub-chips that MUST NOT be bold: TONE CURVE, DYNAMIC RANGE sub-chips, COLOR CHROME sub-chips */
|
||
.chip[data-key="curve"],
|
||
.chip[data-key="curve"] .chip-label,
|
||
.dev-box[data-key="dev-box-dr"] .chip,
|
||
.dev-box[data-key="dev-box-dr"] .chip .chip-label,
|
||
.chip[data-key^="dr-"],
|
||
.chip[data-key^="dr-"] .chip-label,
|
||
.dev-box[data-key="dev-box-color-chrome"] .chip,
|
||
.dev-box[data-key="dev-box-color-chrome"] .chip .chip-label {
|
||
font-weight: 400 !important;
|
||
}
|
||
.chip-row.grid .chip {
|
||
justify-content: center; text-align: center;
|
||
/* Two of these share the column's width, so each is its own small card: the
|
||
standard's name on one line — never wrapped, the names are the point — and
|
||
the kelvin under it, both a size down from a chip's label. */
|
||
flex-direction: column; gap: 1px;
|
||
padding: 4px 6px;
|
||
font-size: 11px; line-height: 1.25;
|
||
white-space: nowrap;
|
||
}
|
||
/* UNDO/REDO are one glyph each, so the pair reads as a single control. */
|
||
.header .btn.icon { padding: 4px 9px; font-size: 15px; line-height: 1.2; }
|
||
/* WATERMARK's own column: its chips carry the longest labels in the app
|
||
("GPS WATERMARK OFF", "CUSTOM WATERMARK OFF" + PRO), which wrap in the
|
||
default column width. */
|
||
.wm-extra { border-right-color: var(--accent-line); width: 200px; }
|
||
/* The watermark panel is two collapses, one per mark: each header chip stands
|
||
on its own controls (colour, face, switches, fields), and a rule keeps the
|
||
two apart while both marks are on. */
|
||
.wm-sec { display: flex; flex-direction: column; gap: 6px; }
|
||
.wm-sec + .wm-sec { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--border); }
|
||
.chip {
|
||
border: 1px solid var(--border);
|
||
background: var(--bg-elev);
|
||
border-radius: 999px;
|
||
padding: 5px 11px;
|
||
font-size: 12px;
|
||
cursor: pointer;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.col-sub .chip { background: var(--bg); }
|
||
/* The consent tick in MY PHOTOS' history column: a plain checkbox, not a chip —
|
||
it is a permission, and it reads as one. */
|
||
.consent {
|
||
display: flex; align-items: flex-start; gap: 7px;
|
||
margin-top: 10px; padding-top: 10px;
|
||
border-top: 1px solid var(--border);
|
||
font-size: 12px; line-height: 1.4; color: var(--text-dim);
|
||
cursor: pointer;
|
||
}
|
||
.consent input { flex: 0 0 auto; margin: 1px 0 0; accent-color: var(--accent); }
|
||
/* SAVE RECENT's own files: the name fills the row, the ✕ that deletes it sits
|
||
beside it — the account's recipe list is the one place a file can be thrown
|
||
away from the studio. */
|
||
.saved-list { display: flex; flex-direction: column; gap: 6px; }
|
||
.saved-row { display: flex; gap: 6px; align-items: stretch; }
|
||
.saved-row .chip:first-child { flex: 1; justify-content: space-between; text-align: left; }
|
||
.chip[aria-pressed="true"], .chip.on { border-color: var(--accent); color: var(--accent); background: var(--accent-soft); }
|
||
/* A chip painted with the value it picks (a WB preset's cast, the same swatch
|
||
the ruler paints). The fill and the ink are inline — they ARE the value, not
|
||
the theme — so both are stated by the caller; the border is the chip's own
|
||
ink, which is visible against the fill it was chosen for, and the accent once
|
||
the preset is on, since the fill can no longer say so. */
|
||
.chip.tinted {
|
||
border: 2px solid currentColor;
|
||
border-radius: 8px;
|
||
font-weight: 600;
|
||
}
|
||
.chip.tinted.on { border-color: var(--accent); }
|
||
/* The kelvin rides on a swatch, whose ink was picked for that fill to clear
|
||
4.5:1 — so a value slot there wears that ink and not the panel's dim grey,
|
||
which is what a mid grey button cannot carry. It is set a size down from the
|
||
name instead, which is separation that costs no contrast. */
|
||
.chip.tinted .val { color: inherit; }
|
||
/* Edited value, strip closed: the text glows amber, the border does not. */
|
||
.chip.amber, .chip.amber .val { color: var(--accent); }
|
||
.chip .val { color: var(--text-dim); font-family: var(--mono); font-size: 11px; }
|
||
.chip-row.grid .chip .val { font-size: 10px; }
|
||
.chip .pro, .btn .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
|
||
/* A button is not a flex row the way a chip is, so the marker carries its own
|
||
gap off the label it qualifies. */
|
||
.btn .pro { margin-left: 6px; }
|
||
.chip:disabled { opacity: 0.4; cursor: not-allowed; }
|
||
/* The font group as a dropdown: one row that shows the current pairing instead
|
||
of three chips that all have to fit. */
|
||
.font-select {
|
||
display: block;
|
||
width: 100%;
|
||
margin-top: 8px;
|
||
padding-right: 26px;
|
||
appearance: none;
|
||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' fill='none' stroke='%239aa2ad' stroke-width='1.5'/%3E%3C/svg%3E");
|
||
background-repeat: no-repeat;
|
||
background-position: right 10px center;
|
||
}
|
||
.font-select option { background: var(--bg-elev); color: var(--text); }
|
||
|
||
/* The ruler column: "<" back button, label + live value, slider, colour swatch
|
||
for the knobs that drive a hue (WB's TEMPERATURE / TINT). */
|
||
.slider-row { border: 1px solid var(--border); border-radius: var(--radius-sm); padding: 8px 10px; display: flex; align-items: center; gap: 10px; }
|
||
.col .slider-row { flex-direction: column; align-items: stretch; gap: 6px; }
|
||
.slider-row .ruler { flex: 1 1 auto; min-width: 0; }
|
||
/* The same one-line ruler as the sidebar's rows: the knob's name, its track,
|
||
then the number it is on — and a colour swatch under them where the knob
|
||
drives a hue (WB's TEMPERATURE / TINT). */
|
||
.slider-row .head { display: flex; align-items: center; gap: 8px; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); }
|
||
.slider-row .head span { flex: 0 0 auto; }
|
||
.slider-row .head input[type="range"] { flex: 1 1 40px; min-width: 0; }
|
||
.slider-row .head b { flex: 0 0 auto; color: var(--text); font-family: var(--mono); letter-spacing: 0; }
|
||
.slider-row .head b.amber { color: var(--accent); }
|
||
.slider-row .swatch {
|
||
height: 26px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.slider-row .swatch span {
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.06em;
|
||
color: #fff;
|
||
mix-blend-mode: difference;
|
||
max-width: 100%;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.slider-row .back {
|
||
flex: 0 0 auto;
|
||
align-self: flex-start;
|
||
border: 1px solid var(--border);
|
||
background: var(--bg-elev);
|
||
color: var(--text-dim);
|
||
border-radius: 999px;
|
||
padding: 4px 10px;
|
||
font-family: var(--mono);
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
/* A slider is a line and a node, and nothing else. The line is the theme's own
|
||
border ink at a hairline; the node is hollow — a ring drawn in the accent, no
|
||
fill of its own — so the control wears the theme's colour instead of the
|
||
browser's. `accent-color` goes with the old look: it painted a track and a
|
||
solid thumb of its own, and there is nothing of either left for it to paint.
|
||
The box is left taller than the line on purpose: the box is the drag target
|
||
(a finger is wider than a hairline), the line is what is read. */
|
||
input[type="range"] {
|
||
width: 100%;
|
||
height: 16px;
|
||
margin: 0;
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
background: transparent;
|
||
cursor: pointer;
|
||
}
|
||
/* One rule per engine: a grouped selector naming a pseudo-element the engine
|
||
does not know is dropped whole, and the track went with it (measured). */
|
||
input[type="range"]::-webkit-slider-runnable-track {
|
||
height: 2px;
|
||
border-radius: 999px;
|
||
background: var(--border);
|
||
}
|
||
input[type="range"]::-moz-range-track {
|
||
height: 2px;
|
||
border-radius: 999px;
|
||
background: var(--border);
|
||
}
|
||
input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 10px;
|
||
height: 10px;
|
||
margin-top: -4px; /* (10px node - 2px line) / 2 */
|
||
border: 1.5px solid var(--accent);
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
}
|
||
input[type="range"]::-moz-range-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
box-sizing: border-box;
|
||
width: 10px;
|
||
height: 10px;
|
||
border: 1.5px solid var(--accent);
|
||
border-radius: 50%;
|
||
background: transparent;
|
||
}
|
||
/* The knob the pointer is on takes the theme's own hover ink, so the node says
|
||
it is the one being dragged. */
|
||
input[type="range"]:hover::-webkit-slider-thumb,
|
||
input[type="range"]:focus-visible::-webkit-slider-thumb { border-color: var(--accent-hover); }
|
||
input[type="range"]:hover::-moz-range-thumb,
|
||
input[type="range"]:focus-visible::-moz-range-thumb { border-color: var(--accent-hover); }
|
||
.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
|
||
.hint.ok { color: var(--accent); }
|
||
/* A hint that is also a control — the backup row's folder is the reader's to
|
||
change, and the line that names it is where it is changed. It keeps the hint's
|
||
own look, so the row still reads as a caption and not as a third button. */
|
||
button.hint {
|
||
padding: 0;
|
||
border: 0;
|
||
background: none;
|
||
font-family: inherit;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
button.hint:hover:not(:disabled) { color: var(--text); }
|
||
button.hint:disabled { cursor: default; }
|
||
|
||
/* --- PRO gate bar ------------------------------------------------------- */
|
||
/* One line between the header and the workspace, shown only while a signed-in
|
||
account has not proven its address — it is served as a guest until it does. */
|
||
.verify-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
padding: 6px 12px;
|
||
background: var(--accent-soft);
|
||
border-bottom: 1px solid var(--accent-line);
|
||
font-size: 12px;
|
||
}
|
||
.verify-bar .btn { padding: 3px 10px; font-size: 11px; }
|
||
/* The trial notice: the verify bar's shape in the plain surface rather than the
|
||
accent — it is news, and it offers nothing to click. */
|
||
.notice-bar {
|
||
padding: 6px 12px;
|
||
text-align: center;
|
||
font-size: 12px;
|
||
background: var(--bg-elev);
|
||
border-bottom: 1px solid var(--border);
|
||
color: var(--text);
|
||
}
|
||
|
||
/* --- CREATE RECIPES form (the phone's RecipeCreateModal, unrolled) ------- */
|
||
.create-form {
|
||
border-top: 1px solid var(--border-soft);
|
||
padding-top: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
.create-label {
|
||
font-size: 10px;
|
||
letter-spacing: 0.12em;
|
||
color: var(--text-dim);
|
||
font-weight: 600;
|
||
}
|
||
.create-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 6px; }
|
||
/* Collapsible groups: native <details>, so only the caret is ours. */
|
||
.create-sec { display: flex; flex-direction: column; gap: 6px; }
|
||
.create-sec > summary { display: flex; align-items: center; gap: 6px; cursor: pointer; list-style: none; user-select: none; }
|
||
.create-sec > summary::-webkit-details-marker { display: none; }
|
||
.create-sec > summary::before { content: '▸'; font-size: 9px; transition: transform 0.15s ease; }
|
||
.create-sec[open] > summary::before { transform: rotate(90deg); }
|
||
.create-num { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
|
||
.create-num span { font-size: 10px; letter-spacing: 0.12em; color: var(--text-dim); font-weight: 600; }
|
||
.create-num.wide { grid-column: 1 / -1; }
|
||
.create-shift { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
|
||
.create-shift .field { width: 64px; }
|
||
.create-actions { display: flex; gap: 6px; margin-top: 4px; }
|
||
.create-actions .btn { flex: 1 1 auto; }
|
||
|
||
/* --- stage -------------------------------------------------------------- */
|
||
.stage {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
background: var(--bg-sunken);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
padding: 16px;
|
||
position: relative;
|
||
}
|
||
.dropzone {
|
||
border: 2px dashed var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 40px 28px;
|
||
text-align: center;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
background: var(--bg);
|
||
max-width: 520px;
|
||
}
|
||
.dropzone.over { border-color: var(--accent); color: var(--text); background: var(--accent-soft); }
|
||
.dropzone strong { display: block; color: var(--text); margin-bottom: 6px; font-size: 16px; }
|
||
/* The camera sits beside the picker's own hint, so it needs a line of its own
|
||
rather than riding on the sentence above it. The click on this button must
|
||
not also open the file dialog the dropzone itself listens for. */
|
||
.dropzone .btn { margin-top: 14px; }
|
||
.canvas-wrap { position: relative; display: flex; align-items: center; justify-content: center; min-height: 0; flex: 1 1 auto; width: 100%; overflow: hidden; }
|
||
.canvas-wrap img { max-width: 100%; max-height: 100%; object-fit: contain; border-radius: var(--radius-sm); box-shadow: var(--shadow); }
|
||
/* Crop editor: the layer is the photo's own box, the rect is the framing and
|
||
its shadow is what shades the part being cut away. */
|
||
/* Compare: the original left of the split, the grade right of it. The layer is
|
||
the photo's own box and carries the stage's transform, so the bar stays put
|
||
on the photo whatever the zoom or pan. */
|
||
.compare-layer {
|
||
position: absolute;
|
||
overflow: hidden;
|
||
border-radius: var(--radius-sm);
|
||
cursor: ew-resize;
|
||
touch-action: none;
|
||
}
|
||
.compare-orig { position: absolute; inset: 0; width: 100%; height: 100%; box-shadow: none; }
|
||
.compare-bar {
|
||
position: absolute;
|
||
top: 0;
|
||
bottom: 0;
|
||
width: 2px;
|
||
margin-left: -1px;
|
||
background: #fff;
|
||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.35);
|
||
pointer-events: none;
|
||
}
|
||
.compare-bar::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 30px;
|
||
height: 30px;
|
||
transform: translate(-50%, -50%);
|
||
border-radius: 999px;
|
||
background: rgba(255, 255, 255, 0.94);
|
||
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
|
||
}
|
||
|
||
.crop-layer { position: absolute; overflow: hidden; border-radius: var(--radius-sm); pointer-events: none; }
|
||
.crop-rect { position: absolute; border: 1px solid var(--accent); box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5); cursor: move; pointer-events: auto; touch-action: none; }
|
||
.crop-handle { position: absolute; width: 16px; height: 16px; background: var(--accent); border: 2px solid #fff; border-radius: 3px; touch-action: none; }
|
||
.crop-handle.tl { left: 0; top: 0; transform: translate(-50%, -50%); cursor: nwse-resize; }
|
||
.crop-handle.tr { right: 0; top: 0; transform: translate(50%, -50%); cursor: nesw-resize; }
|
||
.crop-handle.bl { left: 0; bottom: 0; transform: translate(-50%, 50%); cursor: nesw-resize; }
|
||
.crop-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
|
||
/* LIGHT's TONE CURVE: the graph hung on the photo. The layer is the photo's own
|
||
box and takes no pointer events; the card sits at its bottom-left corner and
|
||
takes them all, because every gesture in it is about the curve. */
|
||
.curve-layer { position: absolute; pointer-events: none; }
|
||
.curve-panel {
|
||
position: absolute;
|
||
left: 10px;
|
||
bottom: 10px;
|
||
pointer-events: auto;
|
||
touch-action: none;
|
||
width: 240px;
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
background: var(--bg-elev);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow: var(--shadow);
|
||
user-select: none;
|
||
}
|
||
.curve-head { display: flex; align-items: center; justify-content: space-between; cursor: grab; }
|
||
.curve-panel.dragging .curve-head { cursor: grabbing; }
|
||
.curve-title { font-size: 11px; letter-spacing: 0.1em; color: var(--muted); }
|
||
.curve-close {
|
||
border: none;
|
||
background: none;
|
||
color: var(--muted);
|
||
font-size: 12px;
|
||
line-height: 1;
|
||
padding: 2px 4px;
|
||
cursor: pointer;
|
||
}
|
||
.curve-close:hover { color: var(--fg); }
|
||
.curve-tabs { display: flex; gap: 4px; align-items: center; }
|
||
.curve-tab {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
flex: 1 1 0;
|
||
justify-content: center;
|
||
padding: 3px 0;
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
}
|
||
.curve-tab.on { border-color: var(--accent-line); color: var(--fg); }
|
||
/* The dot carries the channel's own colour, so the tab says which line it draws;
|
||
it goes amber once that channel has left the diagonal. */
|
||
.curve-dot { width: 7px; height: 7px; border-radius: 999px; }
|
||
.curve-tab.moved .curve-dot { box-shadow: 0 0 0 2px var(--accent); }
|
||
.curve-reset {
|
||
flex: 0 0 auto;
|
||
padding: 3px 7px;
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.06em;
|
||
color: var(--muted);
|
||
background: none;
|
||
border: 1px solid var(--border);
|
||
border-radius: 3px;
|
||
cursor: pointer;
|
||
}
|
||
.curve-reset:hover { color: var(--fg); border-color: var(--accent-line); }
|
||
.curve-graph { display: block; background: rgba(0, 0, 0, 0.35); border: 1px solid var(--border); border-radius: 3px; cursor: crosshair; }
|
||
/* The picture's own distribution, the floor the graph is read against. Light on
|
||
both themes because the graph itself is always the dark box over a photo. */
|
||
.curve-hist { fill: rgba(255, 255, 255, 0.22); }
|
||
.curve-grid { stroke: rgba(255, 255, 255, 0.1); stroke-width: 1; }
|
||
.curve-diagonal { stroke: rgba(255, 255, 255, 0.28); stroke-width: 1; stroke-dasharray: 3 3; }
|
||
/* The line the active tab draws, and — on a channel tab — the composite curve
|
||
under it, so the two moves can be told apart. */
|
||
.curve-line { fill: none; stroke-width: 2; stroke-linecap: round; }
|
||
.curve-line.ghost { stroke: rgba(255, 255, 255, 0.35); stroke-width: 1; stroke-dasharray: 4 3; }
|
||
.curve-point { fill: var(--bg-elev); stroke-width: 2; cursor: grab; }
|
||
.curve-point:active { cursor: grabbing; }
|
||
|
||
/* FRAME's custom watermark box: drag the body to move, the corner to scale. */
|
||
.wm-layer { position: absolute; border-radius: var(--radius-sm); pointer-events: none; }
|
||
.wm-rect { position: absolute; border: 1px dashed var(--accent); cursor: move; pointer-events: auto; touch-action: none; }
|
||
.wm-handle { position: absolute; width: 14px; height: 14px; background: var(--accent); border: 2px solid #fff; border-radius: 3px; touch-action: none; }
|
||
.wm-handle.br { right: 0; bottom: 0; transform: translate(50%, 50%); cursor: nwse-resize; }
|
||
/* A watermark's colour: the preset chips are a shortcut, the well picks any. */
|
||
.color-well { display: flex; align-items: center; gap: 8px; padding: 6px 8px; cursor: pointer; }
|
||
.color-well input[type="color"] { width: 34px; height: 22px; padding: 0; border: 1px solid var(--accent-line); border-radius: 3px; background: none; cursor: pointer; }
|
||
.color-well .val { font-size: 11px; letter-spacing: 0.08em; color: var(--muted); }
|
||
/* Eyedropper: the photo's own box, left transparent, with a crosshair cursor. */
|
||
.pick-layer {
|
||
position: absolute;
|
||
border-radius: var(--radius-sm);
|
||
cursor: crosshair;
|
||
touch-action: none;
|
||
}
|
||
/* FX's brush — HEAL and MOSAIC, one layer. It takes the pointer the way the
|
||
eyedropper's does, so a click paints and a drag paints a run instead of
|
||
panning; the circle under the cursor is the size the wheel has set, the trail
|
||
is the stroke being drawn, and every spot on the photo keeps its own circle.
|
||
A HEAL spot shows the pair the renderer works with — the hole it fills, and
|
||
the patch it borrowed; a MOSAIC spot has nothing beside it, since what it
|
||
covers is gone. */
|
||
.brush-layer {
|
||
position: absolute;
|
||
border-radius: var(--radius-sm);
|
||
cursor: none;
|
||
touch-action: none;
|
||
}
|
||
.brush-set { position: absolute; inset: 0; pointer-events: none; }
|
||
.brush-cursor,
|
||
.brush-spot,
|
||
.brush-source {
|
||
position: absolute;
|
||
border-radius: 50%;
|
||
transform: translate(-50%, -50%);
|
||
pointer-events: none;
|
||
}
|
||
.brush-cursor { border: 1px solid rgba(255, 255, 255, 0.9); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55); }
|
||
/* The stroke in progress is one band of the brush's own width — a path with
|
||
round ends and round joins — so a drag reads as a brush being drawn across
|
||
the photo rather than as the beads a row of overlapping circles looks like.
|
||
It wears the accent, so the run reads as something about to happen rather
|
||
than a spot already laid. */
|
||
.brush-trail {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: visible;
|
||
pointer-events: none;
|
||
}
|
||
.brush-trail path {
|
||
fill: none;
|
||
stroke: var(--accent);
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
stroke-opacity: 0.45;
|
||
}
|
||
.brush-spot { border: 1px solid rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); }
|
||
.brush-source { border: 1px dashed rgba(255, 255, 255, 0.7); }
|
||
/* The spots a stroke left are drawn as the one mark it was painted as: a band
|
||
of the brush's own width through them, in the same translucent white with the
|
||
same dark hairline the circles wear, so the band reads on a white photo too.
|
||
The dashed one is the patch HEAL borrowed, which is a band of its own and is
|
||
meant to look borrowed. A stroke is one path, so where it crosses itself its
|
||
translucency is laid once rather than twice — that doubling-up is exactly
|
||
what a row of overlapping circles looks like. */
|
||
.brush-band {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: visible;
|
||
pointer-events: none;
|
||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.45));
|
||
}
|
||
.brush-band path {
|
||
fill: none;
|
||
stroke: rgba(255, 255, 255, 0.18);
|
||
stroke-linecap: round;
|
||
stroke-linejoin: round;
|
||
}
|
||
.brush-band.band-src path { stroke: rgba(255, 255, 255, 0.12); stroke-dasharray: 5 5; }
|
||
/* A spot the band stands for: its box on the photo is still the spot's, its own
|
||
edge is the band's now. */
|
||
.brush-spot[data-band],
|
||
.brush-source[data-band] { border-color: transparent; box-shadow: none; }
|
||
/* A repair the pointer is not on and no press has chosen: the ring would be one
|
||
more circle on a photo that already carries as many repairs as the user laid,
|
||
so an idle spot is the patch it borrowed and a faint print of the place it
|
||
mended — soft, because it is a record of an edit rather than something to
|
||
aim at, and it is the ring under the pointer that says the spot can be taken
|
||
hold of again. */
|
||
.brush-spot[data-idle] {
|
||
border-color: transparent;
|
||
background: rgba(127, 127, 127, 0.14);
|
||
box-shadow: 0 0 7px 2px rgba(0, 0, 0, 0.25);
|
||
filter: blur(1px);
|
||
}
|
||
/* A run the band already stands for needs no print of its own under it. */
|
||
.brush-spot[data-idle][data-band] { background: none; box-shadow: none; filter: none; }
|
||
/* In hand, one of the run's spots is the circle it would be taken hold of by, so
|
||
it wears that circle again over the band — the ring is what says the spot is
|
||
under the pointer. */
|
||
.brush-spot[data-band]:not([data-idle]) { border-color: rgba(255, 255, 255, 0.85); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45); }
|
||
/* The × on the spot the pointer chose. It sits on the spot's corner and is the
|
||
one thing on this layer the pointer is meant to click, so it takes the
|
||
pointer back off the paint. */
|
||
.brush-del {
|
||
position: absolute;
|
||
transform: translate(-50%, -50%);
|
||
width: 18px;
|
||
height: 18px;
|
||
padding: 0;
|
||
border: 1px solid rgba(255, 255, 255, 0.9);
|
||
border-radius: 50%;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
color: #fff;
|
||
font: 12px/1 sans-serif;
|
||
cursor: pointer;
|
||
pointer-events: auto;
|
||
}
|
||
.brush-del:hover { background: var(--accent); }
|
||
/* FX's gradient masks — LINEAR and RADIAL GRADIENT, one layer. It takes the
|
||
pointer while a mask tool is armed, so a drag draws the next shape instead of
|
||
panning the photo; the shapes already on the photo answer only through their
|
||
own pin and handles, which is what lets a press mean "take hold of this one"
|
||
rather than "draw another". */
|
||
.mask-layer {
|
||
position: absolute;
|
||
border-radius: var(--radius-sm);
|
||
cursor: crosshair;
|
||
touch-action: none;
|
||
}
|
||
/* The shape's own outline, over the photo: the pin and the handles are the
|
||
things on this layer to press, and the chosen shape's edge is one more — the
|
||
line a ramp falls along, or an ellipse's rim. Invisible and a finger wide, so
|
||
a press on the edge turns the shape without having to find a handle for it;
|
||
non-scaling, so the band is the same width however the photo is zoomed. */
|
||
.mask-shape {
|
||
position: absolute;
|
||
inset: 0;
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: visible;
|
||
pointer-events: none;
|
||
filter: drop-shadow(0 0 1px rgba(0, 0, 0, 0.45));
|
||
}
|
||
.mask-line { stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
|
||
.mask-edge {
|
||
fill: none;
|
||
stroke: rgba(255, 255, 255, 0.01);
|
||
stroke-width: 16;
|
||
pointer-events: stroke;
|
||
vector-effect: non-scaling-stroke;
|
||
cursor: grab;
|
||
}
|
||
/* An ellipse's rim, and inside it the ellipse its feather fades to — so the
|
||
hand the shape has on the photo is visible before the knobs are moved. */
|
||
.mask-ring { fill: none; stroke: rgba(255, 255, 255, 0.85); stroke-width: 1; }
|
||
.mask-ring.feather { stroke: rgba(255, 255, 255, 0.5); stroke-dasharray: 4 4; }
|
||
/* The pin that moves the whole shape, and the handles that resize it. Both are
|
||
the pointer's own, and both keep a screen size of their own whatever the zoom
|
||
(the layer's transform is taken back out of them). */
|
||
.mask-pin,
|
||
.mask-handle {
|
||
position: absolute;
|
||
width: 13px;
|
||
height: 13px;
|
||
border: 1px solid rgba(0, 0, 0, 0.55);
|
||
background: rgba(255, 255, 255, 0.9);
|
||
pointer-events: auto;
|
||
cursor: move;
|
||
}
|
||
.mask-pin { border-radius: 50%; }
|
||
.mask-handle { border-radius: 2px; }
|
||
.mask-handle.rot { border-radius: 50%; cursor: grab; }
|
||
/* The mixer's readout: the sampled swatch, its RGB and HSL, and the band the
|
||
ruler is pointed at. */
|
||
.hsl-readout {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
padding-top: 10px;
|
||
border-top: 1px solid var(--border);
|
||
}
|
||
.hsl-swatch { display: block; height: 34px; border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||
.hsl-line { font-size: 12px; color: var(--text-dim); font-variant-numeric: tabular-nums; }
|
||
/* A chip that names a colour shows it: the dot rides the leading edge. */
|
||
.chip-dot { width: 10px; height: 10px; border-radius: 999px; border: 1px solid var(--border); flex: 0 0 auto; }
|
||
/* The eyedropper's own icon, riding the cursor inside the pick layer. Its hot
|
||
spot is the tip, so it is nudged up and left of the pointer. */
|
||
.pick-icon { position: absolute; pointer-events: none; transform: translate(-2px, -21px); filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.65)); }
|
||
.pick-icon-body { fill: #fff; stroke: #111; stroke-width: 1.2; }
|
||
.pick-icon-tip { fill: var(--accent); stroke: #111; stroke-width: 1.2; }
|
||
/* The mixer's panel for the colour just sampled: it hangs on the photo at the
|
||
point the eyedropper read, its swatch on top and HUE/SAT/LUM in one row
|
||
under it. The layer is transparent to the pointer, the card is not. */
|
||
.hsl-panel-layer { position: absolute; pointer-events: none; }
|
||
.hsl-panel {
|
||
position: absolute;
|
||
pointer-events: auto;
|
||
/* Dragged by its body to a clear patch of photo; the knobs and the close
|
||
button keep the pointer to themselves. */
|
||
cursor: grab;
|
||
touch-action: none;
|
||
width: 248px;
|
||
padding: 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
background: var(--bg-elev);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.hsl-card-head { display: flex; align-items: center; gap: 8px; }
|
||
.hsl-card-swatch { width: 28px; height: 28px; flex: 0 0 auto; border: 1px solid var(--border); border-radius: var(--radius-sm); }
|
||
.hsl-card-name { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; font-size: 11px; letter-spacing: 0.08em; color: var(--text-dim); }
|
||
.hsl-card-name b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); }
|
||
.hsl-card-close { border: 1px solid var(--border); background: var(--bg); color: var(--text-dim); border-radius: 999px; padding: 2px 7px; font-size: 11px; cursor: pointer; }
|
||
.hsl-card-knobs { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; }
|
||
.mini-slider { min-width: 0; }
|
||
.mini-head { display: flex; align-items: center; gap: 8px; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
|
||
.mini-head span { flex: 0 0 auto; }
|
||
/* The track takes the width the two labels leave, so the name of the knob opens
|
||
the row and the number it is on closes it (see MiniSlider). */
|
||
.mini-head input[type="range"] { flex: 1 1 40px; min-width: 0; }
|
||
.mini-head b { flex: 0 0 auto; font-family: var(--mono); letter-spacing: 0; color: var(--text); }
|
||
.mini-head b.amber { color: var(--accent); }
|
||
/* The mixer's three knobs share one narrow panel, so they keep the stacked form
|
||
the row above has just left: the name and its number on one line, the track
|
||
under them. The readout is ordered ahead of the track, which takes the whole
|
||
second line — in the markup the track sits between the two. */
|
||
.hsl-card-knobs .mini-head { flex-wrap: wrap; justify-content: space-between; }
|
||
.hsl-card-knobs .mini-head b { order: 1; }
|
||
.hsl-card-knobs .mini-head input[type="range"] { order: 2; flex: 1 0 100%; }
|
||
/* FRAME's STRAIGHTEN scale: a graduated ruler lying along the foot of the
|
||
photo. The layer is transparent to the pointer so the photo still answers a
|
||
drag outside the card; the card itself takes the drag. */
|
||
.straighten-layer { position: absolute; z-index: 3; pointer-events: none; }
|
||
.straighten-ruler {
|
||
position: absolute;
|
||
left: 10px;
|
||
right: 10px;
|
||
bottom: 10px;
|
||
padding: 6px 14px 8px;
|
||
pointer-events: auto;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
user-select: none;
|
||
touch-action: none;
|
||
}
|
||
.straighten-head { display: flex; align-items: baseline; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); text-shadow: 0 1px 2px var(--bg); }
|
||
.straighten-head b { font-family: var(--mono); font-size: 12px; letter-spacing: 0; color: var(--text); font-variant-numeric: tabular-nums; }
|
||
.straighten-head b.amber { color: var(--accent); }
|
||
.straighten-scale { position: relative; height: 30px; cursor: ew-resize; filter: drop-shadow(0 0 2px var(--bg)); }
|
||
.straighten-tick { position: absolute; bottom: 12px; width: 1px; height: 7px; background: var(--border); transform: translateX(-50%); }
|
||
.straighten-tick.major { height: 12px; background: var(--text-dim); }
|
||
.straighten-tick.zero { height: 16px; background: var(--text); }
|
||
.straighten-tick i { position: absolute; top: 13px; left: 0; transform: translateX(-50%); font-style: normal; font-size: 9px; color: var(--text-dim); }
|
||
.straighten-needle { position: absolute; bottom: 4px; width: 2px; height: 20px; background: var(--accent); transform: translateX(-50%); pointer-events: none; }
|
||
/* Histogram overlay: a small translucent frame on the photo, dragged by its
|
||
header. The luma curve is a grey fill behind the three channel curves, which
|
||
screen over each other the way a Lightroom histogram reads. */
|
||
.hist {
|
||
position: absolute;
|
||
z-index: 4;
|
||
width: 236px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
background: color-mix(in srgb, var(--bg) 86%, transparent);
|
||
backdrop-filter: blur(6px);
|
||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
|
||
user-select: none;
|
||
touch-action: none;
|
||
}
|
||
.hist-head { display: flex; align-items: center; gap: 4px; padding: 3px 4px 3px 8px; cursor: grab; }
|
||
.hist.dragging .hist-head { cursor: grabbing; }
|
||
.hist-title { flex: 1 1 auto; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
|
||
.hist-close {
|
||
width: 18px; height: 18px; padding: 0; line-height: 1;
|
||
font-size: 13px; color: var(--text-dim);
|
||
background: none; border: 0; border-radius: 4px; cursor: pointer;
|
||
}
|
||
.hist-close:hover { background: var(--bg-sunken); color: var(--text); }
|
||
.hist-plot { display: block; width: 100%; height: 84px; }
|
||
.hist-lum { fill: var(--text-dim); opacity: 0.38; }
|
||
.hist-ch { fill: none; stroke-width: 1.1; }
|
||
/* Screen is how the three curves add up like light on a dark panel — but it is
|
||
measured against the backdrop, so on the light theme (a white plot) it
|
||
screens every channel straight to white and the histogram reads as empty. */
|
||
[data-theme='dark'] .hist-ch { mix-blend-mode: screen; }
|
||
.hist-r { stroke: #ff5a5a; }
|
||
.hist-g { stroke: #4ade80; }
|
||
.hist-b { stroke: #5aa2ff; }
|
||
.hist-axis {
|
||
display: flex; justify-content: space-between;
|
||
padding: 1px 6px 4px;
|
||
font-family: var(--mono); font-size: 9px; color: var(--text-dim);
|
||
}
|
||
.busy {
|
||
position: absolute;
|
||
inset: auto 0 12px 0;
|
||
margin: 0 auto;
|
||
width: fit-content;
|
||
background: var(--bg-elev);
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
padding: 5px 12px;
|
||
font-size: 12px;
|
||
color: var(--text-dim);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.stage .photo-meta-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8px;
|
||
padding: 4px 12px;
|
||
font-size: 11px;
|
||
font-family: var(--mono);
|
||
color: var(--muted);
|
||
letter-spacing: 0.03em;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
max-width: 100%;
|
||
}
|
||
.stage .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
||
.stage .toolbar .dims { font-family: var(--mono); white-space: nowrap; }
|
||
.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; }
|
||
|
||
/* The stage and, over it, the live viewfinder: the slot they share in the
|
||
workspace. The columns beside it are the app's own — a slider there moves the
|
||
look on the frames the camera is grading right now. */
|
||
.stage-slot { flex: 1 1 auto; min-width: 0; display: flex; position: relative; }
|
||
|
||
/* --- live viewfinder ---------------------------------------------------- */
|
||
/* Covers the stage, not the app: black whatever the theme, because a viewfinder
|
||
is something the user looks through, but the tabs stay in reach — that is the
|
||
whole point of framing through the recipe. */
|
||
.camera-view {
|
||
position: absolute;
|
||
inset: 0;
|
||
z-index: 5;
|
||
background: #000;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 12px;
|
||
padding: 12px;
|
||
}
|
||
/* The graded frame itself. `object-fit: contain` keeps the whole picture on
|
||
screen at any window shape, which is the composition the export writes. */
|
||
.camera-feed {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
max-width: 100%;
|
||
max-height: 100%;
|
||
object-fit: contain;
|
||
background: #000;
|
||
}
|
||
.camera-bar,
|
||
.camera-deck {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
width: 100%;
|
||
max-width: 900px;
|
||
}
|
||
.camera-bar .hint { color: #a9a9a9; font-family: var(--mono); }
|
||
.camera-bar .spacer { flex: 1 1 auto; }
|
||
.camera-deck .camera-spacer { width: 44px; }
|
||
.camera-err { max-width: 900px; }
|
||
/* The <video> the live frames are read from. It has to be IN the document —
|
||
Safari paints blank frames from a detached video — but never seen: 1px, faint
|
||
and out of the way, behind the black backdrop. Not `display: none`: that stops
|
||
the frames as surely as detaching it. */
|
||
.camera-source-host {
|
||
position: fixed;
|
||
left: 0;
|
||
bottom: 0;
|
||
width: 1px;
|
||
height: 1px;
|
||
opacity: 0.01;
|
||
overflow: hidden;
|
||
pointer-events: none;
|
||
}
|
||
.camera-source-host video { width: 1px; height: 1px; }
|
||
/* The shutter: the app's one round control, because it is the one control whose
|
||
shape says what it does. */
|
||
.shutter {
|
||
width: 62px;
|
||
height: 62px;
|
||
margin: 0 auto;
|
||
padding: 0;
|
||
border: 3px solid #fff;
|
||
border-radius: 50%;
|
||
background: #fff;
|
||
box-shadow: 0 0 0 3px rgba(0, 0, 0, 0.65);
|
||
cursor: pointer;
|
||
}
|
||
.shutter:disabled { opacity: 0.4; cursor: default; }
|
||
|
||
/* --- auth dialog -------------------------------------------------------- */
|
||
.modal-backdrop {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(0, 0, 0, 0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 60;
|
||
padding: 16px;
|
||
}
|
||
.modal {
|
||
background: var(--bg-elev);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
padding: 20px;
|
||
width: 100%;
|
||
max-width: 360px;
|
||
box-shadow: var(--shadow);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 10px;
|
||
}
|
||
.modal h2 { margin: 0; font-size: 17px; }
|
||
/* The one row in the export menu that is not a plain button: a number to type
|
||
and the button that takes it. */
|
||
.export-custom {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.export-custom .field { width: 96px; }
|
||
|
||
/* Hand the export to a network. A hairline over it, so the row reads as the
|
||
second way out of the dialog and not as a sixth size. */
|
||
.export-share {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
margin-top: 12px;
|
||
padding-top: 12px;
|
||
border-top: 1px solid var(--border);
|
||
text-align: left;
|
||
}
|
||
.export-share-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
.export-share-row .btn {
|
||
flex: 1 1 auto;
|
||
font-size: 12px;
|
||
letter-spacing: 0.06em;
|
||
}
|
||
|
||
/* --- strip moderation (/admin) ------------------------------------------ */
|
||
.adm { height: 100%; overflow-y: auto; padding: 0 22px 40px; }
|
||
/* Sticky: the mark and the page name stay in the top-left corner while the
|
||
photo grid scrolls under them, like the studio's header and the landing nav. */
|
||
.adm-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 5;
|
||
margin: 0 -22px 16px;
|
||
padding: 12px 22px;
|
||
background: var(--bg);
|
||
border-bottom: 1px solid var(--border-soft);
|
||
}
|
||
.adm-title { font-size: 15px; letter-spacing: 0.02em; }
|
||
/* The mark and the page name, top-left, always a way back to the landing page —
|
||
the same pair the studio's TopBar carries. */
|
||
.adm-brand { display: flex; align-items: center; gap: 6px; font-weight: 650; letter-spacing: -0.01em; color: var(--text); text-decoration: none; }
|
||
.adm-brand em { font-style: normal; font-weight: 400; color: var(--accent); }
|
||
.adm-brand img { border-radius: 8px; }
|
||
/* Curating pictures: two album columns of the same shape — the uploads (one
|
||
album per account) and the landing (one album per section). Each column
|
||
lists its albums and then draws the open album's frames. */
|
||
.adm-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; align-items: start; }
|
||
@media (max-width: 1100px) { .adm-cols { grid-template-columns: minmax(0, 1fr); } }
|
||
.adm-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||
/* The filters over the album list: one name box, two pickers. */
|
||
.adm-input {
|
||
width: 100%;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
color: var(--text);
|
||
background: var(--bg-sunken);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 6px 8px;
|
||
}
|
||
.adm-filters { display: flex; gap: 6px; }
|
||
.adm-filters .adm-input { min-width: 0; }
|
||
/* A shelf of albums: it scrolls on its own so the frames below stay reachable
|
||
however many accounts or sections there are. */
|
||
.adm-album-list { display: flex; flex-direction: column; gap: 4px; max-height: 46vh; overflow-y: auto; padding-right: 2px; }
|
||
.adm-album {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
font: inherit;
|
||
font-size: 12px;
|
||
text-align: left;
|
||
color: var(--text-dim);
|
||
background: var(--bg-sunken);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
padding: 7px 9px;
|
||
cursor: pointer;
|
||
}
|
||
.adm-album:hover { border-color: var(--accent-line); }
|
||
.adm-album.on { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||
.adm-album-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.adm-album-count { font-family: var(--mono); font-size: 11px; }
|
||
/* One album column: the shelf of albums down the left, the frame that is up
|
||
beside it, the album's strip of thumbnails across the bottom. */
|
||
.adm-split { display: grid; grid-template-columns: minmax(150px, 200px) minmax(0, 1fr); gap: 12px; align-items: start; }
|
||
.adm-stage {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
min-width: 0;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg);
|
||
padding: 10px;
|
||
}
|
||
.adm-stage-img { width: 100%; aspect-ratio: 4 / 3; object-fit: contain; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||
/* The album's strip: thumbnails in one row, scrolled sideways when they
|
||
overflow the column rather than wrapping into a wall of pictures. */
|
||
.adm-filmstrip { display: flex; gap: 8px; overflow-x: auto; padding-bottom: 4px; }
|
||
/* A run of the strip that is not drawn — the tiles left and right of the window.
|
||
It is given the width they would have taken, and it must not shrink, or the
|
||
scrollbar would measure the window instead of the shelf. */
|
||
.adm-thumb-gap { flex: 0 0 auto; }
|
||
/* The wall's stand-in for the rows it is not drawing: a run of cards' worth of
|
||
height, spanning the whole grid so nothing lands beside it. */
|
||
.adm-wall-gap { grid-column: 1 / -1; }
|
||
.adm-thumb {
|
||
flex: 0 0 132px;
|
||
margin: 0;
|
||
padding: 0;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
border: 1px solid var(--border-soft);
|
||
border-radius: var(--radius-sm);
|
||
overflow: hidden;
|
||
background: var(--bg-sunken);
|
||
color: var(--text-dim);
|
||
}
|
||
.adm-thumb:hover { border-color: var(--accent-line); }
|
||
.adm-thumb.on { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||
.adm-thumb.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent); background: var(--accent-soft); }
|
||
.adm-thumb img { display: block; width: 100%; aspect-ratio: 4 / 3; object-fit: cover; }
|
||
.adm-thumb-cap { display: block; padding: 3px 6px; font-family: var(--mono); font-size: 10.5px; }
|
||
/* The three destinations sit on one line under the photo, each an independent
|
||
checkbox — tick all three and the photo shows in all three sections. */
|
||
.adm-choices { display: flex; flex-wrap: wrap; gap: 5px; }
|
||
.adm-choice {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
font: inherit;
|
||
font-size: 10.5px;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
padding: 4px 8px;
|
||
cursor: pointer;
|
||
color: var(--text-dim);
|
||
background: var(--bg-sunken);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
}
|
||
.adm-choice:hover { border-color: var(--accent-line); }
|
||
.adm-choice:has(input:checked) { background: var(--accent-soft); border-color: var(--accent); color: var(--accent); }
|
||
.adm-choice:has(input:disabled) { opacity: 0.5; cursor: default; }
|
||
.adm-choice input { margin: 0; accent-color: var(--accent); }
|
||
.adm-row-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin: 0 0 8px;
|
||
font-size: 11.5px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
}
|
||
.adm-row-count { font-family: var(--mono); color: var(--text); }
|
||
.adm-row-empty { margin: 0; }
|
||
/* The QR section's real artefact is the code, not the photo. Drawn on white:
|
||
a scanner needs the quiet zone, and the dark theme has none. */
|
||
.adm-qr { display: flex; align-items: center; justify-content: center; min-height: 88px; padding: 6px; background: #fff; }
|
||
.adm-qr img { width: auto; aspect-ratio: 1 / 1; }
|
||
.adm-qr span { color: #6b7280; font-size: 10.5px; text-align: center; }
|
||
.adm-spacer { flex: 1 1 auto; }
|
||
.adm-note { margin: 12px 0; }
|
||
.adm-sub { margin: 0 0 14px; }
|
||
.adm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; }
|
||
.adm-card {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 8px;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg-elev);
|
||
padding: 10px;
|
||
}
|
||
.adm-card.selected {
|
||
border-color: var(--accent);
|
||
box-shadow: 0 0 0 2px var(--accent);
|
||
}
|
||
.adm-card img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
||
/* A frame whose RAW carries no preview of its own: the tile stands in for the
|
||
picture and opens the file just the same. */
|
||
.lib-noimg {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
aspect-ratio: 4 / 3;
|
||
border-radius: var(--radius-sm);
|
||
background: var(--bg-sunken);
|
||
color: var(--text-dim);
|
||
font-family: var(--mono);
|
||
font-size: 12px;
|
||
letter-spacing: 0.14em;
|
||
cursor: pointer;
|
||
}
|
||
.adm-meta { display: flex; flex-direction: column; gap: 2px; font-size: 11.5px; color: var(--text-dim); min-width: 0; }
|
||
.adm-meta b { color: var(--text); font-family: var(--mono); }
|
||
.adm-meta span { overflow-wrap: anywhere; }
|
||
.adm-danger { color: var(--danger); border-color: var(--border); }
|
||
.adm-danger:hover:not(:disabled) { border-color: var(--danger); }
|
||
|
||
/* The catalogue is one screen and never grows: the frame on the stage, the tree
|
||
beside it, and the strip pinned to the bottom edge. Whatever overflows scrolls
|
||
inside its own panel, so the page itself has no scrollbar to hide the strip
|
||
below the fold. Scoped to `.lib-root` — /admin shares the .adm-* blocks and
|
||
still scrolls as a page. */
|
||
.lib-root { display: flex; flex-direction: column; height: 100%; overflow: hidden; padding-bottom: 12px; }
|
||
.lib-root .adm-frame { flex: 1 1 auto; min-height: 0; }
|
||
.lib-root .adm-pane { display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
|
||
.lib-root .adm-note { flex: 0 0 auto; }
|
||
/* The column: title and the folder's own chips stay put, the split takes what
|
||
is left, the strip keeps its natural height on the bottom. */
|
||
.lib-root .adm-col { flex: 1 1 auto; min-height: 0; }
|
||
/* The column of folders is a list of names and a count — the stage is the point
|
||
of the screen, so the column takes the least it can and gives the rest away.
|
||
Its width is a variable the divider writes, and the divider itself is the middle
|
||
track, thin enough to read as the seam between the two panels. */
|
||
.lib-root .adm-col > .adm-split {
|
||
flex: 1 1 auto;
|
||
min-height: 0;
|
||
align-items: stretch;
|
||
grid-template-columns: var(--lib-col, 118px) 9px minmax(0, 1fr);
|
||
gap: 0;
|
||
}
|
||
.lib-grip { cursor: col-resize; border-radius: 999px; touch-action: none; }
|
||
.lib-grip:hover, .lib-grip:focus-visible { background: var(--accent-soft); outline: none; }
|
||
.lib-root .adm-album { padding: 6px 8px; gap: 6px; justify-content: flex-start; }
|
||
.lib-root .adm-album-name { flex: 1 1 auto; min-width: 0; }
|
||
/* The caret column: pointing right where a row's children are folded away, down
|
||
where they are drawn, and empty on a leaf so names at one depth line up. */
|
||
.lib-caret {
|
||
flex: 0 0 auto;
|
||
width: 0;
|
||
height: 0;
|
||
border-left: 4.5px solid currentColor;
|
||
border-top: 3.5px solid transparent;
|
||
border-bottom: 3.5px solid transparent;
|
||
opacity: 0.5;
|
||
transition: transform 0.15s ease;
|
||
}
|
||
.lib-caret.open { transform: rotate(90deg); }
|
||
.lib-caret.leaf { border-left-color: transparent; }
|
||
/* A roll being read turns a ring on its own row. */
|
||
.lib-spin {
|
||
flex: 0 0 auto;
|
||
width: 11px;
|
||
height: 11px;
|
||
border-radius: 999px;
|
||
border: 2px solid var(--border);
|
||
border-top-color: var(--accent);
|
||
animation: lib-spin 0.7s linear infinite;
|
||
}
|
||
@keyframes lib-spin { to { transform: rotate(360deg); } }
|
||
/* The ring of a reading that is queued: still, and greyed, so it reads as a
|
||
wait rather than as work. */
|
||
.lib-spin.wait { animation: none; border-top-color: var(--border); opacity: 0.6; }
|
||
/* The same ring, out in the header: the reading belongs to this tab, not to a
|
||
screen, so the studio says so too while it runs. */
|
||
.lib-scan { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
|
||
@media (prefers-reduced-motion: reduce) {
|
||
.lib-spin { animation-duration: 2.4s; }
|
||
}
|
||
.lib-root .adm-col > .adm-filmstrip { flex: 0 0 auto; }
|
||
.lib-root .adm-album-list { max-height: none; flex: 1 1 auto; min-height: 0; }
|
||
.lib-root .adm-stage { min-height: 0; overflow: hidden; }
|
||
.lib-root .adm-stage-viewport { flex: 1 1 auto; min-height: 0; width: 100%; overflow: hidden; display: flex; align-items: center; justify-content: center; position: relative; }
|
||
.lib-root .adm-stage-img { flex: 1 1 auto; min-height: 0; width: 100%; aspect-ratio: auto; object-fit: contain; }
|
||
.lib-root .adm-stage > .lib-noimg { flex: 1 1 auto; min-height: 0; aspect-ratio: auto; }
|
||
.lib-root .adm-stage > .adm-meta,
|
||
.lib-root .adm-stage > .chip-row { flex: 0 0 auto; }
|
||
/* The bar under the stage is four tracks — a free one to either side of the two
|
||
quarter-turns, so the pair lands on the picture's own centre whatever the
|
||
OPEN button and the stars happen to measure, and they stay at the ends they
|
||
belong to. Equal `1fr` either side is what does it; a spacer could not, since
|
||
the two end blocks are not the same width. */
|
||
.lib-root .adm-stage > .chip-row { display: grid; grid-template-columns: 1fr auto auto 1fr; align-items: center; }
|
||
.lib-root .adm-stage > .chip-row > .btn { justify-self: start; }
|
||
.lib-root .adm-stage > .chip-row > .lib-stars { justify-self: end; }
|
||
/* Under ~700px of window the stage is narrower than those four need on one line,
|
||
and a grid would run the stars off the edge of it rather than wrap. It goes
|
||
back to the flex row it was — the lines it wraps to are centred under the
|
||
picture, so nothing slides off and the pair still sits in the middle. */
|
||
@media (max-width: 700px) {
|
||
.lib-root .adm-stage > .chip-row { display: flex; justify-content: center; }
|
||
}
|
||
/* All thumbs: the wall scrolls down inside the stage, and its own scrollbar on
|
||
the right edge is what reaches the frames below. */
|
||
.lib-root .adm-grid { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding-right: 6px; }
|
||
/* What the thumbnail wall is read through: the four selects and the count of what
|
||
is left. One row above the wall, and it does not scroll with it. */
|
||
.lib-root .adm-stage > .lib-filters,
|
||
.lib-filters {
|
||
flex: 0 0 auto;
|
||
display: flex !important;
|
||
flex-direction: row !important;
|
||
flex-wrap: nowrap !important;
|
||
grid-template-columns: none !important;
|
||
gap: 6px;
|
||
align-items: center;
|
||
margin-bottom: 8px;
|
||
overflow-x: auto;
|
||
white-space: nowrap;
|
||
width: 100%;
|
||
}
|
||
.lib-filters .adm-input,
|
||
.lib-filters select,
|
||
.lib-filters button,
|
||
.lib-filters input {
|
||
width: auto !important;
|
||
max-width: none !important;
|
||
flex: 0 0 auto !important;
|
||
flex-shrink: 0 !important;
|
||
padding: 4px 8px;
|
||
font-size: 12px;
|
||
}
|
||
.lib-filters .hint { margin-left: auto; flex-shrink: 0; }
|
||
/* The frame's own score: five glyphs, lit up to the score it has. The star the
|
||
click lands on is the score it gives, so the row is the control. */
|
||
.lib-stars { display: flex; gap: 1px; align-items: center; }
|
||
.lib-star {
|
||
border: 1px solid transparent;
|
||
background: none;
|
||
border-radius: 6px;
|
||
padding: 2px 3px;
|
||
font-size: 15px;
|
||
line-height: 1.1;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
}
|
||
.lib-star:hover { border-color: var(--border); }
|
||
.lib-star.on { color: var(--accent); }
|
||
/* One toolbar, not two rows of chrome: ADD FOLDER, the hint, and the pair of view
|
||
icons. The hint gives up its width first and ellipsises — the whole sentence is
|
||
still there under the pointer. */
|
||
.lib-bar { flex: 0 0 auto; align-items: center; margin-bottom: 10px; }
|
||
.lib-bar .btn { padding: 5px 10px; font-size: 12.5px; }
|
||
.lib-bar-hint { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
/* The views are a glyph each (a frame, a wall of thumbs), so the row stays a row
|
||
when the window is narrow. */
|
||
.lib-icon { padding: 5px 8px; justify-content: center; }
|
||
/* The two quarter-turn buttons under the stage: a bigger glyph than a view icon,
|
||
so the padding grows with it — the target is what the thumb aims at, and these
|
||
are pressed far more often than a view is switched. */
|
||
.chip.lib-turn { padding: 7px 12px; }
|
||
/* A folder's own menu, on a right click. Fixed, so the tree's own scroll cannot
|
||
clip it; the position is clamped to the window when it is drawn.
|
||
Sized after a catalogue's own menu rather than after a page's: it hangs off a
|
||
row of a list the reader is scanning with their eyes, and a box of commands as
|
||
loud as the list is a box that stops the scanning. The type and the rows are
|
||
the tree's own, a shade tighter. */
|
||
.lib-menu {
|
||
position: fixed;
|
||
z-index: 40;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-width: 148px;
|
||
padding: 3px;
|
||
background: var(--bg-elev);
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.lib-menu-item {
|
||
border: 0;
|
||
border-radius: var(--radius-sm);
|
||
background: none;
|
||
padding: 4px 7px;
|
||
font-size: 11px;
|
||
line-height: 1.35;
|
||
letter-spacing: 0.03em;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
/* Each command leads with a mark of its own — rename, scan, forget, fold — the
|
||
way the tool rail and the view switch do, so a menu read at a glance is a
|
||
column of marks rather than a block of uppercase. */
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 7px;
|
||
}
|
||
.lib-menu-icon { flex: 0 0 auto; width: 10px; text-align: center; opacity: 0.85; }
|
||
.lib-menu-item:hover:not(:disabled) { background: var(--accent-soft); color: var(--accent); }
|
||
.lib-menu-item:disabled { opacity: 0.45; cursor: not-allowed; }
|
||
|
||
/* Settings drawer: the tabs that organise the page, plus the three panels. */
|
||
/* The management frame: a bordered box whose left column is the menu and whose
|
||
right side is the pane for the chosen section. */
|
||
.adm-frame {
|
||
display: flex;
|
||
align-items: stretch;
|
||
border: 1px solid var(--border);
|
||
border-radius: var(--radius);
|
||
background: var(--bg-elev);
|
||
overflow: hidden;
|
||
}
|
||
.adm-side {
|
||
flex: 0 0 200px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
padding: 12px 10px;
|
||
border-right: 1px solid var(--border);
|
||
background: var(--bg-sunken);
|
||
}
|
||
.adm-side-title {
|
||
padding: 0 8px 8px;
|
||
font-size: 11px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
}
|
||
.adm-side-item {
|
||
display: block;
|
||
padding: 8px 10px;
|
||
border: 0;
|
||
border-radius: var(--radius-sm);
|
||
background: transparent;
|
||
color: var(--text);
|
||
font: inherit;
|
||
text-align: left;
|
||
text-decoration: none;
|
||
cursor: pointer;
|
||
}
|
||
.adm-side-item:hover { background: var(--bg-elev); }
|
||
.adm-side-item.on { background: var(--accent); color: var(--accent-fg); font-weight: 600; }
|
||
.adm-pane { flex: 1 1 auto; min-width: 0; padding: 14px 16px; }
|
||
/* The users table: a frame around every cell, and a head that reads as a head —
|
||
the heading face, smaller and dimmer than the rows under it. The accounts are
|
||
what the eye should land on, so the header steps back. */
|
||
.adm-table-wrap { overflow-x: auto; }
|
||
.adm-table { border-collapse: collapse; width: 100%; }
|
||
.adm-table th,
|
||
.adm-table td {
|
||
border: 1px solid var(--border);
|
||
padding: 6px 9px;
|
||
text-align: left;
|
||
vertical-align: middle;
|
||
}
|
||
.adm-table th {
|
||
background: var(--bg-sunken);
|
||
color: var(--text-dim);
|
||
font-family: var(--font-heading);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
letter-spacing: 0.04em;
|
||
text-transform: uppercase;
|
||
white-space: nowrap;
|
||
}
|
||
.adm-table tbody tr:hover { background: var(--bg-sunken); }
|
||
/* The PRO box: one narrow centred column, and a box big enough to aim at. */
|
||
.adm-table th.adm-pro,
|
||
.adm-table td.adm-pro { width: 1%; text-align: center; white-space: nowrap; }
|
||
.adm-table td.adm-pro input { display: block; margin: 0 auto; cursor: pointer; }
|
||
.adm-table td.adm-pro input:disabled { cursor: default; }
|
||
/* Bulk selection: the three controls above the users table, and the checkbox
|
||
column at its head (narrow so the picture column keeps the width). */
|
||
.adm-bulk { display: flex; align-items: center; gap: 8px; margin: 0 0 12px; }
|
||
.adm-bulk .btn { margin: 0; }
|
||
.adm-check { width: 1%; white-space: nowrap; }
|
||
.adm-check input { display: block; cursor: pointer; }
|
||
.adm-face {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 28px;
|
||
height: 28px;
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
background: var(--bg-sunken);
|
||
border: 1px solid var(--border);
|
||
color: var(--text-dim);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
}
|
||
.adm-face img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||
/* Moderation state next to the account name, and the row's own actions: the
|
||
three controls sit side by side and the pane scrolls when it runs out. */
|
||
.adm-badge-warn { margin-left: 6px; color: var(--danger); font-size: 11px; }
|
||
.adm-table td:last-child { white-space: nowrap; }
|
||
.adm-table td:last-child .btn { margin-right: 6px; }
|
||
.adm-form { display: flex; flex-direction: column; gap: 8px; max-width: 360px; }
|
||
.field-label { font-size: 11.5px; color: var(--text-dim); }
|
||
|
||
/* --- profile picture ---------------------------------------------------- */
|
||
/* One face for every control that names the signed-in user: the workspace
|
||
TopBar, the landing chip and the profile form. No picture = initial. */
|
||
.avatar {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
flex: 0 0 auto;
|
||
width: 26px;
|
||
height: 26px;
|
||
border-radius: 999px;
|
||
overflow: hidden;
|
||
background: var(--accent-soft);
|
||
border: 1px solid var(--accent-line);
|
||
color: var(--accent);
|
||
font-size: 11px;
|
||
font-weight: 600;
|
||
line-height: 1;
|
||
}
|
||
.avatar img { display: block; width: 100%; height: 100%; object-fit: cover; }
|
||
.avatar-sm { width: 20px; height: 20px; font-size: 10px; }
|
||
.avatar-row { display: flex; align-items: center; gap: 10px; }
|
||
.avatar-row .avatar { width: 46px; height: 46px; font-size: 17px; }
|
||
.account-btn { display: inline-flex; align-items: center; gap: 7px; }
|
||
/* The signed-in name rides the accent with the face beside it. */
|
||
.btn.account-btn { color: var(--accent); }
|
||
|
||
/* --- stats (admin) ------------------------------------------------------- */
|
||
/* The traffic screen: one SVG chart and a column of proportional bar lists.
|
||
No charting dependency — a couple of polylines and styled divs are enough. */
|
||
.stats-bar { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin: 0 0 14px; }
|
||
.stats-bar .hint { margin: 0; }
|
||
.stats-kpis { display: flex; flex-wrap: wrap; gap: 12px; margin: 0 0 16px; }
|
||
.stats-kpi {
|
||
display: flex; flex-direction: column; gap: 2px; min-width: 104px;
|
||
padding: 10px 14px; border: 1px solid var(--border);
|
||
border-radius: var(--radius-sm); background: var(--bg-elev);
|
||
}
|
||
.stats-kpi b { font-size: 22px; font-variant-numeric: tabular-nums; }
|
||
.stats-kpi span { font-size: 11px; letter-spacing: 0.06em; color: var(--text-dim); }
|
||
.stats-chart {
|
||
display: block; width: 100%; height: 180px;
|
||
border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--bg-sunken);
|
||
}
|
||
.stats-area { fill: var(--accent); opacity: 0.12; }
|
||
.stats-line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
|
||
.stats-line-2 { stroke: var(--text-dim); }
|
||
.stats-legend { display: flex; gap: 14px; margin: 8px 0 18px; font-size: 11px; color: var(--text-dim); }
|
||
.stats-legend i { display: inline-block; width: 10px; height: 3px; border-radius: 2px; margin-right: 5px; vertical-align: middle; }
|
||
.stats-axis { margin: 0 0 18px; }
|
||
.stats-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 18px 26px; }
|
||
.stats-h { margin: 0 0 8px; font-size: 11px; font-weight: 600; letter-spacing: 0.08em; color: var(--text-dim); }
|
||
.stats-list { display: flex; flex-direction: column; gap: 4px; margin: 0; padding: 0; list-style: none; }
|
||
.stats-row { display: grid; grid-template-columns: minmax(80px, 34%) 1fr auto; align-items: center; gap: 8px; font-size: 12px; }
|
||
.stats-key { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text-dim); }
|
||
.stats-track { height: 8px; border-radius: 4px; background: var(--bg-sunken); overflow: hidden; }
|
||
.stats-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); }
|
||
.stats-n { font-family: var(--mono); font-variant-numeric: tabular-nums; }
|
||
|
||
/* --- studio sidebar (DevelopPanels) ------------------------------------- */
|
||
/* Lightroom's right column: every panel's knobs are on screen at once, each
|
||
panel a disclosure that folds its own rows away. Colour comes from the tokens
|
||
only, so a theme or accent change lands here with no rules of its own. */
|
||
/* The sidebar (DevelopPanels) draws rows, not chips, so its column is the wide
|
||
one: the same width CREATE RECIPES takes, and the chips that are not knobs
|
||
(AUTO, TONE CURVE) stack over it, wrapped in the column's own .chip-row. */
|
||
.col-dev { width: 320px; }
|
||
.dev-panels { display: flex; flex-direction: column; gap: 10px; }
|
||
.dev-panel { border-top: 1px solid var(--border-soft); padding-top: 8px; }
|
||
.dev-panel-head {
|
||
display: flex; justify-content: space-between; align-items: center;
|
||
width: 100%; padding: 0; border: 0; background: transparent;
|
||
font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase;
|
||
color: var(--text); cursor: pointer;
|
||
}
|
||
.dev-panel-head:hover { color: var(--accent); }
|
||
/* The caret is the panel's whole affordance — the head is a line of uppercase
|
||
and says nothing about being a control — so it is drawn larger than the label
|
||
it sits beside, and takes the accent while the pointer is on the head. */
|
||
.dev-caret { font-size: 17px; line-height: 1; opacity: 0.75; }
|
||
.dev-panel-head:hover .dev-caret { opacity: 1; }
|
||
.dev-panel-body { display: flex; flex-direction: column; gap: 10px; padding-top: 8px; }
|
||
/* A pick that is a set of its own rather than a row of the panel's: framed and
|
||
named, so the four stops of D.RANGE read as one setting and not as four more
|
||
rows of DETAIL & EFFECTS. No colour rules: the frame is the token's. */
|
||
.dev-box { border: 1px solid var(--border-soft); border-radius: var(--radius-sm); padding: 8px; }
|
||
.dev-box-title {
|
||
display: block; margin-bottom: 6px;
|
||
font-size: 10px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--text-dim);
|
||
}
|
||
/* A row that cannot be edited at all: a PRO knob on the LITE build. It reads as
|
||
a row, not as a button — the tag is what says the row is not a slider. */
|
||
.dev-row { display: flex; flex-direction: column; gap: 4px; }
|
||
.dev-row.locked {
|
||
flex-direction: row; align-items: center; gap: 6px;
|
||
width: 100%; padding: 0; border: 0; background: transparent;
|
||
text-align: left; opacity: 0.5; cursor: not-allowed;
|
||
}
|
||
.dev-pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
|
||
/* WB's two knobs: the track is the hue the value moves towards, the way
|
||
Lightroom's temperature and tint sliders are painted — the same line as
|
||
everywhere else, only coloured, and a shade thicker so the cast it names can
|
||
be read. The node is the app's own ring and is left to the rule above: the
|
||
line says where the value is, the ring says where the knob is. The colour sits
|
||
on the track and not on the input (where it used to be drawn) because the
|
||
line is what is read — a background on the box would have shown through
|
||
around the hairline every other knob is drawn with. */
|
||
.track-temp::-webkit-slider-runnable-track { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); height: 4px; }
|
||
.track-tint::-webkit-slider-runnable-track { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); height: 4px; }
|
||
.track-temp::-moz-range-track { background: linear-gradient(90deg, #3f6fd8, #b9c2cc 50%, #e8a33d); height: 4px; }
|
||
.track-tint::-moz-range-track { background: linear-gradient(90deg, #3f9d55, #b9c2cc 50%, #c04ec4); height: 4px; }
|
||
/* The node is centred on a track twice the hairline's height, so it shifts up
|
||
the one pixel the base rule does not account for. */
|
||
.track-temp::-webkit-slider-thumb, .track-tint::-webkit-slider-thumb { margin-top: -3px; }
|
||
|
||
/* --- responsive --------------------------------------------------------- */
|
||
@media (max-width: 860px) {
|
||
.workspace { flex-direction: column; }
|
||
/* The frame's left column becomes a wrapping row above the pane. */
|
||
.adm-frame { flex-direction: column; }
|
||
.adm-side { flex: 0 0 auto; flex-direction: row; flex-wrap: wrap; border-right: 0; border-bottom: 1px solid var(--border); }
|
||
/* The header wraps on narrow screens, so a menu-wrap can land at the start of
|
||
a row — the popover is right-anchored to its button and would hang off the
|
||
left of the screen (measured: x=-280 of 390px). Anchor it to the full-width
|
||
header instead. */
|
||
/* The bar stays at the top of the screen: the page itself never scrolls (the
|
||
stage does), but the declaration is what makes that a promise rather than a
|
||
coincidence. Sticky, not fixed — a positioned box still anchors the menus. */
|
||
.header { position: sticky; top: 0; z-index: 5; }
|
||
.menu-wrap { position: static; }
|
||
.popover { right: 8px; max-width: calc(100vw - 16px); }
|
||
/* Four labelled buttons — LIBRARY, RESET, SAVE PHOTO, EXPORT — are most of what
|
||
wraps the bar onto a third row on a 390px screen: the row's own content is
|
||
990px wide with the words and 716px with icons, and the bar measured 135px
|
||
tall before this, 88px after. They wear icons instead. Font-size 0 hides the
|
||
text from the paint, not from the DOM: the accessible name each button
|
||
already had is the word that is no longer drawn. */
|
||
.header [data-key="nav-library"],
|
||
.header [data-key="reset-look"],
|
||
.header [data-key="save-photo"],
|
||
.header [data-key="export-photo"] {
|
||
width: 34px;
|
||
height: 30px;
|
||
padding: 0;
|
||
flex: 0 0 auto;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0;
|
||
}
|
||
.header [data-key="nav-library"]::before,
|
||
.header [data-key="reset-look"]::before,
|
||
.header [data-key="save-photo"]::before,
|
||
.header [data-key="export-photo"]::before { font-size: 15px; line-height: 1; }
|
||
/* The count is the label it was hiding in, so the PRO marker goes with it —
|
||
the badge would be a 9px word in a 34px button. */
|
||
.header [data-key="save-photo"] .pro { display: none; }
|
||
.header [data-key="nav-library"]::before { content: '▤'; }
|
||
.header [data-key="reset-look"]::before { content: '⟲'; }
|
||
.header [data-key="save-photo"]::before { content: '⤓'; }
|
||
.header [data-key="export-photo"]::before { content: '↗'; }
|
||
/* --- the phone's workspace ---------------------------------------------
|
||
One strip at a time in the bottom bar: the rail of tabs, or — once a tab
|
||
is picked — that tab's own chips standing in the rail's place (the bar
|
||
never grows a second row, see .strip-open). What sits above the bar is
|
||
built out of the columns themselves, so .chips stops being a box and its
|
||
columns stand in the workspace's own stack, ordered bottom-up: the tab's
|
||
chips in the bar, the open chip's sub-chips over them, the open ruler over
|
||
those, and the photo taking all that is left. */
|
||
.chips { display: contents; }
|
||
.workspace:not(.strip-open) .chips { display: none; }
|
||
.workspace.strip-open .rail { display: none; }
|
||
.stage-slot { order: 0; min-height: 0; }
|
||
.col[data-col="slider"] { order: 1; }
|
||
.col-sub { order: 2; }
|
||
.col-main { order: 3; }
|
||
.rail { order: 4; }
|
||
|
||
/* The rail takes the shape the Android app gives it: one scrolling row of
|
||
text pills, no glyph, uppercase mono, the open tab amber. It is the bar at
|
||
the foot of the screen, so it carries the home-indicator inset itself. */
|
||
.rail {
|
||
width: auto;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 10px;
|
||
border-right: none;
|
||
border-top: 1px solid var(--border-soft);
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
padding: 8px 10px calc(8px + env(safe-area-inset-bottom));
|
||
scrollbar-width: none;
|
||
}
|
||
.rail::-webkit-scrollbar { display: none; }
|
||
/* The column is a row here, and the row is the tabs: a build name at its foot
|
||
would sit past the end of a line that already scrolls. It goes. */
|
||
.rail-version { display: none; }
|
||
/* A pill the thumb can actually hit: 10px/8x12 rendered ~28px tall, under half
|
||
a finger. The floor is now a 44px target and the label steps up with it. */
|
||
.rail button {
|
||
flex: 0 0 auto;
|
||
flex-direction: row;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-height: 44px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
background: var(--bg-sunken);
|
||
padding: 0 16px;
|
||
font-family: var(--mono);
|
||
font-weight: 700;
|
||
letter-spacing: 0.1em;
|
||
font-size: 12px;
|
||
text-transform: uppercase;
|
||
}
|
||
.rail button .glyph { display: none; }
|
||
.rail button[aria-pressed="true"] { background: var(--accent-soft); border-color: var(--accent-line); }
|
||
|
||
.col {
|
||
width: auto;
|
||
padding: 8px;
|
||
gap: 6px;
|
||
overflow: visible;
|
||
border-right: none;
|
||
}
|
||
/* One chip per line is the desktop column's shape; a phone wants the row. */
|
||
.col .chip-row { flex-direction: row; align-items: center; }
|
||
.col .chip { text-align: center; }
|
||
.col-slider, .col-create { width: auto; }
|
||
|
||
/* --- the tab's own chips: the bottom bar -------------------------------- */
|
||
/* A strip reads across, never down: what does not fit scrolls sideways, so
|
||
the bar keeps exactly one chip's height whatever the tab holds. */
|
||
.col-main {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 6px;
|
||
max-height: 46vh;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
background: var(--bg);
|
||
border-top: 1px solid var(--border-soft);
|
||
/* No inset here any more: the path under the strip (.crumb) is the last
|
||
line of the phone's stack now, so it carries the home indicator. */
|
||
padding: 6px 8px;
|
||
}
|
||
/* The strip is as wide as its chips and the bar scrolls: a row that was
|
||
allowed to shrink would squeeze under its own contents and the chips would
|
||
paint over the ones beside them. */
|
||
.col-main > *, .col-sub:not(.wm-extra) > * { flex: 0 0 auto; }
|
||
.col-main:not(.col-create) .chip-row { flex-wrap: nowrap; }
|
||
.col-main .chip { flex: 0 0 auto; }
|
||
/* The rule a strip is read across runs the other way here: in a row of chips
|
||
it divides the line, it does not break it. */
|
||
.col-main .chip-rule { flex: 0 0 auto; width: 1px; height: 18px; margin: 0 3px; }
|
||
/* RESET is ruled off the tab's chips at the foot of a desktop column; in the
|
||
bar the rule moves to their side. The topbar's RESET already covers every
|
||
other tab (it and this row call the same reset), so the row only survives
|
||
where the button does something the topbar's does not: CREATE's form. */
|
||
.reset-row { display: none; }
|
||
.col-create .reset-row { display: block; margin: 0; padding: 0 0 0 8px; border-top: none; border-left: 1px solid var(--border); }
|
||
/* CREATE's form is not a strip of chips: it keeps the column's shape and
|
||
scrolls down instead of across, in the bar's place. */
|
||
.col-main.col-create {
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
overflow-x: hidden;
|
||
overflow-y: auto;
|
||
}
|
||
/* LIGHT is a strip of chips like any other tab, and its chips are its four
|
||
panels — so the widths of the panels' heads come off and the row stands in
|
||
the wrapper's place, exactly as .chips itself stands in its columns'. */
|
||
.col-dev .dev-chips { display: contents; }
|
||
.col-dev .dev-panels { display: none; }
|
||
|
||
/* --- the way back up: the path under the strip -------------------------- */
|
||
/* TABS > LIGHT > WB. The bar is as deep as the visitor walked, so the bar's
|
||
own chips cannot say it; the path can, and each name in it returns to the
|
||
strip it names. It is the last line of the stack, so it, not the strip,
|
||
carries the home indicator. */
|
||
.crumb {
|
||
order: 5;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
overflow-x: auto;
|
||
padding: 0 10px calc(6px + env(safe-area-inset-bottom));
|
||
background: var(--bg);
|
||
border-top: 1px solid var(--border-soft);
|
||
font-family: var(--mono);
|
||
font-size: 10px;
|
||
letter-spacing: 0.08em;
|
||
text-transform: uppercase;
|
||
color: var(--text-dim);
|
||
}
|
||
.crumb::-webkit-scrollbar { display: none; }
|
||
.crumb-part { display: inline-flex; align-items: center; gap: 6px; flex: 0 0 auto; }
|
||
.crumb-sep { opacity: 0.5; }
|
||
/* A name in the path is a button wherever a strip answers to it, and plain
|
||
where the visitor already stands. */
|
||
.crumb-btn, .crumb-here { padding: 6px 2px; }
|
||
.crumb-btn {
|
||
border: 0;
|
||
background: none;
|
||
font: inherit;
|
||
letter-spacing: inherit;
|
||
text-transform: inherit;
|
||
color: var(--text-dim);
|
||
cursor: pointer;
|
||
}
|
||
.crumb-btn:hover { color: var(--text); }
|
||
.crumb-here { color: var(--accent); }
|
||
|
||
/* --- the strip a chip opens takes the bar -------------------------------- */
|
||
/* One strip at a time, as on the rail: a chip that opens a strip of its own
|
||
takes the bar with it, and the tab's own chips stand down. The way back to
|
||
them is the path under the bar (.crumb), which names the strip they are —
|
||
so a second row of chips over the first would only repeat where the finger
|
||
already is, and charge the photo a row for it while a knob is being moved.
|
||
Only the columns that ARE a strip a chip opened displace the bar: the
|
||
option groups, the watermark, and RECIPES. Each of those is what a chip of
|
||
theirs opened, so the bar would draw the level the finger just asked for
|
||
twice — once as the chip that opened it, once as the strip itself — and the
|
||
second is the one the finger is reaching into. A photo's history and the
|
||
mask's own column are not that: they are an open chip's content, not the
|
||
strip its chip opened, and they keep the bar in sight under them. The
|
||
displacing three wear the bar's slot and the bar's own skin, not the
|
||
sub-column's — this row IS the bar now. */
|
||
.col-sub[data-col="options"], .col-sub[data-col="wm"], .col-sub[data-col="recipes"] {
|
||
order: 3;
|
||
background: var(--bg);
|
||
max-height: 46vh;
|
||
}
|
||
/* A watermark's column is not a row of chips: it keeps the column's shape in
|
||
the slot, with the bar's rule over it and the same ceiling. */
|
||
.col-sub[data-col="wm"] { border-top: 1px solid var(--border-soft); overflow-y: auto; }
|
||
.workspace:has(.chips > .col-sub[data-col="options"]) .col-main,
|
||
.workspace:has(.chips > .col-sub[data-col="wm"]) .col-main,
|
||
.workspace:has(.chips > .col-sub[data-col="recipes"]) .col-main { display: none; }
|
||
|
||
/* One strip at a time holds here too: the ruler a knob opens is the last
|
||
strip of the walk, so it takes the place of the strip that opened it. The
|
||
panel it came from stands down rather than sitting under the ruler it
|
||
launched — the path under the bar (.crumb) already names that panel, so
|
||
the row the panel would hold is a row of the photo spent on a name the
|
||
finger can read below. With the strip and the bar both gone the ruler is
|
||
the only thing left between the photo and the path, which is the slot. */
|
||
.workspace:has(.col[data-col="slider"]) .col-sub[data-col="options"],
|
||
.workspace:has(.col[data-col="slider"]) .col-main { display: none; }
|
||
|
||
/* --- a chip's own sub-chips: the pills the strip is drawn as ------------- */
|
||
.col-sub:not(.wm-extra) {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 6px;
|
||
padding: 6px 8px;
|
||
border-top: 1px solid var(--border-soft);
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
}
|
||
.col-sub:not(.wm-extra) .chip-row { flex-wrap: nowrap; }
|
||
/* The strip's own name is a heading, not a pick: it keeps the leading edge of
|
||
the line and drops the button around it. */
|
||
.col-sub .chip[data-key^="hint-"] {
|
||
width: auto;
|
||
height: auto;
|
||
padding: 0 4px 0 0;
|
||
border: 0;
|
||
background: none;
|
||
opacity: 1;
|
||
font-family: var(--mono);
|
||
font-size: 9px;
|
||
letter-spacing: 0.1em;
|
||
color: var(--text-dim);
|
||
}
|
||
|
||
/* --- the open ruler: the strip's own slot ------------------------------ */
|
||
/* A ruler is the photo's own scale, not a panel: no box, no fill, just the
|
||
ticks. They are drawn in the theme's own ink — dark on the light theme,
|
||
light on the dark — at two weights, a finer mark every eighth of the track
|
||
and a heavier one every fourth. */
|
||
.col[data-col="slider"] {
|
||
width: auto;
|
||
padding: 2px 8px 6px;
|
||
background: none;
|
||
border: 0;
|
||
}
|
||
.col[data-col="slider"] .slider-row {
|
||
flex-direction: row;
|
||
align-items: center;
|
||
gap: 8px;
|
||
padding: 0;
|
||
border: 0;
|
||
background: none;
|
||
}
|
||
/* The way back is the path under the bar (.crumb), which names the panel the
|
||
ruler came from — the strip this "<" would reopen, one row lower and with
|
||
the rest of the walk beside it. A wide screen draws no path, so the button
|
||
is the phone's alone to drop. */
|
||
.col[data-col="slider"] .back { display: none; }
|
||
.col[data-col="slider"] input[type="range"] {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
/* A finger is wider than the ruler. The strip the ticks are drawn on is
|
||
22px tall, and a thumb that lands a dozen pixels off its centre is on the
|
||
photo above it or the row below — the drag never begins. The input's own
|
||
box is the target, so it grows to the finger; the track inside it keeps
|
||
the 22px the ruler is read at, because the pseudo-elements below set that
|
||
height themselves. touch-action none because the drag belongs to the
|
||
knob: nothing here may be taken for a scroll, a pull-to-refresh or a
|
||
URL-bar slide instead, and this row sits where those begin. */
|
||
height: 44px;
|
||
background: none;
|
||
touch-action: none;
|
||
}
|
||
/* One rule per engine: a grouped selector that names a pseudo-element the
|
||
engine does not know — `-moz-range-track` in Chromium — is dropped whole,
|
||
and the ticks went with it (measured: no track at all). */
|
||
.col[data-col="slider"] input[type="range"]::-webkit-slider-runnable-track {
|
||
height: 22px;
|
||
background-color: transparent;
|
||
background-image:
|
||
repeating-linear-gradient(90deg, var(--tick-strong) 0 1px, transparent 1px 32px),
|
||
repeating-linear-gradient(90deg, var(--tick) 0 1px, transparent 1px 8px);
|
||
}
|
||
.col[data-col="slider"] input[type="range"]::-moz-range-track {
|
||
height: 22px;
|
||
background-color: transparent;
|
||
background-image:
|
||
repeating-linear-gradient(90deg, var(--tick-strong) 0 1px, transparent 1px 32px),
|
||
repeating-linear-gradient(90deg, var(--tick) 0 1px, transparent 1px 8px);
|
||
}
|
||
.col[data-col="slider"] input[type="range"]::-webkit-slider-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 3px;
|
||
height: 22px;
|
||
border: 0;
|
||
border-radius: 1px;
|
||
background: var(--accent);
|
||
}
|
||
.col[data-col="slider"] input[type="range"]::-moz-range-thumb {
|
||
-webkit-appearance: none;
|
||
appearance: none;
|
||
width: 3px;
|
||
height: 22px;
|
||
border: 0;
|
||
border-radius: 1px;
|
||
background: var(--accent);
|
||
}
|
||
/* The row under the photo wears its icons on a phone. Six labelled buttons wrap
|
||
to three lines (measured 141px, 17% of an 844px screen) and take the height
|
||
the picture is trying to use; the labels are what wraps, so the labels go —
|
||
out of the paint, not out of the button, which keeps the accessible name it
|
||
always had. The hint at the end of the row follows them out: it is a
|
||
sentence, and a sentence was most of the wrapping. */
|
||
.stage .toolbar { gap: 5px; }
|
||
.stage .toolbar .btn {
|
||
width: 34px;
|
||
height: 30px;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0;
|
||
}
|
||
.stage .toolbar .btn::before { font-size: 14px; line-height: 1; }
|
||
.stage .toolbar .btn .pro { display: none; }
|
||
.stage .toolbar .hint:not(.dims) { display: none; }
|
||
.stage .toolbar [data-key="clear-photo"]::before { content: '✕'; }
|
||
.stage .toolbar [data-key="open-photo"]::before { content: '↻'; }
|
||
.stage .toolbar [data-key="open-camera"]::before { content: '◉'; }
|
||
.stage .toolbar [data-key="save-original"]::before { content: '⤓'; }
|
||
.stage .toolbar [data-key="compare-toggle"]::before { content: '◐'; }
|
||
.stage .toolbar [data-key="hist-toggle"]::before { content: '▥'; }
|
||
/* The crop frame's four corner nodes. The square stays 16px — that is the size
|
||
a corner reads at — but a finger is ~44, so the touch target grows inward
|
||
from the node's own centre: the half that would grow outward is the photo's
|
||
own edge, which the layer clips anyway (measured before this: a node was
|
||
9x16px to a touch, and a thumb a few pixels off it landed on the rect and
|
||
MOVED the frame instead of resizing it). The drag maths reads the pointer
|
||
against the photo, not against the node, so a larger target does not shift
|
||
the corner. */
|
||
.crop-handle::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 44px;
|
||
height: 44px;
|
||
transform: translate(-50%, -50%);
|
||
}
|
||
}
|
||
|
||
/* --- the phone's moderation page -----------------------------------------
|
||
Portrait, for /admin only (`.adm:not(.lib-root)`): one column, every control
|
||
full width, and nothing that asks for a pointer. The bar keeps its mark and
|
||
its name and wears its two commands as 44px icons — a finger's target, the
|
||
floor the workspace's own bars use; font-size 0 takes the word out of the
|
||
paint, not out of the button, so the accessible name and the busy state stand.
|
||
The section menu becomes the swipeable row of pills the studio's rail is, and
|
||
the shelf of albums stands over the frame it opens instead of beside it. */
|
||
@media (max-width: 860px) {
|
||
.adm:not(.lib-root) { padding: 0 12px 32px; }
|
||
.adm:not(.lib-root) .adm-bar {
|
||
flex-wrap: nowrap;
|
||
gap: 8px;
|
||
margin: 0 -12px 12px;
|
||
padding: 8px 12px;
|
||
min-height: 58px;
|
||
}
|
||
/* The count is the first thing to go: every album lists its own. */
|
||
.adm:not(.lib-root) .adm-bar .hint { display: none; }
|
||
/* The word goes, the mark stays — it is still the way home, and the name is
|
||
the title's job on this row. */
|
||
.adm:not(.lib-root) .adm-brand span { display: none; }
|
||
.adm:not(.lib-root) .adm-title {
|
||
flex: 1 1 auto;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
white-space: nowrap;
|
||
text-overflow: ellipsis;
|
||
}
|
||
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"],
|
||
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"] {
|
||
flex: 0 0 auto;
|
||
width: 44px;
|
||
height: 44px;
|
||
padding: 0;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
font-size: 0;
|
||
}
|
||
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"]::before,
|
||
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"]::before { font-size: 20px; line-height: 1; }
|
||
.adm:not(.lib-root) .adm-bar [data-key="adm-upload"]::before { content: '⤒'; }
|
||
.adm:not(.lib-root) .adm-bar [data-key="adm-clear"]::before { content: '✕'; }
|
||
|
||
/* The section menu: one row of pills that scrolls, the studio rail's shape. */
|
||
.adm:not(.lib-root) .adm-side {
|
||
flex-direction: row;
|
||
flex-wrap: nowrap;
|
||
gap: 6px;
|
||
padding: 8px 12px;
|
||
overflow-x: auto;
|
||
overflow-y: hidden;
|
||
scrollbar-width: none;
|
||
}
|
||
.adm:not(.lib-root) .adm-side::-webkit-scrollbar { display: none; }
|
||
.adm:not(.lib-root) .adm-side-title { display: none; }
|
||
.adm:not(.lib-root) .adm-side-item {
|
||
flex: 0 0 auto;
|
||
min-height: 44px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
padding: 0 16px;
|
||
border: 1px solid var(--border);
|
||
border-radius: 999px;
|
||
font-size: 12px;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
}
|
||
.adm:not(.lib-root) .adm-side-item.on { border-color: var(--accent); }
|
||
|
||
.adm:not(.lib-root) .adm-pane { padding: 12px; }
|
||
|
||
/* Portrait: the albums stand over the frame they open, and the shelf stops
|
||
pretending to be a sidebar — it scrolls with the page. */
|
||
.adm:not(.lib-root) .adm-split { grid-template-columns: minmax(0, 1fr); }
|
||
.adm:not(.lib-root) .adm-album-list { max-height: 40vh; }
|
||
.adm:not(.lib-root) .adm-album { min-height: 44px; }
|
||
|
||
/* A destination chip is a control before it is a label. */
|
||
.adm:not(.lib-root) .adm-choice { min-height: 44px; padding: 8px 12px; }
|
||
/* The strip's tiles: two and a bit to a screen, so a thumb is worth aiming at. */
|
||
.adm:not(.lib-root) .adm-thumb { flex: 0 0 152px; }
|
||
.adm:not(.lib-root) .adm-bulk { flex-wrap: wrap; }
|
||
.adm:not(.lib-root) .adm-bulk .btn { min-height: 44px; }
|
||
/* The accounts table is wider than a phone, so it scrolls as one piece; the
|
||
rows grow to a thumb. */
|
||
.adm:not(.lib-root) .adm-table-wrap { -webkit-overflow-scrolling: touch; }
|
||
.adm:not(.lib-root) .adm-table th,
|
||
.adm:not(.lib-root) .adm-table td { padding: 9px 10px; }
|
||
.adm:not(.lib-root) .adm-table .btn { min-height: 40px; }
|
||
}
|
||
|
||
.lib-folder-highlight {
|
||
background: var(--accent-soft, rgba(245, 158, 11, 0.25)) !important;
|
||
outline: 2px solid var(--accent, #f59e0b) !important;
|
||
border-radius: var(--radius-sm, 4px);
|
||
transition: background 0.3s ease, outline 0.3s ease;
|
||
}
|