c0c99a9672
The server still never sees a photo, so the model has to run in the page. Real-ESRGAN x4v3 ships as a 4.9MB ONNX in public/models and is loaded lazily on the first export that actually needs it; the wasm runtime is copied next to CanvasKit at build time and stays lazily fetched, cached for 30 days. Vite is told onnxruntime-web is external-wasm so no 28MB asset lands in the bundle. UNCHANGED keeps the old path and the tier cap; 2K/4K/custom upscale only when the request is larger than the photo being edited, otherwise they resize down. Guests keep UNCHANGED and 2K. Tiling is 256px with an 8px overlap, so memory follows the target size rather than four times it.
853 lines
35 KiB
CSS
853 lines
35 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; }
|
|
.recipe-name {
|
|
font-family: var(--mono);
|
|
font-size: 12px;
|
|
color: var(--text-dim);
|
|
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); }
|
|
|
|
/* --- 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); }
|
|
.chip-row { display: flex; flex-wrap: wrap; gap: 6px; }
|
|
/* 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; }
|
|
/* 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; }
|
|
.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); }
|
|
/* 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 .pro { font-size: 9px; letter-spacing: 0.08em; color: var(--accent); }
|
|
.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; }
|
|
.slider-row .head { display: flex; justify-content: space-between; font-size: 11px; letter-spacing: 0.1em; color: var(--text-dim); }
|
|
.slider-row .head b { 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;
|
|
}
|
|
input[type="range"] { width: 100%; accent-color: var(--accent); }
|
|
.hint { color: var(--text-dim); font-size: 12px; line-height: 1.5; }
|
|
.hint.ok { color: var(--accent); }
|
|
|
|
/* --- 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; }
|
|
|
|
/* --- 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; }
|
|
.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; }
|
|
/* 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;
|
|
}
|
|
/* 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;
|
|
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; justify-content: space-between; font-size: 10px; letter-spacing: 0.08em; color: var(--text-dim); }
|
|
.mini-head b { font-family: var(--mono); letter-spacing: 0; color: var(--text); }
|
|
.mini-head b.amber { color: var(--accent); }
|
|
/* 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: color-mix(in srgb, var(--bg-elev) 90%, transparent);
|
|
backdrop-filter: blur(6px);
|
|
box-shadow: var(--shadow);
|
|
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); }
|
|
.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; }
|
|
.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; 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 .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; }
|
|
.err { color: var(--danger); font-size: 12px; max-width: 520px; text-align: center; }
|
|
|
|
/* --- 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; }
|
|
|
|
/* --- 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; }
|
|
.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 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 img { width: 100%; aspect-ratio: 4 / 3; object-fit: cover; border-radius: var(--radius-sm); background: var(--bg-sunken); }
|
|
.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); }
|
|
|
|
/* 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; }
|
|
.adm-table-wrap { overflow-x: auto; }
|
|
/* 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; }
|
|
|
|
/* --- 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. */
|
|
.header { position: relative; }
|
|
.menu-wrap { position: static; }
|
|
.popover { right: 8px; max-width: calc(100vw - 16px); }
|
|
/* The rail takes the shape the Android app gives it: one scrolling row of
|
|
text pills, no glyph, uppercase mono, the open tab amber and a step larger.
|
|
Android's own rail is exactly this (src/components/ToolRail.tsx). */
|
|
.rail {
|
|
order: 2;
|
|
width: auto;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
gap: 8px;
|
|
border-right: none;
|
|
border-top: 1px solid var(--border-soft);
|
|
overflow-x: auto;
|
|
overflow-y: hidden;
|
|
padding: 6px 8px;
|
|
scrollbar-width: none;
|
|
}
|
|
.rail::-webkit-scrollbar { display: none; }
|
|
.rail button {
|
|
flex: 0 0 auto;
|
|
flex-direction: row;
|
|
align-items: center;
|
|
border: 1px solid var(--border);
|
|
border-radius: 999px;
|
|
background: var(--bg-sunken);
|
|
padding: 8px 12px;
|
|
font-family: var(--mono);
|
|
font-weight: 700;
|
|
letter-spacing: 0.1em;
|
|
font-size: 10px;
|
|
text-transform: uppercase;
|
|
}
|
|
.rail button .glyph { display: none; }
|
|
.rail button[aria-pressed="true"] { font-size: 13px; }
|
|
/* The panel takes the phone's own shape: the columns stack into one scroll,
|
|
and inside each the chips run as a row again — the phone's chip strip is a
|
|
line of pills, never a ladder. What was a left-to-right cascade now reads
|
|
top to bottom, which is the same order the Android panel shows. */
|
|
.chips {
|
|
order: 1;
|
|
flex-direction: column;
|
|
width: auto;
|
|
max-width: none;
|
|
max-height: 42vh;
|
|
overflow-x: hidden;
|
|
overflow-y: auto;
|
|
border-right: none;
|
|
border-bottom: 1px solid var(--border-soft);
|
|
}
|
|
.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; }
|
|
.stage { order: 0; flex: 1 1 auto; }
|
|
}
|