/* 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); } /* Three things that belong to the phone alone: the mark a sub-chip wears on its tile, the path back up under the strip, and LIGHT's panels as a chip row. A desktop draws none of them — a chip there is its label, every column is in sight, and LIGHT's panels are the column itself. */ .chip-icon, .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 .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; } 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; } /* 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
, 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 .toolbar { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; justify-content: center; } /* The photo's own size, first in that row: monospaced so the digits do not shuffle the buttons sideways while a crop is being dragged. */ .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