diff --git a/static/css/13-image-editor.css b/static/css/13-image-editor.css index 257e2805e..fcde25e05 100644 --- a/static/css/13-image-editor.css +++ b/static/css/13-image-editor.css @@ -103,6 +103,407 @@ margin: 0 4px; } +/* Contextual tool options bar (between topbar and editor body) */ +.ge-options-bar { + display: flex; + align-items: center; + gap: 10px; + flex: 0 0 auto; + flex-wrap: nowrap; + row-gap: 4px; + min-height: 40px; + padding: 4px 10px; + box-sizing: border-box; + border-bottom: 1px solid var(--border); + background: color-mix(in srgb, var(--panel) 92%, var(--fg) 8%); + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: thin; +} +.ge-options-tool { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + font-size: 11px; + font-weight: 600; + color: color-mix(in srgb, var(--fg) 85%, transparent); +} +.ge-options-tool-icon { + display: inline-flex; + width: 18px; + height: 18px; + align-items: center; + justify-content: center; +} +.ge-options-sep { + width: 1px; + height: 20px; + background: var(--border); + flex: 0 0 auto; +} +.ge-options-body { + display: flex; + align-items: center; + gap: 14px; + flex: 1 1 auto; + min-width: 0; +} +/* Tool options moved out of the dock into the options bar (Photoshop): + the real controls flow inline on the SAME single thin row, horizontally + scrollable when they do not fit. Section headings/hints are dropped in + this compact strip; the authoritative sliders mirror the contextual + strip, so duplicated ranges are hidden. */ +.ge-options-bar .ge-controls.ge-controls--in-options { + flex: 0 1 auto; + flex-direction: row; + flex-wrap: nowrap; + align-items: center; + gap: 14px; + padding: 0; + margin: 0; + border: 0; + min-width: 0; + max-height: none; + overflow: visible; +} +.ge-controls--in-options .ge-section-title, +.ge-controls--in-options .ge-section-hint { display: none; } +.ge-controls--in-options > section, +.ge-controls--in-options > div { flex: 0 0 auto; } +/* Lay out every visible tool section as one horizontal strip. Hidden + sections carry an inline display:none and are left untouched; visible + ones must override their inline display:block, hence !important. */ +.ge-controls--in-options > div:not([style*="display:none"]):not([style*="display: none"]) { + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + align-items: center; + gap: 12px; +} +.ge-controls--in-options .ge-control-row { + margin: 0; + flex: 0 0 auto; + display: flex; + flex-direction: row; + align-items: center; + gap: 6px; +} +.ge-controls--in-options .ge-control-row label { white-space: nowrap; } +/* The Text section is authored as a vertical panel. When it is relocated into + the options bar its grids collapse to zero width, so the Font picker shrinks + to ~14px and the Font/Size labels pile into "FoSize". Lay the text strip out + as fixed-width field groups so the font picker keeps its room. */ +.ge-controls--in-options .ge-text-section .ge-text-grid { + display: flex; + flex-direction: row; + flex-wrap: nowrap; + align-items: flex-end; + gap: 8px; + flex: 0 0 auto; +} +.ge-controls--in-options .ge-text-section .ge-text-field { + flex: 0 0 auto; + min-width: 56px; + align-items: stretch; +} +.ge-controls--in-options .ge-text-section .ge-text-field > span { white-space: nowrap; } +.ge-controls--in-options .ge-text-section .ge-text-font-field { min-width: 116px; } +.ge-controls--in-options .ge-text-section .ge-text-size-field { min-width: 56px; } +.ge-controls--in-options .ge-text-section .ge-text-field > select, +.ge-controls--in-options .ge-text-section .ge-text-field > input { width: 100%; } +.ge-controls--in-options .ge-text-section textarea { + flex: 0 0 auto; + width: 200px; + min-height: 0; + height: 40px; + resize: none; +} +.ge-options-bar:has(.ge-controls--in-options) .ge-options-body .ge-opt[data-opt="range"] { display: none; } +/* Secondary brush rows (pen-pressure toggles + preset Save/delete) do not + fit the thin strip; hide them behind the "..." overflow button. */ +.ge-controls--in-options .ge-brush-pressure-row, +.ge-controls--in-options .ge-brush-preset-row { display: none !important; } +.ge-options-bar.ge-more-open { flex-wrap: wrap; } +.ge-options-bar.ge-more-open .ge-controls--in-options .ge-brush-pressure-row, +.ge-options-bar.ge-more-open .ge-controls--in-options .ge-brush-preset-row { + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + align-items: center; + gap: 10px; +} +/* The brush-preset picker stays visible inline whenever a painting tool is + active, so switching brushes does not require discovering the "..." menu. */ +.ge-controls--in-options .ge-has-brush-presets .ge-brush-preset-row { + display: flex !important; + flex-direction: row !important; + flex-wrap: nowrap !important; + align-items: center; + gap: 8px; +} +.ge-options-more { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + color: color-mix(in srgb, var(--fg) 65%, transparent); + background: transparent; + border: 1px solid var(--border); + border-radius: 5px; + cursor: pointer; +} +.ge-options-more:hover { color: var(--fg); border-color: color-mix(in srgb, var(--fg) 35%, transparent); } +.ge-options-more[hidden] { display: none; } +.ge-opt { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; +} +.ge-opt-label { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.04em; + color: color-mix(in srgb, var(--fg) 55%, transparent); + white-space: nowrap; +} +.ge-opt-value { + color: color-mix(in srgb, var(--fg) 80%, transparent); + font-variant-numeric: tabular-nums; +} +.ge-opt[data-opt="range"] input[type="range"] { width: 110px; } +/* Checkbox options (e.g. Move tool Auto-Select) — restyle the native + control so it matches the dark editor chrome instead of rendering as + a light OS checkbox. */ +.ge-opt.ge-auto-select-option { gap: 6px; cursor: pointer; } +.ge-opt input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + margin: 0; + flex: 0 0 auto; + box-sizing: border-box; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg); + color: var(--fg); + cursor: pointer; + position: relative; +} +.ge-opt input[type="checkbox"]:checked { + background: var(--accent, #4c8bf5); + border-color: var(--accent, #4c8bf5); +} +.ge-opt input[type="checkbox"]:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 4px; + height: 8px; + border: solid #fff; + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +.ge-opt input[type="checkbox"]:focus-visible { + outline: 2px solid var(--accent, #4c8bf5); + outline-offset: 1px; +} +/* Dropdown options in the options bar (e.g. Auto-Select Target). */ +.ge-opt[data-opt="select"] select { + height: 24px; + padding: 0 6px; + border: 1px solid var(--border); + border-radius: 5px; + background: var(--bg); + color: var(--fg); + font: inherit; + font-size: 11px; + cursor: pointer; +} +.ge-opt[data-opt="select"] select:focus-visible { + outline: 2px solid var(--accent, #4c8bf5); + outline-offset: 1px; +} +.ge-opt-modes { display: inline-flex; gap: 2px; } +.ge-opt-mode { + min-width: 26px; + height: 24px; + padding: 0 6px; + font-size: 11px; + border: 1px solid var(--border); + background: transparent; + color: var(--fg); + border-radius: 5px; + cursor: pointer; +} +.ge-opt-mode.active { + background: var(--accent, #4c8bf5); + border-color: transparent; + color: #fff; +} +.ge-opt-empty { font-size: 11px; opacity: 0.5; } +.ge-options-empty .ge-options-sep { display: none; } +@media (max-width: 820px) { + .ge-options-bar { display: none; } +} + +/* Document tab strip (Photoshop-style) — names the open document and + offers a close affordance. Sits between the options bar and the body. */ +.ge-doc-tabs { + display: flex; + align-items: center; + gap: 2px; + flex: 0 0 auto; + min-height: 30px; + padding: 4px 8px 0; + box-sizing: border-box; + background: var(--panel); + border-bottom: 1px solid var(--border); + overflow: hidden; +} +.ge-doc-tabs::-webkit-scrollbar { display: none; } +.ge-doc-tab { + display: inline-flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + max-width: 240px; + padding: 5px 6px 5px 12px; + font-size: 11px; + color: color-mix(in srgb, var(--fg) 70%, transparent); + background: color-mix(in srgb, var(--panel) 82%, var(--fg) 6%); + border: 1px solid var(--border); + border-bottom: none; + border-radius: 6px 6px 0 0; +} +.ge-doc-tab.is-active { + background: var(--bg); + color: var(--fg); + border-bottom: 1px solid var(--bg); + margin-bottom: -1px; +} +.ge-doc-tab-label { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 190px; +} +.ge-doc-tab-close { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: inherit; + font-size: 13px; + line-height: 1; + cursor: pointer; + opacity: 0.6; +} +.ge-doc-tab-close:hover { + opacity: 1; + background: color-mix(in srgb, var(--fg) 14%, transparent); +} +/* Title-bar actions: Import + Save and the window controls (_ □ ×). */ +.ge-doc-tabs-actions { + display: flex; + align-items: center; + gap: 6px; + flex: 0 0 auto; + margin-left: auto; +} +/* Import/Save in the title bar render as ONE compact row (glyph + label), + matching the File/Edit/… topbar button height — not the 2-line stack. */ +.ge-doc-tabs-actions .ge-stacked-btn { + flex-direction: row; + height: 26px; + gap: 4px; + padding: 0 9px !important; +} +.ge-doc-tabs-actions .ge-stacked-btn .ge-stacked-glyph { display: inline-flex; } +.ge-doc-tabs-actions .ge-stacked-btn .ge-stacked-label { top: 0; font-size: 10px; opacity: 0.8; } +/* Save shows a SINGLE label (the live "Saved"/"Unsaved" status), not "SAVE Saved". */ +.ge-doc-tabs-actions #ge-save-menu-btn { flex-wrap: nowrap !important; column-gap: 5px; } +.ge-doc-tabs-actions #ge-save-menu-btn .ge-stacked-glyph { flex: 0 0 auto; } +.ge-doc-tabs-actions #ge-save-menu-btn .ge-save-action-label { display: none !important; } +/* Import + Save now live in the topbar row (their old title bar is gone): + one compact row each, matching the File/Edit/… button height. */ +.ge-topbar-right .ge-stacked-btn { + flex-direction: row; + height: 26px; + gap: 4px; + padding: 0 9px !important; +} +.ge-topbar-right .ge-stacked-btn .ge-stacked-glyph { display: inline-flex; } +.ge-topbar-right .ge-stacked-btn .ge-stacked-label { top: 0; font-size: 10px; opacity: 0.85; } +.ge-topbar-right #ge-save-menu-btn { flex-wrap: nowrap !important; column-gap: 5px; } +.ge-topbar-right #ge-save-menu-btn .ge-stacked-glyph { flex: 0 0 auto; } +.ge-topbar-right #ge-save-menu-btn .ge-save-action-label { display: none !important; } +.ge-win-controls { + display: flex; + align-items: center; + gap: 1px; + margin-left: 4px; +} +.ge-win-btn.ge-btn, +.ge-win-controls #ge-maximize-toggle { + width: 24px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + box-shadow: none; +} +.ge-win-btn { + display: inline-flex; + align-items: center; + justify-content: center; + width: 24px; + height: 22px; + padding: 0; + border: none; + border-radius: 4px; + background: transparent; + color: var(--fg); + font-size: 14px; + line-height: 1; + cursor: pointer; + opacity: 0.75; +} +.ge-win-btn:hover, +.ge-win-controls #ge-maximize-toggle:hover { + opacity: 1; + background: color-mix(in srgb, var(--fg) 14%, transparent); +} +.ge-win-close:hover { + background: #e5484d; + color: #fff; +} +.ge-win-controls #ge-maximize-toggle svg { top: 0 !important; } +/* The editor maximize now lives in the gallery modal header, beside the + window's minimize/close buttons (it adopts .modal-minimize-btn so it matches + them exactly). Keep its icon optically centred rather than the minimize + glyph's 2px drop. */ +#gallery-modal .modal-header .ge-header-max-btn svg { top: 0 !important; transform: none; } +@media (max-width: 820px) { + .ge-doc-tabs { min-height: 26px; padding: 3px 6px 0; } + .ge-doc-tab-label { max-width: 140px; } +} + /* Editor body (toolbar + canvas + panel) */ .ge-editor-body { display: flex; @@ -114,7 +515,7 @@ .ge-editor-footer { display: flex; align-items: center; - justify-content: space-between; + justify-content: flex-start; gap: 10px; min-height: 34px; padding: 3px 10px; @@ -125,9 +526,40 @@ } .ge-editor-footer .ge-canvas-size { min-width: 0; + padding-left: 10px; font-size: 11px; + white-space: nowrap; + font-variant-numeric: tabular-nums; color: color-mix(in srgb, var(--fg) 65%, transparent); } +.ge-status-tool { + min-width: 0; + padding-left: 10px; + border-left: 1px solid var(--border); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: color-mix(in srgb, var(--fg) 55%, transparent); +} +.ge-status-model { + min-width: 0; + max-width: 280px; + padding-left: 10px; + border-left: 1px solid var(--border); + font-size: 11px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + color: color-mix(in srgb, var(--fg) 55%, transparent); +} +.ge-footer-history { + display: flex; + align-items: center; + gap: 4px; + margin-right: 8px; +} +.ge-footer-history > .ge-btn { min-width: 28px; min-height: 26px; } .ge-footer-zoom { display: flex; align-items: center; @@ -163,10 +595,10 @@ /* Asymmetric horizontal padding shifts the whole column 4 px left — buttons, hover backgrounds, active highlight pills, and section separators all move together. */ - padding: 8px 8px 8px 0; + padding: 6px 6px 6px 0; background: var(--panel); border-right: 1px solid var(--border); - width: 64px; + width: 50px; flex-shrink: 0; min-height: 0; overflow-x: hidden; @@ -186,6 +618,87 @@ opacity: 0.6; flex-shrink: 0; } +/* Foreground / background color swatches pinned to the bottom of the + tool rail (Photoshop-style), with a swap button. */ +.ge-tool-swatches { + position: sticky; + bottom: 0; + margin-top: auto; + padding: 10px 0 8px; + display: flex; + flex-direction: column; + align-items: center; + gap: 2px; + background: var(--panel); +} +.ge-swatch { + display: block; + width: 26px; + height: 26px; + border: 1px solid var(--border); + border-radius: 999px; + overflow: hidden; + cursor: pointer; + box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5); + background: #fff; +} +.ge-swatch input[type="color"] { + -webkit-appearance: none; + appearance: none; + width: 100%; + height: 100%; + padding: 0; + border: 0; + background: none; + cursor: pointer; +} +/* After attachColorPicker swaps the rail swatch to type=text + .cp-swatch-input, + show just the colour (hide the hex text) and keep it round. */ +.ge-swatch input.cp-swatch-input { + width: 100%; + height: 100%; + padding: 0; + border: 0; + border-radius: 999px; + color: transparent; + caret-color: transparent; + text-shadow: none; + font-size: 0; + outline: none; + cursor: pointer; + background: var(--panel); +} +.ge-swatch input.cp-swatch-input::selection { background: transparent; } +.ge-swatch input[type="color"]::-webkit-color-swatch-wrapper { padding: 0; } +.ge-swatch input[type="color"]::-webkit-color-swatch { border: 0; border-radius: 3px; } +.ge-swatch-fg { + z-index: 2; + margin-left: 8px; + margin-top: -10px; +} +.ge-swatch-bg { margin-right: 8px; } +.ge-swatch-swap { + width: 18px; + height: 15px; + margin-top: 2px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--fg) 55%, transparent); + background: transparent; + border: 0; + cursor: pointer; +} +.ge-swatch-swap:hover { color: var(--fg); } +/* The left-rail fg/bg swatches are ROUND (Photoshop-style). Overrides the + generic `.ge-swatch-grid .ge-swatch` sizing rule further down the sheet. */ +.ge-tool-swatches .ge-swatch { + width: 26px; + height: 26px; + aspect-ratio: auto; + border-radius: 999px; +} /* All tool buttons share a fixed height so the column reads as a clean grid. Long labels (e.g. "Remove BG") truncate with ellipsis instead of wrapping to two lines and making one button taller than the others. */ @@ -194,9 +707,9 @@ flex-direction: column; align-items: center; justify-content: center; - gap: 2px; + gap: 1px; padding: 0 2px; - height: 42px; + height: 34px; flex-shrink: 0; border: none; background: none; @@ -224,8 +737,26 @@ shifted an additional 2 px left so the icon + label sit further inside the highlight pill (which still anchors to the button bounds). */ .ge-tool-btn > * { position: relative; z-index: 1; left: -2px; } -.ge-tool-icon { font-size: 18px; line-height: 1; } -.ge-tool-label { font-size: 10px; line-height: 1.25; } +.ge-tool-icon { font-size: 15px; line-height: 1; } +.ge-tool-icon svg { width: 15px; height: 15px; } +.ge-tool-label { font-size: 9px; line-height: 1.2; } +/* Corner triangle on tool slots that hide a family of tools (Photoshop + mark). Clicking it opens the flyout; the face itself still selects. */ +.ge-tool-flyout-tri { + position: absolute !important; + right: 3px !important; + bottom: 3px !important; + left: auto !important; + width: 0; + height: 0; + border-left: 5px solid transparent; + border-bottom: 5px solid currentColor; + opacity: 0.5; + z-index: 3; + cursor: pointer; +} +.ge-tool-flyout-tri:hover { opacity: 0.9; } +.ge-tool-flyout-label { font-size: 12px; white-space: nowrap; } /* AI badge — same ✦ glyph the Enhance tool uses, pinned to the top- left of any tool button marked `ai: true`. Same color as the icon itself (inherits foreground) so it reads as part of the tool, not @@ -286,6 +817,79 @@ 0 2px 6px color-mix(in srgb, var(--red) 60%, transparent); } +/* Flyout slots — related tools collapse behind a single face button; the + caret (or right-click / long-press) opens the family. The flyout is + detached from the toolbar's scroll clip via position:fixed, with coords + set in toolbar.js on open. */ +.ge-tool-slot { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; +} +.ge-tool-slot > .ge-tool-btn { width: 100%; } +.ge-tool-caret { + position: absolute; + right: 3px; + bottom: 3px; + width: 12px; + height: 12px; + padding: 0; + display: none; + align-items: center; + justify-content: center; + border: none; + border-radius: 3px; + background: color-mix(in srgb, var(--fg) 14%, transparent); + color: var(--fg); + opacity: 0.7; + cursor: pointer; + z-index: 2; +} +.ge-tool-slot:hover > .ge-tool-caret, +.ge-tool-slot.open > .ge-tool-caret { display: inline-flex; } +.ge-tool-caret:hover { opacity: 1; } +.ge-tool-flyout { + position: fixed; + display: none; + flex-direction: column; + gap: 1px; + padding: 4px; + min-width: 132px; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4); + z-index: 200; +} +.ge-tool-slot.open > .ge-tool-flyout { display: flex; } +.ge-tool-flyout .ge-tool-btn { + flex-direction: row; + justify-content: flex-start; + gap: 8px; + height: 34px; + width: 100%; + padding: 0 8px; + opacity: 0.85; + border-radius: 5px; +} +.ge-tool-flyout .ge-tool-btn:hover { opacity: 1; background: color-mix(in srgb, var(--fg) 8%, transparent); } +.ge-tool-flyout .ge-tool-btn.active { opacity: 1; } +.ge-tool-flyout .ge-tool-label { font-size: 12px; white-space: nowrap; } +.ge-tool-flyout .ge-tool-ai { position: static !important; left: auto !important; } +.ge-tool-flyout .ge-tool-btn .ge-tool-clear { display: none !important; } +.ge-tool-flyout-sep { height: 1px; margin: 3px 2px; background: var(--border); opacity: 0.7; } +.ge-tool-flyout-heading { + font-size: 9px; + text-transform: uppercase; + letter-spacing: 0.06em; + opacity: 0.55; + padding: 2px 8px 1px; +} +.ge-tool-flyout-preset { opacity: 0.78; } +.ge-tool-flyout-preset .ge-tool-flyout-label { font-size: 11px; } + /* Aspect-ratio placeholder shown while a draft is loading. */ .ge-canvas-placeholder { background: color-mix(in srgb, var(--fg) 8%, transparent); @@ -359,7 +963,7 @@ .ge-ai-command { position: absolute; left: 50%; - bottom: 14px; + bottom: calc(14px + var(--ge-ai-lift, 0px)); z-index: 18; width: min(430px, calc(100% - 28px)); transform: translateX(-50%); @@ -372,6 +976,12 @@ backdrop-filter: blur(8px); transition: bottom 0.16s ease, opacity 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease; } +/* While the floating Transform box is open it owns the canvas area. The + bottom-docked Quick Edit bar would otherwise sit over the bottom-centre + transform handle and intercept the touch aimed at it. */ +#gallery-editor-container:has(.ge-transform-popup) .ge-ai-command { + display: none; +} .ge-ai-command-toggle { width: 100%; height: 28px; @@ -404,15 +1014,6 @@ font-size: 11px; font-weight: 650; } -.ge-ai-command-scope { - margin-left: 7px; - padding-left: 7px; - border-left: 1px solid var(--border); - color: var(--fg-muted); - font-size: 9px; - line-height: 1; - text-transform: uppercase; -} .ge-ai-command-collapsed { bottom: -16px; width: auto; @@ -430,6 +1031,104 @@ border-color: color-mix(in srgb, var(--accent, var(--red)) 42%, var(--border)); box-shadow: 0 8px 22px color-mix(in srgb, #000 24%, transparent); } +.ge-ai-command-model { + flex: 1 1 auto; + min-width: 0; + height: 26px; + box-sizing: border-box; + padding: 0 6px; + border: 1px solid var(--border); + border-radius: 7px; + background: color-mix(in srgb, var(--bg) 76%, #000 24%); + color: var(--fg); + font: inherit; + font-size: 11px; +} +.ge-ai-command-model:focus { + outline: none; + border-color: var(--accent, var(--red)); +} +/* AI settings dialog (image-edit + agent model pickers, double-check toggle) */ +.ge-ai-settings { + position: absolute; + inset: 0; + z-index: 261; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(2px); +} +.ge-ai-settings[hidden] { display: none; } +.ge-ai-settings-card { + width: min(420px, 100%); + max-height: 100%; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; + padding: 18px; + border: 1px solid var(--border); + border-radius: 12px; + background: var(--panel, #232323); + color: var(--fg); + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); +} +.ge-ai-settings-head { + display: flex; + align-items: center; + justify-content: space-between; +} +.ge-ai-settings-head h3 { margin: 0; font-size: 15px; } +.ge-ai-settings-close { + width: 26px; + height: 26px; + border: none; + border-radius: 7px; + background: transparent; + color: var(--fg-muted); + cursor: pointer; + font-size: 16px; + line-height: 1; +} +.ge-ai-settings-close:hover { background: rgba(255, 255, 255, 0.1); color: var(--fg); } +.ge-ai-settings-body { display: flex; flex-direction: column; gap: 6px; } +.ge-ai-settings-label { + margin-top: 4px; + color: var(--fg-muted); + font-size: 10px; + letter-spacing: 0.04em; + text-transform: uppercase; +} +.ge-ai-settings-body .ge-ai-command-model { flex: 0 0 auto; width: 100%; height: 30px; } +.ge-ai-settings-note { margin: 2px 0 0; color: var(--fg-muted); font-size: 11px; line-height: 1.4; } +.ge-ai-settings-opt { + display: flex; + align-items: center; + gap: 6px; + margin-top: 6px; + color: var(--fg-muted); + font-size: 12px; + cursor: pointer; + user-select: none; +} +.ge-ai-settings-opt input { margin: 0; accent-color: var(--accent, var(--red)); } +.ge-ai-settings-strength { display: flex; align-items: center; gap: 8px; } +.ge-ai-settings-strength input[type="range"] { flex: 1 1 auto; min-width: 0; accent-color: var(--accent, var(--red)); } +.ge-ai-settings-strength-val { flex: 0 0 auto; min-width: 30px; text-align: right; font-size: 11px; color: var(--fg-muted); } +.ge-ai-settings-foot { display: flex; justify-content: flex-end; } +.ge-ai-settings-done { + height: 30px; + padding: 0 14px; + border: none; + border-radius: 8px; + background: var(--accent, var(--red)); + color: #fff; + font: inherit; + font-size: 12px; + cursor: pointer; +} .ge-ai-command-form { display: flex; align-items: center; @@ -517,7 +1216,6 @@ opacity: 0.52; cursor: pointer; padding: 0; - margin-left: auto; } .ge-ai-command-close:hover { opacity: 0.9; @@ -543,7 +1241,7 @@ .ge-ai-command-suggestion { width: 100%; display: grid; - grid-template-columns: 38px minmax(82px, auto) minmax(0, 1fr); + grid-template-columns: 22px 38px minmax(82px, auto) minmax(0, 1fr); align-items: center; gap: 8px; min-height: 28px; @@ -560,6 +1258,23 @@ .ge-ai-command-suggestion.active { background: color-mix(in srgb, var(--accent, var(--red)) 16%, transparent); } +.ge-ai-command-suggestion-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + color: var(--fg-muted); +} +.ge-ai-command-suggestion-icon svg { + width: 15px; + height: 15px; + display: block; +} +.ge-ai-command-suggestion.active .ge-ai-command-suggestion-icon, +.ge-ai-command-suggestion:hover .ge-ai-command-suggestion-icon { + color: var(--fg); +} .ge-ai-command-suggestion-kind { min-width: 0; overflow: hidden; @@ -600,6 +1315,134 @@ .ge-ai-command-status[data-kind="error"] { color: var(--red, #e5484d); } +.ge-ai-command-progress { + position: relative; + height: 14px; + margin-top: 5px; + border-radius: 7px; + background: color-mix(in srgb, var(--accent, var(--red)) 14%, transparent); + overflow: hidden; +} +.ge-ai-command-progress[hidden] { display: none; } +.ge-ai-command-progress-fill { + position: absolute; + inset: 0 auto 0 0; + width: 0%; + border-radius: 7px; + background: linear-gradient(90deg, var(--accent, var(--red)), color-mix(in srgb, var(--accent, var(--red)) 55%, #fff)); + transition: width 0.4s ease; +} +.ge-ai-command-progress-label { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.2px; + color: #fff; + text-shadow: 0 1px 2px rgba(0, 0, 0, 0.45); + pointer-events: none; +} +/* Stacked Quick Edit jobs — transient jelly-style notifications: each prompt + shows a row while queued/running, fades out when finished, and multiple rows + stack under each other. No persistent history to dismiss. */ +.ge-ai-command-stack { + display: flex; + flex-direction: column; + gap: 4px; + margin-top: 6px; + max-height: 168px; + overflow-y: auto; +} +.ge-ai-job { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 6px; + border-radius: 7px; + border: 1px solid color-mix(in srgb, var(--fg) 10%, transparent); + background: color-mix(in srgb, var(--fg) 4%, transparent); + font-size: 10px; + line-height: 1.25; + animation: ge-ai-job-in 0.2s ease-out; +} +.ge-ai-job-leaving { + opacity: 0; + transform: translateY(-4px); + transition: opacity 0.32s ease, transform 0.32s ease; +} +@keyframes ge-ai-job-in { + from { opacity: 0; transform: translateY(4px); } + to { opacity: 1; transform: translateY(0); } +} +.ge-ai-job-icon { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + justify-content: center; + width: 13px; + color: var(--fg-muted); +} +.ge-ai-job-body { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 1px; +} +.ge-ai-job-prompt { + color: var(--fg); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ge-ai-job-detail { + color: var(--fg-muted); + font-size: 9px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ge-ai-job-detail[hidden] { display: none; } +.ge-ai-job-cancel { + flex: 0 0 auto; + padding: 2px 6px; + border-radius: 5px; + border: 1px solid color-mix(in srgb, var(--red, #e5484d) 40%, transparent); + background: transparent; + color: var(--red, #e5484d); + font-size: 9px; + line-height: 1.4; + cursor: pointer; +} +.ge-ai-job-cancel:hover { + background: color-mix(in srgb, var(--red, #e5484d) 16%, transparent); +} +.ge-ai-job-cancel[hidden] { display: none; } +.ge-ai-job-running { border-color: color-mix(in srgb, var(--accent, var(--red)) 45%, transparent); } +.ge-ai-job-running .ge-ai-job-detail { color: var(--accent, var(--red)); } +.ge-ai-job-running .ge-ai-job-icon { color: var(--accent, var(--red)); } +.ge-ai-job-done .ge-ai-job-icon { color: var(--green, #4caf50); } +.ge-ai-job-error .ge-ai-job-icon { color: var(--red, #e5484d); } +.ge-ai-job-error .ge-ai-job-detail { color: var(--red, #e5484d); } +.ge-ai-job-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} +/* The running row uses the app's whirlpool spinner (canvas), not a CSS ring. */ +.ge-ai-job-icon .ai-spinner-whirlpool { display: inline-flex; } +/* "fx" glyph for the new-adjustment-layer button next to the "+" new-layer button. */ +.ge-add-fx-glyph { + font-size: 10px; + font-style: italic; + font-weight: 700; + letter-spacing: -0.4px; + line-height: 1; +} /* Loading overlay shown while a large image is decoding — centered whirlpool + "Loading" caption over a translucent dim. */ .ge-loading-overlay { @@ -694,6 +1537,66 @@ box-shadow: 0 2px 12px rgba(0,0,0,0.3); background: repeating-conic-gradient(#808080 0% 25%, #a0a0a0 0% 50%) 50% / 16px 16px; } +/* AI-working indicator: while a Quick Edit / inpaint request is in flight a + fireflies glow drifts over only the region being edited (the selection + bounds, or the whole canvas when nothing is selected) — the same drifting, + pulsing look as the "yoyo" theme's ascii-fireflies background, scoped so it + never covers the workspace around the image. */ +.ge-canvas-area.ge-canvas-ai-busy::after { + content: ""; + position: absolute; + left: var(--ge-ai-busy-x, 0px); + top: var(--ge-ai-busy-y, 0px); + width: var(--ge-ai-busy-w, 100%); + height: var(--ge-ai-busy-h, 100%); + border-radius: 3px; + z-index: 11; + pointer-events: none; + /* slight dark scrim under the ASCII skeleton so the glyphs read clearly */ + background: rgba(6, 14, 9, 0.38); +} +.ge-canvas-area .ge-ai-ascii-fireflies { + position: absolute; + left: var(--ge-ai-busy-x, 0px); + top: var(--ge-ai-busy-y, 0px); + width: var(--ge-ai-busy-w, 100%); + height: var(--ge-ai-busy-h, 100%); + margin: 0; + padding: 0; + overflow: hidden; + border-radius: 3px; + z-index: 12; + pointer-events: none; + white-space: pre; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; + font-size: 11px; + line-height: 1; + letter-spacing: 0; + color: #b8e6c1; + text-shadow: 0 0 6px rgba(120, 220, 150, 0.55); + background: transparent !important; + opacity: 0; + transition: opacity 0.25s ease; +} +.ge-canvas-area.ge-canvas-ai-busy .ge-ai-ascii-fireflies { opacity: 1; } +.ge-canvas-area .ge-ai-ascii-fireflies::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(100deg, transparent 35%, rgba(184, 230, 193, 0.20) 50%, transparent 65%); + background-size: 220% 100%; + background-repeat: no-repeat; + animation: ge-ai-canvas-sheen 2.2s linear infinite; + mix-blend-mode: screen; +} +@media (prefers-reduced-motion: reduce) { + .ge-canvas-area .ge-ai-ascii-fireflies { display: none; } +} +@keyframes ge-ai-canvas-sheen { + 0% { background-position: 130% 0; } + 100% { background-position: -30% 0; } +} /* Transform-overlay canvas — sits over the main canvas with extra margin so resize / rotation handles can render OUTSIDE the image @@ -789,7 +1692,7 @@ /* Right panel */ .ge-right-panel { - width: 250px; + width: 340px; flex-shrink: 0; display: flex; flex-direction: column; @@ -866,8 +1769,25 @@ #ge-brush-controls { display: flex; flex-direction: column; - gap: 9px; + gap: 2px; } +/* The dropdown shells set `display:flex` above, which otherwise beats the + UA `[hidden]{display:none}` rule and leaves every menu open on load. */ +.ge-file-menu[hidden], +.ge-edit-menu[hidden], +.ge-layer-menu[hidden], +.ge-type-menu[hidden], +.ge-window-menu[hidden], +.ge-help-menu[hidden], +.ge-resize-menu[hidden], +.ge-edge-menu[hidden], +.ge-view-menu[hidden], +.ge-image-menu[hidden], +.ge-selection-menu[hidden], +.ge-filter-menu[hidden], +.ge-3d-menu[hidden], +.ge-plugins-menu[hidden], +.ge-save-menu[hidden] { display: none; } .ge-brush-pressure-row { justify-content: space-between; gap: 6px; } .ge-pressure-option { display: inline-flex; align-items: center; gap: 4px; font-size: 10px; opacity: 0.7; } .ge-pressure-option .toggle-switch { width: 24px; height: 14px; } @@ -955,6 +1875,10 @@ background: color-mix(in srgb, var(--red) 12%, transparent); } .ge-text-format-sep { width: 1px; height: 20px; background: var(--border); margin: 0 2px; } +.ge-text-opentype-row { grid-template-columns: repeat(3, minmax(0, 1fr)); } +.ge-text-inline-check { flex-direction: row; align-items: center; gap: 4px; } +.ge-text-inline-check > span { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ge-text-inline-check input[type="checkbox"] { flex: 0 0 auto; width: auto; height: auto; min-width: 0; margin: 0; } .ge-shape-types { display: flex; gap: 4px; } .ge-shape-sides-field { max-width: 90px; } .ge-direct-text-editor { @@ -985,6 +1909,11 @@ flex-direction: column; gap: 4px; padding: 0 0 8px; + /* Never let the single-row popup grow wider than the editor: its + min-content width otherwise pushes it left across the tool column. + Capping + wrapping keeps the tool buttons clickable. */ + max-width: calc(100% - 90px); + min-width: 0; background: color-mix(in srgb, var(--panel) 98%, var(--bg)); border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 42%, var(--border)); border-radius: 8px; @@ -1038,8 +1967,11 @@ .ge-transform-popup-body { display: flex; align-items: center; + flex-wrap: wrap; + row-gap: 6px; gap: 6px; padding: 4px 12px; + min-width: 0; } .ge-transform-popup-hint { padding: 0 12px; } .ge-transform-field { @@ -1975,6 +2907,57 @@ .ge-history-row-label { flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ge-history-row-time { font-size: 9px; opacity: 0.55; flex-shrink: 0; } +/* Named snapshots (Photoshop-style restore points) — camera button + rows. */ +.ge-history-snapshots { border-bottom: 1px solid color-mix(in srgb, var(--fg) 10%, transparent); } +.ge-history-snap-head { + display: flex; align-items: center; gap: 6px; + padding: 7px 10px 4px; +} +.ge-history-snap-title { + flex: 1 1 auto; + font-weight: 600; font-size: 10px; letter-spacing: 0.04em; + text-transform: uppercase; opacity: 0.7; +} +.ge-history-snap-new { + display: inline-flex; align-items: center; justify-content: center; + width: 22px; height: 22px; + background: none; border: 1px solid color-mix(in srgb, var(--fg) 18%, transparent); + border-radius: 5px; color: var(--fg-muted); cursor: pointer; flex-shrink: 0; +} +.ge-history-snap-new:hover { color: var(--fg); border-color: color-mix(in srgb, var(--fg) 35%, transparent); } +.ge-history-snap-new svg { width: 13px; height: 13px; display: block; flex: 0 0 auto; } +.ge-history-snap-rows { padding: 0 0 4px; } +.ge-history-snap-empty { padding: 2px 10px 6px; font-size: 10px; opacity: 0.45; } +.ge-history-snap-row { + display: flex; align-items: center; gap: 6px; + padding: 0 6px 0 0; +} +.ge-history-snap-restore { + display: flex; align-items: center; gap: 8px; + flex: 1 1 auto; min-width: 0; + padding: 5px 4px 5px 10px; + background: none; border: none; text-align: left; cursor: pointer; + color: var(--fg); font-size: 11px; +} +.ge-history-snap-restore:hover { background: color-mix(in srgb, var(--fg) 8%, transparent); } +.ge-history-snap-dot { + width: 6px; height: 6px; border-radius: 50%; + background: var(--accent, var(--red)); flex-shrink: 0; +} +.ge-history-snap-del { + background: none; border: none; color: var(--fg-muted); + font-size: 14px; line-height: 1; cursor: pointer; padding: 0 4px; flex-shrink: 0; +} +.ge-history-snap-del:hover { color: var(--accent, var(--red)); } + +/* Docked History panel: the panel body *is* the timeline (Photoshop-style), + so it fills the dock and scrolls its own list. */ +.ge-history-body { padding: 0; display: flex; flex-direction: column; min-height: 0; } +.ge-history-body .ge-history-list { flex: 1 1 auto; min-height: 90px; } +.ge-history-popout { margin: 6px 10px; align-self: flex-start; font-size: 10px; } +/* (The docked History panel dropped its "Open in window" button — the timeline + is already shown inline; the floating window stays on the topbar clock.) */ + /* Tight button group in popup heads — min + close sit immediately next to each other with no inherited flex gap, pushed to the right edge. */ .ge-head-btns { @@ -2435,6 +3418,23 @@ } .ge-adj-revert:hover { opacity: 1; color: var(--fg); } +/* Develop panel polish: full-contrast value readouts, grouped heading labels, + and revert arrows that stay hidden until hover or a non-default value. */ +.ge-adj-develop .ge-adj-value { flex: 0 0 52px; color: var(--fg); font-size: 12px; opacity: 1; } +.ge-adj-group-label { + font-size: 10px; letter-spacing: .08em; text-transform: uppercase; + opacity: .55; margin: 10px 0 4px; +} +.ge-adj-develop .ge-adj-group-label:first-child { margin-top: 2px; } +.ge-adj-develop .ge-adj-revert { + min-width: 24px; min-height: 24px; margin-left: 6px; padding: 0; + opacity: 0; pointer-events: none; +} +.ge-adj-develop .ge-adj-row:hover .ge-adj-revert, +.ge-adj-develop .ge-adj-row.is-modified .ge-adj-revert { + opacity: 1; pointer-events: auto; +} + /* Adjustment sub-layer row in the layer panel — indented under parent. */ .ge-adj-sub-item { padding-left: 18px; @@ -2672,7 +3672,7 @@ .ge-layers-header #ge-add-layer { position: relative; top: 1px; } .ge-layer-blend-row { display: grid; - grid-template-columns: 38px minmax(0, 1fr); + grid-template-columns: 38px minmax(0, 1fr) auto; align-items: center; gap: 6px; padding: 4px 7px; @@ -2680,6 +3680,20 @@ font-size: 10px; color: color-mix(in srgb, var(--fg) 62%, transparent); } +.ge-layer-opacity-head { + display: inline-flex; + align-items: center; + gap: 4px; + white-space: nowrap; +} +.ge-layer-opacity-head input { + width: 48px; + height: 25px; + padding: 0 4px; + border-radius: 4px; + font-size: 10px; + text-align: right; +} .ge-layer-blend-row select { width: 100%; min-width: 0; @@ -2739,6 +3753,12 @@ } .ge-save-state-icon { display: none; } .ge-save-label { white-space: nowrap; min-width: 0; } +/* Save button: glyph on its own row, then the SAVE action label and the + live draft-status chip share the second row. */ +#ge-save-menu-btn { flex-direction: row !important; flex-wrap: wrap; column-gap: 4px; } +#ge-save-menu-btn .ge-stacked-glyph { flex: 0 0 100%; } +#ge-save-menu-btn .ge-save-action-label { top: 0; } +#ge-save-menu-btn .ge-save-label { top: 0; } .ge-save-state-saved { color: var(--fg); } .ge-save-menu .dropdown-item-compact { width: 100%; @@ -2755,14 +3775,30 @@ .ge-edge-wrap, .ge-view-wrap, .ge-image-wrap, +.ge-layer-wrap, +.ge-type-wrap, .ge-selection-wrap, -.ge-filter-wrap { position: relative; display: inline-block; } +.ge-filter-wrap, +.ge-3d-wrap, +.ge-plugins-wrap, +.ge-window-wrap, +.ge-file-wrap, +.ge-edit-wrap, +.ge-help-wrap { position: relative; display: inline-block; } .ge-resize-menu, .ge-edge-menu, .ge-view-menu, .ge-image-menu, +.ge-file-menu, +.ge-edit-menu, +.ge-layer-menu, +.ge-type-menu, .ge-selection-menu, -.ge-filter-menu { +.ge-filter-menu, +.ge-3d-menu, +.ge-plugins-menu, +.ge-window-menu, +.ge-help-menu { position: absolute; top: calc(100% + 2px); right: 0; @@ -2778,17 +3814,31 @@ } /* Mobile: escape the topbar's stacking context (z 5) so the dropdown lands above the bottom-sheet panels (z 50 / 60). Switch to fixed and - pin under the topbar. */ + pin under the topbar. Anchor to the LEFT of the viewport: the editor's + `.gallery-modal-content` ancestor carries a (identity) transform, so a + fixed dropdown's containing block is that ancestor rather than the + viewport. A `right` anchor resolved against the narrow topbar wrap and + pushed these ~224px menus off-screen (x ≈ -160 at 390px); a left + anchor keeps them fully on-screen. */ @media (max-width: 820px) { .ge-resize-menu, .ge-edge-menu, .ge-view-menu, .ge-image-menu, + .ge-file-menu, + .ge-edit-menu, + .ge-layer-menu, + .ge-type-menu, .ge-selection-menu, - .ge-filter-menu { + .ge-filter-menu, + .ge-3d-menu, + .ge-plugins-menu, + .ge-window-menu, + .ge-help-menu { position: fixed; top: 44px; - right: 8px; + left: 8px; + right: auto; z-index: 500; max-width: calc(100vw - 16px); } @@ -2797,13 +3847,29 @@ .ge-edge-menu { min-width: 220px; padding: 10px; gap: 6px; } .ge-view-menu { min-width: 174px; } .ge-image-menu { min-width: 180px; } +.ge-file-menu { min-width: 208px; } +.ge-edit-menu { min-width: 180px; } +.ge-layer-menu { min-width: 200px; } +.ge-type-menu { min-width: 180px; } .ge-selection-menu { min-width: 224px; } .ge-filter-menu { min-width: 180px; } +.ge-3d-menu { min-width: 220px; } +.ge-plugins-menu { min-width: 200px; } +.ge-window-menu { min-width: 200px; } +.ge-help-menu { min-width: 200px; } .ge-resize-menu .dropdown-item-compact, .ge-view-menu .dropdown-item-compact, .ge-image-menu .dropdown-item-compact, +.ge-file-menu .dropdown-item-compact, +.ge-edit-menu .dropdown-item-compact, +.ge-layer-menu .dropdown-item-compact, +.ge-type-menu .dropdown-item-compact, .ge-selection-menu .dropdown-item-compact, -.ge-filter-menu .dropdown-item-compact { +.ge-filter-menu .dropdown-item-compact, +.ge-3d-menu .dropdown-item-compact, +.ge-plugins-menu .dropdown-item-compact, +.ge-window-menu .dropdown-item-compact, +.ge-help-menu .dropdown-item-compact { width: 100%; background: none; border: none; @@ -2812,12 +3878,14 @@ } .ge-view-toggle .ge-view-check { opacity: 0; } .ge-view-toggle.active .ge-view-check { opacity: 1; color: var(--red); } +.ge-window-toggle .ge-window-check { opacity: 0; } +.ge-window-toggle.active .ge-window-check { opacity: 1; color: var(--red); } .ge-view-grid-size { display: grid; grid-template-columns: 1fr 52px auto; align-items: center; gap: 5px; - padding: 4px 8px; + padding: 4px 14px 4px 8px; font-size: 10px; color: var(--color-muted); } @@ -2832,25 +3900,198 @@ font: inherit; text-align: right; } -/* Sub-menu label inside the Filter / Image dropdown — small uppercase - header so "Blur" / "Selection" reads as a category, not an action. */ -.ge-filter-submenu-label { +/* Sub-menu label inside the topbar dropdowns — small uppercase header so + "Blur" / "Transform" reads as a category, not an action. Shared by every + editor dropdown (`.ge-menu-label`) and the legacy filter class. */ +.ge-filter-submenu-label, +.ge-menu-label { font-size: 9px; text-transform: uppercase; letter-spacing: 0.08em; - opacity: 0.45; - font-weight: 600; - padding: 4px 8px 2px; + opacity: 0.72; + font-weight: 700; + color: var(--color-muted); + padding: 8px 8px 3px; + border-top: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); pointer-events: none; } +/* The first header in a menu sits flush to the menu's own padding so the + dropdown does not show an empty band at the top (Photoshop menus start + with an item or a tight separator, never dead space). */ +.ge-filter-submenu-label:first-child, +.ge-menu-label:first-child, +.dropdown-section-label:first-child { padding-top: 2px; } /* Disabled dropdown items (e.g. Fill when there's no selection) read as muted + non-interactive. */ .ge-image-menu .dropdown-item-compact:disabled, +.ge-layer-menu .dropdown-item-compact:disabled, +.ge-type-menu .dropdown-item-compact:disabled, .ge-selection-menu .dropdown-item-compact:disabled, -.ge-filter-menu .dropdown-item-compact:disabled { +.ge-filter-menu .dropdown-item-compact:disabled, +.ge-3d-menu .dropdown-item-compact:disabled, +.ge-plugins-menu .dropdown-item-compact:disabled, +.ge-file-menu .dropdown-item-compact:disabled, +.ge-edit-menu .dropdown-item-compact:disabled, +.ge-view-menu .dropdown-item-compact:disabled, +.ge-window-menu .dropdown-item-compact:disabled, +.ge-help-menu .dropdown-item-compact:disabled { opacity: 0.4; cursor: not-allowed; } +/* Photoshop menu feel — dark chrome, hover highlight, right-aligned + shortcut hints, submenu chevrons, keyboard-focus ring. */ +.ge-file-menu .dropdown-item-compact, +.ge-edit-menu .dropdown-item-compact, +.ge-image-menu .dropdown-item-compact, +.ge-layer-menu .dropdown-item-compact, +.ge-type-menu .dropdown-item-compact, +.ge-selection-menu .dropdown-item-compact, +.ge-filter-menu .dropdown-item-compact, +.ge-view-menu .dropdown-item-compact, +.ge-window-menu .dropdown-item-compact, +.ge-help-menu .dropdown-item-compact, +.ge-3d-menu .dropdown-item-compact, +.ge-plugins-menu .dropdown-item-compact { + position: relative; + justify-content: flex-start; +} +.ge-file-menu .dropdown-item-compact:hover:not(:disabled), +.ge-edit-menu .dropdown-item-compact:hover:not(:disabled), +.ge-image-menu .dropdown-item-compact:hover:not(:disabled), +.ge-layer-menu .dropdown-item-compact:hover:not(:disabled), +.ge-type-menu .dropdown-item-compact:hover:not(:disabled), +.ge-selection-menu .dropdown-item-compact:hover:not(:disabled), +.ge-filter-menu .dropdown-item-compact:hover:not(:disabled), +.ge-view-menu .dropdown-item-compact:hover:not(:disabled), +.ge-window-menu .dropdown-item-compact:hover:not(:disabled), +.ge-help-menu .dropdown-item-compact:hover:not(:disabled), +.ge-3d-menu .dropdown-item-compact:hover:not(:disabled), +.ge-plugins-menu .dropdown-item-compact:hover:not(:disabled) { + background: var(--accent, var(--red)); + color: var(--panel); +} +.ge-file-menu .dropdown-item-compact:focus-visible, +.ge-edit-menu .dropdown-item-compact:focus-visible, +.ge-image-menu .dropdown-item-compact:focus-visible, +.ge-layer-menu .dropdown-item-compact:focus-visible, +.ge-type-menu .dropdown-item-compact:focus-visible, +.ge-selection-menu .dropdown-item-compact:focus-visible, +.ge-filter-menu .dropdown-item-compact:focus-visible, +.ge-view-menu .dropdown-item-compact:focus-visible, +.ge-window-menu .dropdown-item-compact:focus-visible, +.ge-help-menu .dropdown-item-compact:focus-visible, +.ge-3d-menu .dropdown-item-compact:focus-visible, +.ge-plugins-menu .dropdown-item-compact:focus-visible { + outline: none; + background: color-mix(in srgb, var(--accent, #4c8bf5) 22%, transparent); + color: var(--fg); +} +.ge-file-menu .dropdown-shortcut, +.ge-edit-menu .dropdown-shortcut, +.ge-image-menu .dropdown-shortcut, +.ge-layer-menu .dropdown-shortcut, +.ge-selection-menu .dropdown-shortcut, +.ge-filter-menu .dropdown-shortcut, +.ge-view-menu .dropdown-shortcut, +.ge-window-menu .dropdown-shortcut, +.ge-help-menu .dropdown-shortcut, +.ge-save-menu .dropdown-shortcut { + margin-left: auto; + font-size: 9.5px; + opacity: 0.5; + font-variant-numeric: tabular-nums; + white-space: nowrap; +} +/* Menu items that open a submenu render a chevron on the right edge. */ +.ge-topbar .dropdown-item-compact.ge-has-submenu::after { + content: '›'; + margin-left: auto; + font-size: 13px; + line-height: 1; + opacity: 0.6; +} +/* --- Photoshop polish pass ------------------------------------------------- + 1. Flat text menu bar: title-case labels, no icon/box chrome, tight gaps. + 2. Compact dropdown rows + real separators instead of faint headers. + 3. Pin the right-hand action group so SAVE can never be clipped; the menu + row scrolls internally when the editor gets narrow. */ +.ge-topbar { justify-content: flex-start; } +.ge-topbar-left { + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; +} +.ge-topbar-left::-webkit-scrollbar { display: none; } +.ge-topbar-right { flex: 0 0 auto; margin-left: auto; } +.ge-topbar-left .ge-stacked-btn { + flex-direction: row !important; + gap: 0 !important; + padding: 5px 7px !important; + background: transparent; + border: 0; + box-shadow: none; + border-radius: 4px; + color: var(--fg); +} +.ge-topbar-left .ge-stacked-btn .ge-stacked-glyph, +.ge-topbar-left .ge-stacked-btn .ge-stacked-caret { display: none; } +.ge-topbar-left .ge-stacked-btn .ge-stacked-label { + position: static; + top: 0; + font-size: 12px; + letter-spacing: 0; + opacity: 1; + font-weight: 400; + text-transform: none; + color: inherit; +} +.ge-topbar-left .ge-stacked-btn:hover { background: var(--accent, var(--red)); color: var(--panel); } +/* Keep the menu bar Photoshop-short. The stacked buttons render as flat text + labels here, so they only need a tight row height. */ +.ge-topbar { padding: 2px 10px; } +.ge-topbar-left .ge-stacked-btn { height: 26px; min-height: 0; padding: 0 8px !important; } +/* When the menu bar is tight, drop the right group's text first (icon-only + Import/Save) so all twelve menus stay visible instead of Window/Help + scrolling out of view. Toggled from JS by measuring overflow. */ +.ge-topbar.ge-topbar-compact #ge-import-topbar { padding: 5px 7px !important; } +.ge-topbar.ge-topbar-compact #ge-import-topbar .ge-stacked-label, +.ge-topbar.ge-topbar-compact #ge-save-menu-btn .ge-save-action-label { display: none !important; } +.ge-topbar.ge-topbar-compact #ge-save-menu-btn { padding: 5px 8px !important; } +/* Non-essential right-group chrome hid so the full Photoshop menu bar + (through Help) always fits without scrolling out of view. */ +.ge-topbar .ge-alpha-badge, +.ge-topbar #ge-shortcuts-btn { display: none; } +/* Compact dropdown rows (Photoshop menus are ~24px tall, 12-13px text). */ +.ge-topbar .dropdown .dropdown-item-compact { + min-height: 0; + padding: 4px 10px; + font-size: 12.5px; + line-height: 1.35; + gap: 8px; +} +.ge-topbar .dropdown .dropdown-icon { width: 13px; height: 13px; } +.ge-topbar .dropdown .dropdown-shortcut { font-size: 11px; } +/* Section headers become real separators (Photoshop has no section labels); + the first one is dropped so a menu never starts with dead space. */ +.ge-topbar .dropdown .ge-menu-label, +.ge-topbar .dropdown .ge-filter-submenu-label, +.ge-topbar .dropdown .dropdown-section-label { + font-size: 0; + line-height: 0; + letter-spacing: 0; + opacity: 1; + height: 0; + padding: 0; + margin: 5px 10px; + border-top: 1px solid var(--border); + overflow: hidden; + pointer-events: none; +} +.ge-topbar .dropdown > .ge-menu-label:first-child, +.ge-topbar .dropdown > .ge-filter-submenu-label:first-child, +.ge-topbar .dropdown > .dropdown-section-label:first-child { display: none; } .ge-selection-save-row { display: grid; grid-template-columns: minmax(0, 1fr) 28px; @@ -3318,23 +4559,27 @@ display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px; + flex: 0 0 auto; } .ge-canvas-preset { display: flex; flex-direction: column; - align-items: flex-start; - gap: 3px; + align-items: center; + justify-content: center; + gap: 2px; + flex: 0 0 auto; + min-height: 62px; min-width: 0; - padding: 8px 9px; + padding: 10px 9px; + line-height: 1.25; border: 1px solid color-mix(in srgb, var(--border) 85%, transparent); border-radius: 6px; background: color-mix(in srgb, var(--fg) 4%, transparent); color: var(--fg); font: inherit; - text-align: left; + text-align: center; cursor: pointer; box-sizing: border-box; - overflow: hidden; } .ge-canvas-preset:hover, .ge-canvas-preset.active { @@ -3344,14 +4589,31 @@ .ge-canvas-preset span, .ge-canvas-preset strong { display: block; - width: 100%; + width: auto; + max-width: 100%; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .ge-canvas-preset span { font-size: 11px; font-weight: 600; } -.ge-canvas-preset strong { font-size: 10px; font-weight: 500; opacity: .62; } +.ge-canvas-preset strong { font-size: 10px; font-weight: 500; opacity: .8; } +.ge-canvas-preview { + display: flex; + align-items: center; + justify-content: center; + margin: 2px 0 8px; + padding: 6px; + border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); + border-radius: 6px; + background: color-mix(in srgb, var(--fg) 4%, transparent); +} +.ge-canvas-preview img { max-width: 100%; max-height: 120px; object-fit: contain; display: block; } +.ge-canvas-preview[hidden], +.ge-canvas-resolution-field[hidden] { display: none; } +.ge-canvas-resolution-field { display: inline-flex; align-items: center; gap: 6px; } +.ge-canvas-resolution-field input[type="number"] { width: 84px; } +.ge-canvas-resolution-unit { font-size: 11px; opacity: .6; } .ge-canvas-prompt-footer { display: flex; align-items: center; @@ -3413,6 +4675,328 @@ button .spinner-whirlpool { position: relative; } +/* Docked right panel stack (desktop) — Photoshop-style: several panel + groups are visible at once, stacked vertically. Each group shows one + tab's panel, collapses to its tab strip, and resizes via the divider + below it. The panel wrappers only re-parent existing elements, so all + ids/classes/listeners keep working; inactive panels are `hidden`. */ +.ge-right-panel--dock { + overflow: hidden; +} +.ge-dock { + display: flex; + flex-direction: column; + flex: 0 1 auto; + min-height: 0; + max-height: 68%; + overflow-y: auto; + overflow-x: hidden; +} +.ge-right-panel--dock > .ge-layers { flex: 1 1 auto; min-height: 0; } + +.ge-dock-group { + display: flex; + flex-direction: column; + flex: 0 0 auto; + min-height: 0; + overflow: hidden; +} +.ge-dock-group-head { + display: flex; + align-items: stretch; + flex: 0 0 auto; + gap: 2px; + padding: 0 4px 0 6px; + background: color-mix(in srgb, var(--panel) 88%, var(--fg) 12%); + border-bottom: 1px solid var(--border); +} +.ge-dock-tabs { + display: flex; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + scrollbar-width: none; + gap: 1px; +} +.ge-dock-tabs::-webkit-scrollbar { display: none; } +.ge-dock-tab { + flex: 0 0 auto; + padding: 5px 14px 5px 8px; + font: inherit; + font-size: 9px; + font-weight: 600; + letter-spacing: 0.01em; + text-transform: uppercase; + white-space: nowrap; + color: color-mix(in srgb, var(--fg) 72%, transparent); + background: transparent; + border: 0; + border-bottom: 2px solid transparent; + cursor: pointer; +} +.ge-dock-tab:hover { color: var(--fg); } +.ge-dock-tab.active { + color: var(--fg); + border-bottom-color: var(--red); +} +.ge-dock-tab:focus-visible { + outline: 2px solid var(--red); + outline-offset: -2px; +} +/* A tab + its tear-off ("undock") control. The undock button appears on + hover / focus and floats that single panel into its own window. It sits + inside the tab button (top-right corner), not beside it. */ +.ge-dock-tab-item { + display: inline-flex; + align-items: stretch; + flex: 0 0 auto; + position: relative; +} +.ge-dock-tab-pop { + position: absolute; + top: 1px; + right: 1px; + display: none; + align-items: center; + justify-content: center; + width: 13px; + height: 13px; + padding: 0; + border: 0; + border-radius: 3px; + background: color-mix(in srgb, var(--panel) 88%, transparent); + color: color-mix(in srgb, var(--fg) 55%, transparent); + cursor: pointer; +} +.ge-dock-tab-item:hover .ge-dock-tab-pop, +.ge-dock-tab:focus-visible ~ .ge-dock-tab-pop { display: inline-flex; } +.ge-dock-tab-pop:hover { color: var(--fg); } +.ge-dock-collapse { + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--fg) 72%, transparent); + background: transparent; + border: 0; + cursor: pointer; + transition: transform 0.12s; +} +.ge-dock-collapse:hover { color: var(--fg); } +.ge-dock-detach { + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + margin-left: 2px; + border-left: 1px solid color-mix(in srgb, var(--fg) 12%, transparent); + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--fg) 70%, transparent); + background: transparent; + border-top: 0; + border-right: 0; + border-bottom: 0; + cursor: pointer; +} +.ge-dock-detach:hover { color: var(--fg); } +/* Collapsed groups shrink to their tab header only (never clip it). */ +.ge-dock-group.is-collapsed { + flex: 0 0 auto !important; + height: auto !important; +} +.ge-dock-tabs-more { + flex: 0 0 auto; + width: 26px; + height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--fg) 72%, transparent); + background: transparent; + border: 0; + cursor: pointer; +} +.ge-dock-tabs-more:hover { color: var(--fg); } +.ge-dock-collapse svg, .ge-dock-detach svg, .ge-dock-tabs-more svg { width: 16px; height: 16px; flex: 0 0 auto; } +.ge-dock-tabs-menu { + position: fixed; + z-index: 10060; + min-width: 140px; + padding: 4px; + display: flex; + flex-direction: column; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 10px 28px rgba(0, 0, 0, 0.5); +} +.ge-dock-tabs-menu-item { + text-align: left; + padding: 5px 8px; + font-size: 11.5px; + color: var(--fg); + background: transparent; + border: 0; + border-radius: 4px; + cursor: pointer; +} +.ge-dock-tabs-menu-item:hover { + background: color-mix(in srgb, var(--accent, #4c8bf5) 22%, transparent); +} +.ge-dock-group.is-floating .ge-dock-detach { display: none; } +/* Detached panel group — floats above the editor in its own draggable + window with a Dock control to return it to the stack. */ +.ge-float-window { + position: fixed; + z-index: 10050; + width: 260px; + max-width: calc(100vw - 16px); + background: var(--panel); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 12px 34px rgba(0, 0, 0, 0.5); + overflow: hidden; +} +.ge-float-head { + display: flex; + align-items: center; + gap: 6px; + padding: 5px 6px 5px 10px; + background: color-mix(in srgb, var(--panel) 88%, var(--fg) 12%); + border-bottom: 1px solid var(--border); + cursor: grab; + touch-action: none; +} +.ge-float-title { + flex: 1 1 auto; + min-width: 0; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ge-float-btns { display: inline-flex; align-items: center; gap: 2px; } +.ge-float-dock { + width: 28px; + height: 26px; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + color: color-mix(in srgb, var(--fg) 70%, transparent); + background: transparent; + border: 0; + cursor: pointer; +} +.ge-float-dock svg { width: 16px; height: 16px; flex: 0 0 auto; } +.ge-float-dock:hover { color: var(--fg); } +.ge-float-body { max-height: 60vh; overflow-y: auto; } +.ge-float-body > .ge-dock-group { flex: 0 0 auto !important; height: auto !important; } +.ge-dock-group.is-collapsed .ge-dock-collapse { transform: rotate(-90deg); } +.ge-dock-group.is-collapsed .ge-dock-panels { display: none; } + +.ge-dock-panels { + display: flex; + flex-direction: column; + min-height: 0; + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + /* A visible thin scrollbar makes it obvious when a group's content + (e.g. the swatch grid) continues below the fold instead of looking + like it was clipped mid-row. */ + scrollbar-width: thin; + scrollbar-color: color-mix(in srgb, var(--fg) 30%, transparent) transparent; +} +.ge-dock-panels::-webkit-scrollbar { width: 8px; } +.ge-dock-panels::-webkit-scrollbar-thumb { + background: color-mix(in srgb, var(--fg) 28%, transparent); + border-radius: 4px; +} +.ge-dock-panels::-webkit-scrollbar-track { background: transparent; } +.ge-dock-panel { + display: flex; + flex-direction: column; + flex: 0 0 auto; + min-width: 0; +} +.ge-dock-panel[hidden] { display: none; } + +/* View > Panels visibility: hide a panel's body + its tab-strip item, a whole + group (and its divider) once every member is hidden, and the pinned Layers / + Tool Options strips. Tagged by panel-visibility.js. */ +.ge-dock-panel[data-panel-hidden="1"], +.ge-dock-tab-item[data-panel-hidden="1"], +.ge-dock-group[data-panel-hidden="1"], +.ge-dock-divider[data-panel-hidden="1"], +.ge-layers[data-panel-hidden="1"], +.ge-controls[data-panel-hidden="1"], +.ge-dock[data-panel-hidden="1"], +.ge-right-panel[data-panel-hidden="1"] { display: none !important; } +/* When every dock group is hidden the rail must shrink to its remaining pinned + content instead of stretching to full height and leaving a blank column. */ +.ge-right-panel[data-panel-dock-hidden="1"] { align-self: flex-start; height: auto; } +.ge-right-panel[data-panel-dock-hidden="1"] > .ge-layers { flex: 0 0 auto; max-height: 100%; } +.ge-dock-select option[data-panel-hidden="1"] { display: none; } + +/* Drag divider between two stacked panel groups. */ +.ge-dock-divider { + flex: 0 0 auto; + height: 5px; + margin: -1px 0; + background: transparent; + cursor: ns-resize; + transition: background 0.12s; +} +.ge-dock-divider:hover, +.ge-dock-divider:active { + background: color-mix(in srgb, var(--red) 35%, transparent); +} + +/* Compact fallback (narrow desktop columns) — a single selector that + swaps one panel at a time. */ +.ge-dock--compact .ge-dock-tabbar { + display: flex; + flex: 0 0 auto; + padding: 7px 8px; + border-bottom: 1px solid var(--border); + background: var(--panel); +} +.ge-dock-select { + flex: 1 1 auto; + min-width: 0; + width: 100%; + padding: 5px 8px; + font: inherit; + font-size: 11px; + font-weight: 600; + letter-spacing: 0.03em; + text-transform: uppercase; + color: var(--fg); + background-color: var(--bg, var(--panel)); + border: 1px solid var(--border); + border-radius: 6px; + cursor: pointer; +} +.ge-dock-select:focus-visible { + outline: 2px solid var(--red); + outline-offset: 1px; +} +.ge-dock--compact .ge-dock-panels { + flex: 1 1 auto; + max-height: none; +} + /* Floating value bubble that pops above the slider thumb while dragging. Solves the "I can't see the number" problem when the slider's opaque plate covers the row's right-edge value chip. */ @@ -3454,14 +5038,13 @@ button .spinner-whirlpool { } .ge-layers-header { display: flex; - flex-wrap: wrap; align-items: center; gap: 6px; - padding: 6px 10px; + padding: 4px 10px; font-size: 11px; font-weight: 600; color: var(--fg); - opacity: 0.8; + opacity: 0.85; border-bottom: 1px solid var(--border); position: relative; } @@ -3469,10 +5052,22 @@ button .spinner-whirlpool { .ge-layers-header #ge-add-layer { flex: 0 0 auto; white-space: nowrap; } .ge-layer-header-tools { display: flex; - flex: 0 0 100%; + flex: 0 1 auto; flex-wrap: wrap; align-items: center; + gap: 4px; +} +/* Panel action icons live in a compact row at the BOTTOM of the panel + (Photoshop-style); #ge-layer-tools stays an empty hook for shared + selected-layer actions. */ +.ge-layers-footer { + display: flex; + align-items: center; + justify-content: space-between; gap: 6px; + padding: 4px 8px; + border-top: 1px solid var(--border); + background: color-mix(in srgb, var(--bg-secondary, var(--panel)) 40%, var(--panel)); } .ge-layers-grab { display: none; } /* mobile only */ .ge-layers-merge-wrap { position: relative; display: inline-flex; } @@ -3554,7 +5149,7 @@ button .spinner-whirlpool { display: flex; align-items: center; gap: 4px; - padding: 5px 8px; + padding: 3px 8px; cursor: pointer; border-left: 2px solid transparent; transition: background 0.1s; @@ -3569,8 +5164,8 @@ button .spinner-whirlpool { @media (min-width: 701px) { .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) { display: grid; - grid-template-columns: 12px 22px 34px minmax(0, 1fr) 24px; - row-gap: 5px; + grid-template-columns: 12px 22px 28px minmax(0, 1fr) 24px; + row-gap: 2px; } .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-drag { grid-column: 1; @@ -3604,12 +5199,25 @@ button .spinner-whirlpool { justify-content: flex-end; min-width: 0; } + /* Photoshop-style: an idle layer row is ONE compact line. The per-row + opacity slider and action strip stay in the DOM (so they remain + testable) but only surface on the active or hovered row. */ + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-opacity-row, + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-controls { + display: none; + } + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active > .ge-layer-opacity-row, + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active > .ge-layer-controls, + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item):hover > .ge-layer-opacity-row, + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item):hover > .ge-layer-controls { + display: flex; + } } .ge-layer-inline-thumb { display: block; - width: 34px; - height: 26px; - flex: 0 0 34px; + width: 28px; + height: 21px; + flex: 0 0 28px; object-fit: contain; border: 1px solid color-mix(in srgb, var(--border) 80%, transparent); border-radius: 3px; @@ -3959,6 +5567,21 @@ button .spinner-whirlpool { font-variant-numeric: tabular-nums; opacity: .75; } +/* In the contextual options bar keep the eyedropper readout on ONE compact + row so the bar height matches the other tools and the centred canvas does + not jump between tools (which shifted the eyedropper sample point). The + loupe canvas keeps its 84x84 backing store; only its display size shrinks, + so the live pixel readout still works. */ +.ge-controls--in-options .ge-eyedropper-live { + min-height: 0; + height: 26px; + padding: 0 6px; + gap: 6px; +} +.ge-controls--in-options .ge-eyedropper-loupe { width: 22px; height: 22px; flex: 0 0 22px; } +.ge-controls--in-options .ge-eyedropper-live-swatch { width: 14px; height: 14px; flex: 0 0 14px; } +.ge-controls--in-options .ge-eyedropper-live-values { display: flex; flex-direction: row; align-items: center; gap: 8px; } + /* Left-edge drag handle on each layer row. */ .ge-layer-drag { display: flex; @@ -4112,6 +5735,14 @@ button .spinner-whirlpool { } .ge-crop-apply-btn:hover { background: color-mix(in srgb, var(--red) 85%, #000); } +/* Crop aspect preset buttons (options bar) */ +.ge-crop-preset-row { display: flex; gap: 4px; } +.ge-crop-preset.active { + background: var(--accent, #4c8bf5); + border-color: transparent; + color: #fff; +} + /* Text input floating */ .ge-text-input { background: var(--bg); @@ -4129,6 +5760,10 @@ button .spinner-whirlpool { display: none; box-sizing: border-box; } +/* Pixel brush: a hard square footprint instead of a feathered circle. */ +.ge-brush-cursor.ge-brush-cursor--pixel { + border-radius: 0; +} .ge-inpaint-section { padding: 8px 0; border-top: 1px solid var(--border); margin-top: 4px; } @@ -4267,6 +5902,19 @@ button .spinner-whirlpool { .ge-controls input[type="range"]::-moz-range-thumb { transition: width 0.12s ease, height 0.12s ease; } + /* Keep the right-hand actions (Import / Save / maximize) pinned and always + visible on phones: the menu row scrolls internally instead of dragging + the whole topbar sideways, which otherwise pushes Save off-screen. Uses a + child selector to beat the base `.ge-topbar-left{flex:0 0 auto}`. */ + .ge-topbar > .ge-topbar-left { + flex: 1 1 0; + min-width: 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + } + .ge-topbar > .ge-topbar-left::-webkit-scrollbar { display: none; } + .ge-topbar > .ge-topbar-right { flex: 0 0 auto; margin-left: 0; } .ge-toolbar { flex-direction: row; width: 100%; @@ -4282,6 +5930,34 @@ button .spinner-whirlpool { scrollbar-width: none; } .ge-toolbar::-webkit-scrollbar { display: none; } + /* Audit #5: the menu row and tool rail scroll sideways with hidden + scrollbars, so they read as static. Fade the overflowing edge (and drop + the fade on the side with nothing more to scroll to) so it's obvious there + are off-screen menus/tools. attachScrollAffordance() toggles the classes. */ + .ge-topbar-left.ge-scroll-fade, + .ge-toolbar.ge-scroll-fade { + -webkit-mask-image: linear-gradient(to right, transparent 0, #000 42px, #000 calc(100% - 42px), transparent 100%); + mask-image: linear-gradient(to right, transparent 0, #000 42px, #000 calc(100% - 42px), transparent 100%); + } + .ge-topbar-left.ge-scroll-fade.is-at-left, + .ge-toolbar.ge-scroll-fade.is-at-left { + -webkit-mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 42px), transparent 100%); + mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 42px), transparent 100%); + } + .ge-topbar-left.ge-scroll-fade.is-at-right, + .ge-toolbar.ge-scroll-fade.is-at-right { + -webkit-mask-image: linear-gradient(to right, transparent 0, #000 42px, #000 100%); + mask-image: linear-gradient(to right, transparent 0, #000 42px, #000 100%); + } + .ge-topbar-left.ge-scroll-fade.is-at-left.is-at-right, + .ge-toolbar.ge-scroll-fade.is-at-left.is-at-right { + -webkit-mask-image: none; + mask-image: none; + } + /* The horizontal mobile rail keeps a fixed short height; the fg/bg + swatch stack would stretch it and push tools under the layer sheet, + so it is hidden here (color stays reachable in the options bar). */ + .ge-tool-swatches { display: none; } /* Section labels in the toolbar were vertical breaks — turn them into thin vertical dividers when the column flows horizontally. */ .ge-tool-sep { @@ -4299,6 +5975,14 @@ button .spinner-whirlpool { flex-shrink: 0; width: 48px; } + /* Horizontal rail: slots become inline columns and the flyout caret is + always visible so touch users can open tool families without a hover. */ + .ge-tool-slot { + flex-direction: row; + flex-shrink: 0; + } + .ge-tool-slot > .ge-tool-btn { width: 48px; } + .ge-tool-caret { display: inline-flex; } /* Canvas is the main thing — give it as much vertical space as the editor body can spare. min-height keeps it visible even when the right panel is at its max. */ @@ -4355,12 +6039,20 @@ button .spinner-whirlpool { padding-top: 16px; position: relative; } - /* In peek state, the layers list takes the full panel height (~70vh) - and never overflows so its own scrollbar doesn't activate. Constrain - the list to the visible peek area so it scrolls properly when there - are more layers than the peek can show. */ + /* Peek state: bound the whole layers section to the peek height so its + bottom always meets the viewport edge and the list scrolls inside it. + Previously only the list was capped (peek - 52px) while the section + itself grew past the peek, so the add/fx/delete footer hung ~89px + below the phone viewport with no way to reach it. */ + .ge-right-panel:not(.expanded) .ge-layers { + max-height: var(--peek-height, 110px); + overflow: hidden; + } .ge-right-panel:not(.expanded) .ge-layers-list { - max-height: calc(var(--peek-height, 110px) - 52px); + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow-y: auto; -webkit-overflow-scrolling: touch; } /* Mobile layer-row buttons — bump hit targets up so fingers can @@ -4368,8 +6060,10 @@ button .spinner-whirlpool { .ge-layer-vis, .ge-layer-btn, .ge-layer-fx-btn { - width: 40px !important; - height: 40px !important; + width: 44px !important; + min-width: 44px !important; + height: 44px !important; + min-height: 44px !important; font-size: 18px !important; display: inline-flex; align-items: center; @@ -4380,19 +6074,30 @@ button .spinner-whirlpool { width: 18px; height: 18px; } + /* The desktop actions sit at 0.4 opacity, which reads as "nearly invisible" + against the pink selected-row overlay on a phone. Lift the resting opacity + so the fx / mask / duplicate icons stay legible. */ + .ge-layer-btn, + .ge-layer-fx-btn { + opacity: 0.78; + } + .ge-layer-btn:hover, + .ge-layer-fx-btn:hover { + opacity: 1; + } .ge-layers-list { overflow-x: clip; } .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) { display: grid; - grid-template-columns: 12px 40px 34px minmax(0, 1fr) 40px; - grid-template-rows: 40px auto 40px; + grid-template-columns: 12px 44px 34px minmax(0, 1fr) 44px; + grid-template-rows: 44px auto; align-items: center; row-gap: 3px; } .ge-layers-list > .ge-layer-group-row { - grid-template-columns: 12px 40px 40px 34px minmax(0, 1fr) 40px; - grid-template-rows: 40px auto 40px; + grid-template-columns: 12px 44px 44px 34px minmax(0, 1fr) 44px; + grid-template-rows: 44px auto; align-items: center; row-gap: 3px; } @@ -4441,6 +6146,18 @@ button .spinner-whirlpool { min-width: 0; margin-left: 52px; } + /* Keep the active row's action strip on ONE line inside its row so it + can never wrap over the next layer; scroll it sideways instead. + (documents-gallery-editor.css sets flex-wrap:wrap on the .active row, + so this needs the extra .ge-right-panel scope to win.) */ + .ge-right-panel .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active > .ge-layer-controls, + .ge-right-panel .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active-parent > .ge-layer-controls, + .ge-right-panel .ge-layers-list > .ge-layer-group-row.active > .ge-layer-controls { + flex-wrap: nowrap; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + } .ge-mask-sub-item { display: grid; grid-template-columns: 40px 34px minmax(80px, 1fr) auto; @@ -4701,7 +6418,7 @@ button .spinner-whirlpool { flex-wrap: nowrap; justify-content: flex-start; gap: 6px; - padding: 6px 8px; + padding: 3px 8px; -webkit-overflow-scrolling: touch; scrollbar-width: none; } @@ -4723,9 +6440,9 @@ button .spinner-whirlpool { } .ge-topbar .ge-btn, .ge-topbar .ge-btn-sm { - padding: 7px 11px !important; - font-size: 13px !important; - min-height: 34px; + padding: 4px 9px !important; + font-size: 12px !important; + min-height: 28px; line-height: 1; top: 0 !important; } @@ -4873,6 +6590,21 @@ button .spinner-whirlpool { animation: none; transition: transform 0.18s ease-in; } + /* The layers right-panel (z 50) and the controls sheet (z 60) both + dock to the bottom on phones, covering the footer's history group + and making Undo/Redo unreachable by touch. Lift the history controls + above both sheets so a touch stroke can still be undone. */ + .ge-footer-history { + position: relative; + z-index: 65; + } + /* Once moved into the layer-sheet header the pill no longer needs the + overlay z-index/opacity lift — keep it crisp and flush right. */ + .ge-layers-header .ge-footer-history { + z-index: auto; + opacity: 1; + margin-left: auto; + } } /* ── Group Chat ────────────────────────────────────── */ @@ -4900,3 +6632,2510 @@ button .spinner-whirlpool { #group-model-picker .memory-item:hover { background: color-mix(in srgb, var(--fg) 6%, transparent); } + +/* Select and Mask workspace */ +.ge-sam-overlay { + position: fixed; + inset: 0; + z-index: 10050; + display: flex; + align-items: center; + justify-content: center; + background: rgba(3, 5, 8, 0.72); +} +.ge-sam-panel { + width: min(560px, 94vw); + max-height: 92vh; + overflow: auto; + display: flex; + flex-direction: column; + background: var(--panel, #14181f); + color: var(--fg, #e8ecf1); + border: 1px solid var(--border, #2a313c); + border-radius: 10px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55); +} +.ge-sam-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 10px; + padding: 10px 14px; + border-bottom: 1px solid var(--border, #2a313c); +} +.ge-sam-title { + font-weight: 600; + font-size: 13px; +} +.ge-sam-output-label { + display: flex; + align-items: center; + gap: 6px; + font-size: 11px; + color: var(--color-muted, #97a1ad); +} +.ge-sam-body { + display: flex; + gap: 14px; + padding: 14px; + flex-wrap: wrap; +} +.ge-sam-preview { + background: #11151b; + border: 1px solid var(--border, #2a313c); + border-radius: 6px; + cursor: crosshair; + touch-action: none; +} +.ge-sam-controls { + flex: 1 1 200px; + min-width: 200px; + display: flex; + flex-direction: column; + gap: 8px; + font-size: 11px; +} +.ge-sam-controls label { + display: flex; + flex-direction: column; + gap: 2px; +} +.ge-sam-brush-row { + display: flex; + gap: 6px; +} +.ge-sam-brush-add.active, +.ge-sam-brush-subtract.active { + border-color: var(--accent, #4c8bf5); + color: var(--accent, #4c8bf5); +} +.ge-sam-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 14px; + border-top: 1px solid var(--border, #2a313c); +} + +/* Color & analysis dock panels (Phase 7) */ +.ge-panel { + display: flex; + flex-direction: column; + gap: 8px; + padding: 10px 12px; + color: var(--fg, #e6e9ef); +} +.ge-panel-title { + font-size: 10px; + font-weight: 700; + letter-spacing: .08em; + text-transform: uppercase; + color: var(--color-muted, #8b93a1); +} +.ge-panel-empty { + font-size: 12px; + opacity: .6; + padding: 6px 0; +} +.ge-swatch-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(20px, 1fr)); + gap: 4px; +} +.ge-swatch { + width: 100%; + aspect-ratio: 1 / 1; + border: 1px solid var(--border, #2a313c); + border-radius: 4px; + cursor: pointer; + padding: 0; +} +.ge-swatch.active { + outline: 2px solid var(--accent, #4c8bf5); + outline-offset: 1px; +} +.ge-current-swatch { + height: 26px; + border-radius: 5px; + border: 1px solid var(--border, #2a313c); +} +/* SWATCHES panel hosts the shared color picker's trigger swatch + (colorPicker.js) and, next to it, harmony Suggestions + Recent colours. */ +.ge-swatch-body { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; } +.ge-swatch-body .ge-color-input.cp-swatch-input { + width: 44px; + height: 44px; + flex: 0 0 auto; + min-width: 0; + border-radius: 8px; + border: 1px solid var(--border, #2a313c); + cursor: pointer; + background: none; +} +.ge-swatch-body .ge-color-input.cp-swatch-input::selection { background: transparent; } +.ge-swatch-col { display: flex; flex-direction: column; gap: 3px; flex: 1 1 auto; min-width: 0; } +.ge-swatch-row-label { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; opacity: 0.78; } +.ge-swatch-row { display: flex; flex-wrap: nowrap; overflow-x: auto; gap: 4px; scrollbar-width: none; } +.ge-swatch-row::-webkit-scrollbar { display: none; } +.ge-mini-swatch { + width: 16px; + height: 16px; + padding: 0; + flex: 0 0 auto; + border: 1px solid color-mix(in srgb, var(--fg) 22%, transparent); + border-radius: 4px; + cursor: pointer; +} +.ge-mini-swatch:hover { transform: scale(1.12); } +.ge-mini-empty { font-size: 10px; opacity: 0.45; } +/* Always-visible SV + hue picker inside the SWATCHES panel (panels.js). Reuses + the shared .cp-sl/.cp-hue classes; this container just lays them out inline + instead of inside the floating .cp-popover, so the panel reads as a picker + without a click. */ +.ge-swatch-body { flex-wrap: wrap; } +.ge-inline-picker { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1 1 100%; + width: 100%; + margin-top: 6px; +} +.ge-inline-picker .cp-sl { height: 80px; } +.ge-inline-picker .cp-hue { margin-top: 4px; } +.ge-hex-row { display: flex; align-items: center; gap: 4px; flex: 1 1 100%; width: 100%; margin-top: 6px; background: color-mix(in srgb, var(--fg) 6%, transparent); border: 1px solid var(--border); border-radius: 6px; padding: 4px 8px; } +.ge-hex-hash { color: color-mix(in srgb, var(--fg) 65%, transparent); font-size: 12px; font-weight: 600; } +.ge-hex-input { flex: 1 1 auto; min-width: 0; background: transparent; border: 0; outline: 0; color: var(--fg); font: inherit; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase; padding: 0; } +.ge-gradient-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(56px, 1fr)); + gap: 6px; +} +.ge-gradient-swatch { + height: 24px; + border-radius: 5px; + border: 1px solid var(--border, #2a313c); + cursor: pointer; + padding: 0; +} +.ge-channel-list { + display: flex; + flex-direction: column; + gap: 2px; + max-height: 220px; + overflow-y: auto; +} +.ge-channel-row { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 6px; + border-radius: 5px; + cursor: pointer; + font-size: 12px; +} +.ge-channel-row.active { + background: color-mix(in srgb, var(--accent, #4c8bf5) 22%, transparent); +} +.ge-channel-eye { + width: 20px; + text-align: center; + opacity: .8; +} +.ge-channel-name { + flex: 1 1 auto; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ge-histogram-canvas, +.ge-navigator-canvas { + width: 100%; + background: #0b0d10; + border: 1px solid var(--border, #2a313c); + border-radius: 5px; +} +.ge-navigator-canvas { + cursor: grab; + touch-action: none; +} +.ge-navigator-canvas:active { + cursor: grabbing; +} +.ge-info-rows { + display: flex; + flex-direction: column; + gap: 4px; +} +.ge-info-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 12px; +} +.ge-info-label { + color: var(--color-muted, #8b93a1); +} +.ge-info-value { + display: flex; + align-items: center; + gap: 6px; + font-variant-numeric: tabular-nums; +} +.ge-info-swatch { + width: 14px; + height: 14px; + border-radius: 3px; + border: 1px solid var(--border, #2a313c); + display: inline-block; +} +/* Command palette (Phase 11) */ +.ge-command-palette { + position: fixed; + inset: 0; + z-index: 10100; + display: flex; + align-items: flex-start; + justify-content: center; + padding-top: 12vh; + background: rgba(6, 8, 11, 0.45); +} +.ge-command-palette[hidden] { + display: none; +} +.ge-command-panel { + width: min(520px, 92vw); + max-height: 60vh; + display: flex; + flex-direction: column; + background: var(--panel, #161a21); + border: 1px solid var(--border, #2a313c); + border-radius: 10px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); + overflow: hidden; +} +.ge-command-input { + width: 100%; + box-sizing: border-box; + padding: 12px 14px; + border: 0; + border-bottom: 1px solid var(--border, #2a313c); + background: transparent; + color: var(--fg, #e6e9ef); + font-size: 14px; + outline: none; +} +.ge-command-list { + overflow-y: auto; + padding: 6px; + display: flex; + flex-direction: column; + gap: 2px; +} +.ge-command-item { + text-align: left; + padding: 8px 10px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--fg, #e6e9ef); + font-size: 13px; + cursor: pointer; +} +.ge-command-item.active, +.ge-command-item:hover { + background: var(--accent, #4c8bf5); + color: #fff; +} +.ge-command-empty { + margin: 0; + padding: 12px; + color: var(--color-muted, #8b93a1); + font-size: 13px; + text-align: center; +} +/* Extra studio dock panels (Properties / Character / Paths / Patterns / Adjustments / History) */ +.ge-panel-body { + display: flex; + flex-direction: column; + gap: 8px; + padding: 8px 10px; +} +.ge-text-align-row { + display: flex; + gap: 4px; +} +.ge-path-list { + gap: 4px; +} +.ge-adjustment-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 3px; + padding: 4px 6px; +} +.ge-adjustment-grid .ge-btn { + width: 100%; + text-align: left; +} +/* Actions panel (Phase 11) */ +.ge-actions-name-row, +.ge-actions-add-row, +.ge-actions-controls, +.ge-actions-batch-row { + display: flex; + gap: 6px; +} +.ge-actions-batch-select { + flex: 1 1 auto; + min-width: 0; +} +.ge-actions-name { + flex: 1 1 auto; + min-width: 0; +} +.ge-actions-command { + flex: 1 1 auto; + min-width: 0; +} +.ge-actions-list { + display: flex; + flex-direction: column; + gap: 4px; + max-height: 240px; + overflow-y: auto; +} +.ge-actions-step, +.ge-actions-saved-row { + display: flex; + align-items: center; + gap: 6px; +} +.ge-actions-step-index { + width: 18px; + color: var(--color-muted, #8b93a1); + font-variant-numeric: tabular-nums; +} +.ge-actions-step-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.ge-action-remove { + border: none; + background: none; + color: var(--color-muted, #8b93a1); + cursor: pointer; +} +.ge-actions-controls .ge-btn.active { + border-color: var(--accent, #4c8bf5); + color: var(--accent, #4c8bf5); +} + +/* Studio dock panels (PhotoCraft parity: Properties/Character/Adjustments/Paths/Patterns/History) */ +.ge-panel-body { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px 10px; +} +.ge-text-align-row { + display: flex; + gap: 4px; +} +.ge-adjustment-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 3px; + padding: 4px 6px; +} +.ge-adjustment-grid .ge-btn { + width: 100%; + justify-content: flex-start; + font-size: 11px; +} +.ge-path-list { + gap: 4px; +} + +/* ── Full-viewport editor (Photoshop-style) ────────────────────────────── + The editor opens filling the whole viewport instead of the small + gallery sheet. `#ge-maximize-toggle` flips to a remembered windowed + mode by adding `.ge-editor-windowed` to #gallery-editor-container. */ +.gallery-modal-content:has(#gallery-editor-container:not(.ge-editor-windowed)) { + width: 100vw; + max-width: 100vw; + height: 100vh; + max-height: 100vh; + margin: 0; + border-radius: 0; +} +#gallery-modal:has(#gallery-editor-container:not(.ge-editor-windowed)) { + padding: 0; + align-items: stretch; + /* Escape the desktop "chat area" narrowing (left = icon-rail + sidebar) so + the editor really fills the whole viewport, over the nav. */ + left: 0; + width: 100vw; +} +#ge-maximize-toggle.is-windowed { color: var(--fg); } + +/* Multi-document editor tabs live in the gallery tab bar. The container is + display:contents so each extra tab is a direct flex child of .gallery-tabs, + matching the primary #gallery-editor-tab. */ +.gallery-editor-tabs-extra { display: contents; } +.gallery-tab.gallery-tab-new { + min-width: 26px; + justify-content: center; + font-size: 16px; + font-weight: 600; + opacity: 0.75; +} +.gallery-tab.gallery-tab-new:hover { opacity: 1; } + +/* Draggable dialog grab bars (Image Size / canvas-size prompt). */ +.ge-draggable-handle { + cursor: move; + user-select: none; + touch-action: none; +} +.ge-draggable-handle:active { cursor: grabbing; } + +/* ============================================================================ + * Editor control kit + * ---------------------------------------------------------------------------- + * One shared, theme-token style for the interactive controls scattered across + * the editor (options bar, adjustment dialogs, Image/Canvas Size, transform + * popup, layers, brush settings, filters). control-kit.js adds the .ge-ctl* + * classes; these rules make every such control look like the rest of Odysseus + * (matches .cookbook-slider / .admin-slider / .toggle-switch). Older, more + * specific rules (transform popup, eraser/fx rows) still win on purpose. + * ==========================================================================*/ + +/* -- Range sliders --------------------------------------------------------*/ +.ge-ctl-range { + -webkit-appearance: none; + appearance: none; + max-width: 100%; + height: 14px; + margin: 0; + padding: 0; + background: transparent; + cursor: pointer; + vertical-align: middle; + --ge-pct: 50%; + --ge-accent: var(--accent, var(--red)); +} +.ge-ctl-range::-webkit-slider-runnable-track { + height: 6px; + border-radius: 4px; + background: linear-gradient( + to right, + var(--ge-accent) 0 var(--ge-pct), + color-mix(in srgb, var(--fg) 14%, transparent) var(--ge-pct) 100% + ); +} +.ge-ctl-range::-moz-range-track { + height: 6px; + border-radius: 4px; + background: color-mix(in srgb, var(--fg) 14%, transparent); +} +.ge-ctl-range::-moz-range-progress { + height: 6px; + border-radius: 4px; + background: var(--ge-accent); +} +.ge-ctl-range::-webkit-slider-thumb { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + margin-top: -4px; + border-radius: 50%; + background: var(--fg); + border: 2px solid var(--panel); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); + cursor: pointer; + transition: transform 0.1s, box-shadow 0.1s; +} +.ge-ctl-range::-moz-range-thumb { + width: 12px; + height: 12px; + border-radius: 50%; + background: var(--fg); + border: 2px solid var(--panel); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.35); + cursor: pointer; +} +.ge-ctl-range:hover::-webkit-slider-thumb { + transform: scale(1.15); + box-shadow: 0 0 0 4px color-mix(in srgb, var(--ge-accent) 22%, transparent); +} +.ge-ctl-range:active::-webkit-slider-thumb { + transform: scale(1.25); + box-shadow: 0 0 0 6px color-mix(in srgb, var(--ge-accent) 28%, transparent); +} +.ge-ctl-range:hover::-moz-range-thumb { + box-shadow: 0 0 0 4px color-mix(in srgb, var(--ge-accent) 22%, transparent); +} +.ge-ctl-range:focus-visible { + outline: none; +} +.ge-ctl-range:focus-visible::-webkit-slider-thumb { + box-shadow: 0 0 0 3px var(--panel), 0 0 0 5px color-mix(in srgb, var(--ge-accent) 55%, transparent); +} +.ge-ctl-range:disabled { + opacity: 0.45; + cursor: default; +} +.ge-ctl-range:disabled::-webkit-slider-thumb { transform: none; box-shadow: none; } + +/* -- Number fields (no native spinners) -----------------------------------*/ +.ge-num-field { + position: relative; + display: inline-flex; + align-items: center; +} +.ge-ctl-num { + width: auto; + max-width: 100%; + height: 26px; + padding: 0 6px; + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; + font-size: 11px; + font-variant-numeric: tabular-nums; + text-align: right; + -moz-appearance: textfield; + appearance: textfield; + box-sizing: border-box; +} +.ge-ctl-num::-webkit-outer-spin-button, +.ge-ctl-num::-webkit-inner-spin-button { + -webkit-appearance: none; + margin: 0; +} +.ge-ctl-num:focus { + outline: none; + border-color: var(--accent, var(--red)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 25%, transparent); +} +.ge-ctl-num:disabled, +.ge-ctl-num[readonly] { opacity: 0.5; cursor: default; } + +/* Small stacked/horizontal stepper next to a number field */ +.ge-stepper { + display: inline-flex; + flex-direction: column; + margin-left: 3px; + border-radius: 4px; + overflow: hidden; + border: 1px solid var(--border); +} +.ge-stepper button { + width: 16px; + height: 12px; + padding: 0; + border: 0; + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: var(--fg); + font-size: 9px; + line-height: 1; + cursor: pointer; +} +.ge-stepper button:hover { background: var(--accent, var(--red)); color: var(--panel); } +.ge-stepper button:active { background: color-mix(in srgb, var(--accent, var(--red)) 75%, #000); color: var(--panel); } + +/* -- Selects --------------------------------------------------------------*/ +.ge-ctl-select { + height: 26px; + max-width: 100%; + padding: 0 22px 0 8px; + background-color: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; + font-size: 11px; + appearance: none; + -webkit-appearance: none; + background-image: linear-gradient(45deg, transparent 50%, currentColor 50%), + linear-gradient(135deg, currentColor 50%, transparent 50%); + background-position: calc(100% - 12px) 11px, calc(100% - 8px) 11px; + background-size: 4px 4px, 4px 4px; + background-repeat: no-repeat; + cursor: pointer; +} +.ge-ctl-select:hover { border-color: color-mix(in srgb, var(--fg) 45%, var(--border)); } +.ge-ctl-select:focus { + outline: none; + border-color: var(--accent, var(--red)); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 25%, transparent); +} +.ge-ctl-select option { background: var(--select-option-bg, var(--panel)); color: var(--select-option-fg, var(--fg)); } + +/* -- Checkboxes -----------------------------------------------------------*/ +input.ge-ctl-check { + appearance: none; + -webkit-appearance: none; + width: 14px; + height: 14px; + margin: 0; + border: 1px solid var(--border); + border-radius: 3px; + background: var(--bg); + cursor: pointer; + position: relative; + vertical-align: middle; + flex: 0 0 auto; +} +input.ge-ctl-check:hover { border-color: color-mix(in srgb, var(--fg) 45%, var(--border)); } +input.ge-ctl-check:checked { + background: var(--accent, var(--red)); + border-color: var(--accent, var(--red)); +} +input.ge-ctl-check:checked::after { + content: ''; + position: absolute; + left: 4px; + top: 1px; + width: 3px; + height: 7px; + border: solid var(--panel); + border-width: 0 2px 2px 0; + transform: rotate(45deg); +} +input.ge-ctl-check:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); +} + +/* -- Small icon/text buttons ----------------------------------------------*/ +.ge-ctl-btn { + height: 24px; + min-width: 24px; + padding: 0 8px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + background: color-mix(in srgb, var(--fg) 6%, transparent); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 4px; + font: inherit; + font-size: 11px; + cursor: pointer; +} +.ge-ctl-btn:hover { background: color-mix(in srgb, var(--fg) 14%, transparent); } +.ge-ctl-btn:active { background: color-mix(in srgb, var(--accent, var(--red)) 30%, transparent); } +.ge-ctl-btn:focus-visible { + outline: none; + box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, var(--red)) 35%, transparent); +} +.ge-ctl-btn:disabled { opacity: 0.45; cursor: default; } + +/* Scrubby numeric label — drag horizontally to change the value */ +.ge-scrub { + cursor: ew-resize; + user-select: none; + touch-action: none; +} +.ge-scrub::after { content: '\2194'; font-size: 8px; opacity: 0.4; margin-left: 3px; } +.ge-scrub.ge-scrubbing { color: var(--accent, var(--red)); } + +/* On touch/mobile the topbar menus are icon/touch driven — hide keyboard shortcut hints. */ +@media (max-width: 820px) { + .ge-topbar .dropdown .dropdown-shortcut { display: none; } +} + +/* Editor landing ("Image Editor" chooser + Saved projects) — keep it + scrollable at EVERY width, not just <=620px. The base rule centres the + content vertically (justify-content: center) with no overflow; once the + saved-project list grows taller than the sheet (worst in list view), flex + centring pushes the launch controls AND the grid/list toggle ABOVE the + sheet, while #gallery-editor-container's overflow: hidden makes that top + region unreachable — the user could not scroll back and got stuck. Using + auto margins to centre collapses them to 0 on overflow, so the top stays + reachable while short content still looks vertically centred. */ +.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) .gallery-editor-landing { + justify-content: flex-start; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} +.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) .gallery-editor-landing::before, +.gallery-modal-content:has(#gallery-editor-container[style*="flex"]) .gallery-editor-landing::after { + content: ''; + flex: 0 0 auto; + margin: auto; +} + +/* Tool-aware canvas right-click menu (canvas-context-menu.js). */ +.ge-canvas-menu { + position: fixed; + z-index: 100000; + min-width: 208px; + padding: 5px; + background: #2b2b2b; + border: 1px solid #454545; + border-radius: 6px; + box-shadow: 0 10px 32px rgba(0, 0, 0, 0.55); + font-size: 13px; + color: #e6e6e6; + user-select: none; +} +.ge-canvas-menu-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 6px 10px; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + font: inherit; + text-align: left; + cursor: pointer; +} +.ge-canvas-menu-item:hover:not([disabled]), +.ge-canvas-menu-item:focus-visible:not([disabled]) { + background: #3d6ea5; + color: #fff; + outline: none; +} +.ge-canvas-menu-item[disabled] { + color: #6d6d6d; + cursor: default; +} +.ge-canvas-menu-lead { + width: 12px; + flex: 0 0 12px; + text-align: center; + color: #9ecbff; +} +.ge-canvas-menu-label { flex: 1 1 auto; white-space: nowrap; } +.ge-canvas-menu-hint { + flex: 0 0 auto; + margin-left: 20px; + color: #9a9a9a; + font-size: 11px; +} +.ge-canvas-menu-item:hover .ge-canvas-menu-hint { color: #d8e6f5; } +.ge-canvas-menu-caret { flex: 0 0 auto; margin-left: 16px; color: #b8b8b8; } +.ge-canvas-menu-sep { + height: 1px; + margin: 4px 6px; + background: #454545; +} +.ge-canvas-menu-sub { min-width: 172px; max-height: 60vh; overflow-y: auto; } + +/* Editor setup checklist (preflight) — shown over the canvas on open. */ +.ge-preflight { + position: absolute; + inset: 0; + z-index: 260; + display: flex; + align-items: center; + justify-content: center; + padding: 16px; + background: rgba(8, 10, 14, 0.55); + backdrop-filter: blur(2px); +} +.ge-preflight[hidden] { display: none; } +.ge-preflight-card { + width: min(560px, 100%); + max-height: 100%; + overflow: auto; + background: #23262c; + color: #e8eaed; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 12px; + box-shadow: 0 18px 50px rgba(0, 0, 0, 0.5); + padding: 16px 18px; +} +.ge-preflight-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } +.ge-preflight-head h3 { margin: 0 0 3px; font-size: 15px; } +.ge-preflight-head p { margin: 0; font-size: 12px; color: #a9b0ba; } +.ge-preflight-close { + background: transparent; border: 0; color: #a9b0ba; font-size: 20px; line-height: 1; + cursor: pointer; padding: 2px 6px; border-radius: 6px; +} +.ge-preflight-close:hover { background: rgba(255, 255, 255, 0.08); color: #fff; } +.ge-preflight-rows { display: flex; flex-direction: column; gap: 8px; margin: 14px 0; } +.ge-preflight-row { + display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; border-radius: 9px; + background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.06); +} +.ge-preflight-row.is-done { border-color: rgba(90, 200, 130, 0.35); } +.ge-preflight-row.is-todo { border-color: rgba(230, 120, 110, 0.4); } +.ge-preflight-badge { + flex: 0 0 auto; width: 20px; height: 20px; border-radius: 50%; + display: flex; align-items: center; justify-content: center; + font-size: 12px; font-weight: 700; margin-top: 1px; +} +.ge-preflight-row.is-done .ge-preflight-badge { background: rgba(70, 180, 110, 0.2); color: #5fd08a; } +.ge-preflight-row.is-todo .ge-preflight-badge { background: rgba(220, 90, 80, 0.18); color: #ff8a7d; } +.ge-preflight-row.is-optional { border-color: rgba(210, 180, 90, 0.35); } +.ge-preflight-row.is-optional .ge-preflight-badge { background: rgba(210, 180, 90, 0.16); color: #e0c266; } +.ge-preflight-opt { + margin-left: 6px; + padding: 1px 6px; + border-radius: 999px; + background: rgba(255, 255, 255, 0.08); + color: var(--fg-muted); + font-size: 9px; + letter-spacing: 0.05em; + text-transform: uppercase; + vertical-align: middle; +} +.ge-preflight-body { flex: 1 1 auto; min-width: 0; } +.ge-preflight-title { font-size: 13px; font-weight: 600; } +.ge-preflight-hint { font-size: 11.5px; color: #a9b0ba; margin-top: 2px; } +.ge-preflight-detail { font-size: 11px; color: #7f8792; margin-top: 3px; font-style: italic; } +.ge-preflight-actions { flex: 0 0 auto; display: flex; align-items: center; gap: 8px; } +.ge-preflight-act { + background: rgba(255, 255, 255, 0.08); border: 1px solid rgba(255, 255, 255, 0.14); + color: #dfe3e8; font-size: 11px; padding: 4px 9px; border-radius: 7px; cursor: pointer; white-space: nowrap; +} +.ge-preflight-act:hover { background: rgba(255, 255, 255, 0.16); } +.ge-preflight-manual { display: flex; align-items: center; cursor: pointer; } +.ge-preflight-foot { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + border-top: 1px solid rgba(255, 255, 255, 0.08); padding-top: 12px; +} +.ge-preflight-remember { font-size: 11.5px; color: #a9b0ba; display: flex; align-items: center; gap: 6px; cursor: pointer; } +.ge-preflight-foot-actions { display: flex; align-items: center; gap: 10px; } +.ge-preflight-summary { font-size: 11.5px; color: #8fd6a8; } +.ge-preflight-start { + background: #3d6ea5; border: 0; color: #fff; font-size: 12.5px; font-weight: 600; + padding: 7px 16px; border-radius: 8px; cursor: pointer; +} +.ge-preflight-start:hover { background: #4a80bd; } +.ge-setup-btn { + background: transparent; border: 1px solid rgba(255, 255, 255, 0.16); color: #c8cdd4; + font-size: 11px; padding: 2px 8px; border-radius: 6px; cursor: pointer; margin-left: 8px; +} +.ge-setup-btn:hover { background: rgba(255, 255, 255, 0.1); color: #fff; } +@media (max-width: 700px) { + .ge-preflight-card { padding: 13px 14px; } +} + +/* ── Quick Edit — premium look ────────────────────────────────────────── */ +@property --ge-qe-angle { + syntax: ''; + initial-value: 0deg; + inherits: false; +} +.ge-ai-command { + border: 1px solid transparent; + border-radius: 15px; + padding: 9px; + background: + linear-gradient( + color-mix(in srgb, var(--panel, #232323) 95%, transparent), + color-mix(in srgb, var(--panel, #232323) 90%, transparent) + ) padding-box, + conic-gradient(from 215deg, + color-mix(in srgb, var(--accent, var(--red)) 78%, transparent), + color-mix(in srgb, #7aa2ff 52%, transparent) 34%, + color-mix(in srgb, var(--accent, var(--red)) 18%, transparent) 58%, + color-mix(in srgb, var(--accent, var(--red)) 78%, transparent) + ) border-box; + box-shadow: + 0 16px 44px color-mix(in srgb, #000 46%, transparent), + 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 10%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 9%, transparent); + backdrop-filter: blur(16px) saturate(1.25); +} +.ge-ai-command::before { + content: ""; + position: absolute; + inset: -1px; + z-index: 0; + border-radius: inherit; + padding: 1px; + background: conic-gradient(from var(--ge-qe-angle), + transparent 0 60%, + color-mix(in srgb, var(--accent, var(--red)) 88%, transparent) 78%, + color-mix(in srgb, #d6e3ff 96%, transparent) 84%, + transparent 92%); + -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0); + -webkit-mask-composite: xor; + mask-composite: exclude; + opacity: 0.8; + pointer-events: none; + animation: ge-qe-arc 7s linear infinite; +} +@keyframes ge-qe-arc { to { --ge-qe-angle: 360deg; } } +.ge-ai-command-head { padding-bottom: 8px; } +.ge-ai-command-title { + letter-spacing: 0.03em; + background: linear-gradient(90deg, + color-mix(in srgb, var(--fg) 92%, #fff), + color-mix(in srgb, var(--accent, var(--red)) 70%, var(--fg))); + -webkit-background-clip: text; background-clip: text; color: transparent; +} +.ge-ai-command .ge-btn-ai-mark { + background: linear-gradient(135deg, + color-mix(in srgb, var(--accent, var(--red)) 100%, #fff 22%), + #9ec1ff); + -webkit-background-clip: text; background-clip: text; color: transparent; + animation: ge-qe-spark 3.6s ease-in-out infinite; +} +@keyframes ge-qe-spark { + 0%, 100% { opacity: 0.82; transform: scale(1) rotate(0); } + 50% { opacity: 1; transform: scale(1.14) rotate(18deg); } +} +.ge-ai-command-input { + height: 36px; + border-radius: 10px; + border-color: color-mix(in srgb, var(--accent, var(--red)) 22%, var(--border)); + background: + linear-gradient(color-mix(in srgb, #fff 5%, transparent), transparent 55%), + color-mix(in srgb, var(--bg) 72%, #000 28%); + box-shadow: inset 0 1px 2px color-mix(in srgb, #000 42%, transparent); + transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease; +} +.ge-ai-command-input:focus { + border-color: color-mix(in srgb, var(--accent, var(--red)) 82%, transparent); + box-shadow: + inset 0 1px 2px color-mix(in srgb, #000 42%, transparent), + 0 0 0 3px color-mix(in srgb, var(--accent, var(--red)) 22%, transparent); +} +.ge-ai-command-clear { height: 36px; } +.ge-ai-command-run { + height: 36px !important; + min-width: 36px; + width: 36px; + border-radius: 10px; + background: linear-gradient(135deg, + color-mix(in srgb, var(--accent, var(--red)) 100%, #fff 14%), + color-mix(in srgb, var(--accent, var(--red)) 62%, #6f8dff)); + box-shadow: + 0 6px 18px color-mix(in srgb, var(--accent, var(--red)) 46%, transparent), + inset 0 1px 0 color-mix(in srgb, #fff 32%, transparent); + transition: transform 0.12s ease, box-shadow 0.16s ease, filter 0.16s ease, background 0.16s ease; +} +.ge-ai-command-run:hover { + background: linear-gradient(135deg, + color-mix(in srgb, var(--accent, var(--red)) 100%, #fff 20%), + color-mix(in srgb, var(--accent, var(--red)) 60%, #7a95ff)); + filter: brightness(1.06); + transform: translateY(-1px); + box-shadow: 0 9px 24px color-mix(in srgb, var(--accent, var(--red)) 58%, transparent); +} +.ge-ai-command-run:active { transform: translateY(0); filter: brightness(0.98); } +.ge-ai-command-suggestions { + border-radius: 12px; + border-color: color-mix(in srgb, var(--accent, var(--red)) 26%, var(--border)); + background: color-mix(in srgb, var(--panel, #232323) 97%, transparent); + box-shadow: 0 16px 36px color-mix(in srgb, #000 42%, transparent); +} +.ge-ai-command-suggestion { + border-radius: 8px; + transition: background 0.12s ease, transform 0.12s ease; +} +.ge-ai-command-suggestion:hover, +.ge-ai-command-suggestion.active { + background: color-mix(in srgb, var(--accent, var(--red)) 16%, transparent); + transform: translateX(1px); +} +.ge-ai-command-status { font-weight: 600; } +.ge-ai-command-collapsed { + border-radius: 999px; + padding: 3px 13px; +} +.ge-ai-command-collapsed:hover, +.ge-ai-command-collapsed:focus-within { + box-shadow: + 0 10px 26px color-mix(in srgb, #000 30%, transparent), + 0 0 0 1px color-mix(in srgb, var(--accent, var(--red)) 30%, transparent); +} +@media (prefers-reduced-motion: reduce) { + .ge-ai-command::before, + .ge-ai-command .ge-btn-ai-mark { animation: none; } +} + +/* Quick Edit routing-mode selector (Auto / Quick / Agent / Image): uses the + standard ctl-select look, pushed to the right of the header row. */ +.ge-ai-command .ge-ai-command-mode { + margin-left: auto; + margin-right: 6px; + max-width: 104px; +} + +/* Mobile footer fix (audit 2026-10-11 #1): the footer overflowed horizontally + (scrollWidth 532 vs clientWidth 378) so the zoom controls sat off-screen. + On phones drop the wide text readouts and tighten the controls so history + + zoom fit. */ +@media (max-width: 700px) { + .ge-editor-footer { + gap: 6px; + padding: 3px 6px; + overflow: hidden; + min-width: 0; + } + .ge-editor-footer .ge-status-tool, + .ge-editor-footer .ge-status-model, + .ge-editor-footer .ge-canvas-size { + display: none; + } + .ge-footer-history { margin-right: 2px; } + .ge-footer-zoom { gap: 3px; min-width: 0; } + .ge-footer-zoom > .ge-btn, + .ge-footer-history > .ge-btn { min-width: 26px; } + /* Float the footer above the peek/expanded layers sheet so its zoom/setup + controls stay tappable (position/left/right/bottom set in JS). */ + .ge-editor-footer.ge-footer-floating { + border: 1px solid var(--border); + border-radius: 8px; + background: var(--panel); + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.28); + } +} + +/* EVAL: an editor toast fired while a floating panel is open (Adjust/Develop, + FX, Transform, filter modal, History) is moved to the bottom-centre by + ui.js so it never covers the panel's controls. */ +body.ge-editor-open .toast.toast-editor-bottom { + top: auto; + bottom: 84px; + left: 12px; + right: 12px; + max-width: none; + min-width: 0; +} + +/* Audit #7 / EVAL: on a phone, a toast at the viewport top overlaps the + editor's menu row AND the top-docked Transform/Adjust/FX popups (Develop, + Filters). While the editor is open, anchor shared toasts at the bottom, + centred just above the floating footer, so they never cover the controls. + The horizontal slide animation is preserved (the toast stays full-width). */ +@media (max-width: 700px) { + body.ge-editor-open .toast { + top: auto; + bottom: 84px; + left: 12px; + right: 12px; + max-width: none; + min-width: 0; + } +} + +/* Mobile touch targets (audit 2026-10-11 #3): keep the primary editor controls + at least 44x44 CSS px for a comfortable tap. Scoped to the open editor on + phones — desktop is untouched. */ +@media (max-width: 700px) { + .ge-toolbar .ge-tool-btn { + min-width: 44px; + min-height: 44px; + height: 44px; + } + /* Tap targets: only the primary in-sheet controls (layers footer, editor + footer, panels overlay) get the full 44px box. Applying this to every + editor button inflated the top menu row and clipped its labels, so the + topbar/menu buttons keep their natural size. */ + .ge-layers-footer .ge-btn, + .ge-editor-footer .ge-btn, + .ge-mobile-panels .ge-btn { + min-height: 44px; + min-width: 44px; + } + body.ge-editor-open .ge-ai-command-toggle, + body.ge-editor-open .ge-setup-btn { + min-height: 44px; + } + body.ge-editor-open .ge-layer-opacity, + body.ge-editor-open .ge-ctl-select, + body.ge-editor-open .ge-ctl-num { + min-height: 44px; + } + /* Horizontal-scroll fix (audit 2026-10-11 #4): the menu row and tool rail stay + single-row (wrapping them made the chrome 2-3x taller), but their sideways + scroll is contained so it never drags the page or overshoots the fade. */ + .ge-topbar-left, + .ge-toolbar, + .ge-dock-tabs { + overscroll-behavior-x: contain; + -webkit-overflow-scrolling: touch; + } +} + + +/* Colour panels: chips are round and the redundant inner title is dropped + (the dock tab strip already names each panel). Adjustments get SVG icons. */ +.ge-mini-swatch, +.ge-swatch-grid .ge-swatch, +.ge-swatch-body .ge-color-input.cp-swatch-input { + border-radius: 50%; +} +.ge-gradient-swatch { border-radius: 999px; } +#ge-panel-swatches > .ge-panel-title, +#ge-panel-gradients > .ge-panel-title, +#ge-panel-patterns > .ge-panel-title, +#ge-panel-adjustments > .ge-panel-title, +#ge-panel-properties > .ge-panel-title, +#ge-panel-histogram > .ge-panel-title { + display: none; +} +.ge-adjustment-grid .ge-adj-btn { + display: flex; + align-items: center; + gap: 5px; + min-height: 0; + padding: 3px 6px; +} +.ge-adj-icon { display: inline-flex; flex: 0 0 auto; } +.ge-adj-icon svg { width: 14px; height: 14px; display: block; } +.ge-adj-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } + +/* Mobile panel access (≤ 700 px). The desktop dock is bypassed on phones, so + the studio panels are mounted in a compact overlay opened from a "Panels" + button in the layers header (see right-panel.js). */ +@media (max-width: 700px) { + .ge-layers-panels-btn { + order: 1; + margin-left: 8px; + min-height: 44px; + min-width: 44px; + padding: 0 12px; + border-radius: 8px; + } + .ge-mobile-panels { + position: fixed; + left: 0; + right: 0; + bottom: 0; + z-index: 70; + display: flex; + flex-direction: column; + max-height: 62vh; + background: var(--panel, #1b1f27); + border-top: 1px solid var(--border, #2a313c); + border-radius: 12px 12px 0 0; + box-shadow: 0 -6px 20px rgba(0, 0, 0, 0.35); + overflow: hidden; + } + .ge-mobile-panels[hidden] { display: none; } + .ge-mobile-panels-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + border-bottom: 1px solid var(--border, #2a313c); + } + .ge-mobile-panels-title { + font-size: 12px; + letter-spacing: 0.06em; + text-transform: uppercase; + opacity: 0.7; + } + .ge-mobile-panels-close { min-width: 44px; min-height: 44px; } + .ge-mobile-panels .ge-dock-tabbar { padding: 8px 12px 4px; } + .ge-mobile-panels .ge-dock-select { width: 100%; min-height: 44px; } + .ge-mobile-panels .ge-dock-panels { overflow-y: auto; padding: 4px 12px 16px; } +} + +/* Mobile layer rows: only the ACTIVE layer shows its opacity slider, and the + slider stays a thin fixed-width track instead of stretching edge-to-edge + over the thumbnail and the fx/action strip. The panel's Blend/Opacity row is + the primary touch control for opacity. */ +@media (max-width: 700px) { + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item) > .ge-layer-opacity-row { + display: none; + } + .ge-layers-list > .ge-layer-item:not(.ge-adj-sub-item):not(.ge-mask-sub-item).active > .ge-layer-opacity-row { + display: flex; + } + body.ge-editor-open .ge-layer-item .ge-layer-opacity { + min-height: 0 !important; + height: 18px !important; + flex: 0 0 auto !important; + width: 140px !important; + } +} + +/* Mobile layers footer: keep the add / fx / merge / Fill tools on ONE row + (they were wrapping and orphaning "Fill" on its own line). */ +@media (max-width: 700px) { + .ge-layers-footer { + flex-wrap: nowrap; + gap: 4px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + .ge-layers-footer .ge-layer-header-tools, + .ge-layers-footer .ge-layer-tools { + flex-wrap: nowrap; + gap: 3px; + } + /* Spread the tool row across the full sheet width instead of leaving a + big empty right gutter (the empty #ge-layer-tools hook used to hold the + right edge via space-between). */ + .ge-layers-footer .ge-layer-header-tools { + flex: 1 1 auto; + justify-content: space-between; + } + .ge-layers-footer .ge-layer-tools:empty { display: none; } + .ge-layers-footer .ge-btn { + min-width: 40px !important; + flex: 0 0 auto; + } + .ge-layers-footer #ge-layer-fill { + min-width: 52px !important; + width: auto !important; + padding: 0 10px !important; + } +} + +/* Tool-rail "more tools" chevron. The rail is wrapped (see galleryEditor.js) + so an overlay chevron can sit on its right edge on phones, outside the + scrolling element (and thus outside its mask fade). */ +.ge-toolbar-wrap { display: contents; } +.ge-tool-more { display: none; } +@media (max-width: 700px) { + .ge-toolbar-wrap { + display: flex; + flex: 0 0 auto; + width: 100%; + position: relative; + align-items: stretch; + } + .ge-toolbar-wrap > .ge-toolbar { flex: 1 1 auto; min-width: 0; } + .ge-tool-more { + display: none; + position: absolute; + top: 0; + bottom: 0; + right: 0; + width: 22px; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + color: var(--fg, #e6e9ef); + font-size: 20px; + line-height: 1; + cursor: pointer; + z-index: 2; + } + .ge-tool-more.is-visible { display: flex; } + /* Keep the floating editor footer from stacking on the Quick Edit card. The + card owns the bottom of the canvas while it is open, so the footer (zoom / + Setup / AI settings) hides until it closes. */ + body:has(.ge-ai-command:not(.ge-ai-command-collapsed)) .ge-editor-footer.ge-footer-floating { + display: none; + } +} + +/* On phones the Quick Edit card sits between the tool rail and the peek layers + sheet, so the suggestion popup cannot grow upward without being clipped by + the rail. Show the suggestions as a compact horizontal chip row inside the + card instead. */ +@media (max-width: 700px) { + .ge-ai-command:not(.ge-ai-command-collapsed) { + display: flex; + flex-direction: column; + } + .ge-ai-command-head { order: 0; } + .ge-ai-command-suggestions { + position: static; + order: 1; + left: auto; + right: auto; + bottom: auto; + z-index: auto; + flex-direction: row; + flex-wrap: nowrap; + gap: 6px; + max-height: none; + padding: 0 0 6px; + border: 0; + border-radius: 0; + background: transparent; + box-shadow: none; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-width: none; + } + .ge-ai-command-suggestions::-webkit-scrollbar { display: none; } + .ge-ai-command-form { order: 2; } + .ge-ai-command-status { order: 3; } + .ge-ai-command-stack { order: 4; } + .ge-ai-command-progress { order: 5; } + .ge-ai-command-suggestion { + width: auto; + flex: 0 0 auto; + grid-template-columns: none; + gap: 0; + min-height: 30px; + padding: 5px 11px; + border: 1px solid color-mix(in srgb, var(--accent, var(--red)) 32%, var(--border)); + border-radius: 16px; + background: color-mix(in srgb, var(--panel, var(--bg)) 82%, transparent); + } + .ge-ai-command-suggestion-icon, + .ge-ai-command-suggestion-kind, + .ge-ai-command-suggestion-hint { + display: none; + } + .ge-ai-command-suggestion-main { + white-space: nowrap; + font-size: 11px; + font-weight: 550; + } +} + +/* While the editor is open on a phone the app-shell chat selector sits right + above the editor chrome; lift it above the 70% default so it stays legible. */ +@media (max-width: 700px) { + body.ge-editor-open .chat-meta-overlay { + display: none !important; + } + body.ge-editor-open .tour-hint { + display: none !important; + } + body.ge-editor-open { + font-family: var(--font-family, 'Fira Code', monospace); + } +} + +/* Phone menu bar: tighten the Photoshop menus and keep the Save control + icon-only so the twelve menus + Import + Save are not crowded. */ +@media (max-width: 700px) { + .ge-topbar-left .ge-stacked-btn { + font-size: 11px; + padding: 0 6px !important; + } + .ge-topbar.ge-topbar-compact #ge-draft-status { + display: none; + } +} + +/* Phone editor: dock the zoom/Setup footer flush to the Layers sheet. The + footer spans the full width with square corners so canvas bottom == footer + top == sheet top, with no rounded-card seam or dead space between them, and + the sheet's grab handle (which would read as a stray strip) is hidden. */ +@media (max-width: 700px) { + .ge-editor-footer.ge-footer-floating { + border-radius: 0; + border-left: 0; + border-right: 0; + border-bottom: 0; + } + body.ge-footer-docked .ge-right-panel .ge-layers-grab { + display: none !important; + } +} + +/* Long dropdown menus (Filter especially) can grow past the viewport bottom; + cap them to the space below the topbar and let them scroll. */ +.ge-image-menu, +.ge-layer-menu, +.ge-filter-menu, +.ge-edit-menu, +.ge-selection-menu, +.ge-type-menu, +.ge-3d-menu, +.ge-view-menu, +.ge-window-menu, +.ge-plugins-menu, +.ge-help-menu { + max-height: min(560px, 70vh) !important; + overflow-y: auto !important; + overscroll-behavior: contain; + scrollbar-width: auto !important; + scrollbar-color: auto !important; + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent); +} +.ge-image-menu::-webkit-scrollbar, +.ge-layer-menu::-webkit-scrollbar, +.ge-filter-menu::-webkit-scrollbar, +.ge-edit-menu::-webkit-scrollbar, +.ge-selection-menu::-webkit-scrollbar, +.ge-type-menu::-webkit-scrollbar, +.ge-3d-menu::-webkit-scrollbar, +.ge-view-menu::-webkit-scrollbar, +.ge-window-menu::-webkit-scrollbar, +.ge-plugins-menu::-webkit-scrollbar, +.ge-help-menu::-webkit-scrollbar { + width: 8px; + height: 8px; +} +.ge-image-menu::-webkit-scrollbar-track, +.ge-layer-menu::-webkit-scrollbar-track, +.ge-filter-menu::-webkit-scrollbar-track, +.ge-edit-menu::-webkit-scrollbar-track, +.ge-selection-menu::-webkit-scrollbar-track, +.ge-type-menu::-webkit-scrollbar-track, +.ge-3d-menu::-webkit-scrollbar-track, +.ge-view-menu::-webkit-scrollbar-track, +.ge-window-menu::-webkit-scrollbar-track, +.ge-plugins-menu::-webkit-scrollbar-track, +.ge-help-menu::-webkit-scrollbar-track { + background: var(--panel); +} +.ge-image-menu::-webkit-scrollbar-thumb, +.ge-layer-menu::-webkit-scrollbar-thumb, +.ge-filter-menu::-webkit-scrollbar-thumb, +.ge-edit-menu::-webkit-scrollbar-thumb, +.ge-selection-menu::-webkit-scrollbar-thumb, +.ge-type-menu::-webkit-scrollbar-thumb, +.ge-3d-menu::-webkit-scrollbar-thumb, +.ge-view-menu::-webkit-scrollbar-thumb, +.ge-window-menu::-webkit-scrollbar-thumb, +.ge-plugins-menu::-webkit-scrollbar-thumb, +.ge-help-menu::-webkit-scrollbar-thumb { + background-color: var(--red); + border-radius: 4px; +} +.ge-image-menu::-webkit-scrollbar, +.ge-layer-menu::-webkit-scrollbar, +.ge-filter-menu::-webkit-scrollbar, +.ge-edit-menu::-webkit-scrollbar, +.ge-selection-menu::-webkit-scrollbar, +.ge-type-menu::-webkit-scrollbar, +.ge-3d-menu::-webkit-scrollbar, +.ge-view-menu::-webkit-scrollbar, +.ge-window-menu::-webkit-scrollbar, +.ge-plugins-menu::-webkit-scrollbar, +.ge-help-menu::-webkit-scrollbar { + width: 8px; +} +.ge-image-menu::-webkit-scrollbar-track, +.ge-layer-menu::-webkit-scrollbar-track, +.ge-filter-menu::-webkit-scrollbar-track, +.ge-edit-menu::-webkit-scrollbar-track, +.ge-selection-menu::-webkit-scrollbar-track, +.ge-type-menu::-webkit-scrollbar-track, +.ge-3d-menu::-webkit-scrollbar-track, +.ge-view-menu::-webkit-scrollbar-track, +.ge-window-menu::-webkit-scrollbar-track, +.ge-plugins-menu::-webkit-scrollbar-track, +.ge-help-menu::-webkit-scrollbar-track { + background: var(--panel); +} +.ge-image-menu::-webkit-scrollbar-thumb, +.ge-layer-menu::-webkit-scrollbar-thumb, +.ge-filter-menu::-webkit-scrollbar-thumb, +.ge-edit-menu::-webkit-scrollbar-thumb, +.ge-selection-menu::-webkit-scrollbar-thumb, +.ge-type-menu::-webkit-scrollbar-thumb, +.ge-3d-menu::-webkit-scrollbar-thumb, +.ge-view-menu::-webkit-scrollbar-thumb, +.ge-window-menu::-webkit-scrollbar-thumb, +.ge-plugins-menu::-webkit-scrollbar-thumb, +.ge-help-menu::-webkit-scrollbar-thumb { + background: var(--red); + border-radius: 4px; + border: 2px solid var(--panel); +} +/* Custom scroll indicator: macOS/headless browsers hide overlay scrollbars, so + draw a thin 3px thumb driven by scrollTop inside each dropdown menu. */ +.ge-menu-thumb { + position: absolute; + top: 0; + right: 2px; + width: 3px; + min-height: 18px; + border-radius: 2px; + background: var(--red); + pointer-events: none; + display: none; + z-index: 3; +} + +/* Busy veil shown while a heavy pixel filter commits on a large document. */ +.ge-busy-veil { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + z-index: 60; + pointer-events: none; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--fg); + font: inherit; + font-size: 12px; +} +.ge-busy-spin { + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, var(--fg) 25%, transparent); + border-top-color: var(--red); + border-radius: 50%; + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { + to { transform: rotate(360deg); } +} + +/* Flyout submenu panels: compact rows + a viewport-bounded height so long + lists (the 19 Image > Adjustments entries) all fit or scroll. */ +.ge-menu-parent + .ge-submenu .dropdown-item-compact { + min-height: 0 !important; + height: auto !important; + padding: 3px 10px !important; + line-height: 1.2 !important; + font-size: 12px !important; +} +.ge-submenu { + max-height: calc(100vh - 180px) !important; + overflow-y: auto !important; + scrollbar-width: auto; + scrollbar-color: auto; +} +.ge-submenu::-webkit-scrollbar { width: 8px; height: 8px; } +.ge-submenu::-webkit-scrollbar-track { background: var(--panel); } +.ge-submenu::-webkit-scrollbar-thumb { background: var(--red); border-radius: 4px; border: 2px solid var(--panel); } +.ge-filter-menu:has(.ge-menu-parent.open), +.ge-image-menu:has(.ge-menu-parent.open) { + overflow: visible !important; +} + +/* Flyout submenu panels: compact rows so long lists (e.g. the 19 Image > + Adjustments entries) fit without clipping, and let the panel scroll. */ +.ge-submenu .dropdown-item-compact { + padding-top: 2px; + padding-bottom: 2px; +} +.ge-submenu { + scrollbar-width: auto; + scrollbar-color: auto; +} +.ge-submenu::-webkit-scrollbar { width: 8px; height: 8px; } +.ge-submenu::-webkit-scrollbar-track { background: var(--panel); } +.ge-submenu::-webkit-scrollbar-thumb { background: var(--red); border-radius: 4px; border: 2px solid var(--panel); } +/* Release the parent menu's overflow clip while a flyout is open so the + panel is not cut off at the menu edge. */ +.ge-filter-menu:has(.ge-menu-parent.open), +.ge-image-menu:has(.ge-menu-parent.open) { + overflow: visible !important; +} + +/* The global "Pro tip" hint card can overlap the editor menus; hide it while + the editor is open. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* The global "Pro tip" hint card can overlap the editor menus; hide it while + the editor is open. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* The onboarding "Pro tip" tour hint floats over the editor menus; hide it + while the editor is open so it never occludes a dropdown or flyout. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* Long dropdown menus (Filter especially) can grow past the viewport bottom; + cap them to the space below the topbar and let them scroll. */ +.ge-image-menu, +.ge-layer-menu, +.ge-filter-menu, +.ge-edit-menu, +.ge-selection-menu, +.ge-type-menu, +.ge-3d-menu, +.ge-view-menu, +.ge-window-menu, +.ge-plugins-menu, +.ge-help-menu { + max-height: min(560px, 70vh) !important; + overflow-y: auto !important; + overscroll-behavior: contain; + scrollbar-width: auto !important; + scrollbar-color: auto !important; + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent); +} +.ge-image-menu::-webkit-scrollbar, +.ge-layer-menu::-webkit-scrollbar, +.ge-filter-menu::-webkit-scrollbar, +.ge-edit-menu::-webkit-scrollbar, +.ge-selection-menu::-webkit-scrollbar, +.ge-type-menu::-webkit-scrollbar, +.ge-3d-menu::-webkit-scrollbar, +.ge-view-menu::-webkit-scrollbar, +.ge-window-menu::-webkit-scrollbar, +.ge-plugins-menu::-webkit-scrollbar, +.ge-help-menu::-webkit-scrollbar { + width: 8px; + height: 8px; +} +.ge-image-menu::-webkit-scrollbar-track, +.ge-layer-menu::-webkit-scrollbar-track, +.ge-filter-menu::-webkit-scrollbar-track, +.ge-edit-menu::-webkit-scrollbar-track, +.ge-selection-menu::-webkit-scrollbar-track, +.ge-type-menu::-webkit-scrollbar-track, +.ge-3d-menu::-webkit-scrollbar-track, +.ge-view-menu::-webkit-scrollbar-track, +.ge-window-menu::-webkit-scrollbar-track, +.ge-plugins-menu::-webkit-scrollbar-track, +.ge-help-menu::-webkit-scrollbar-track { + background: var(--panel); +} +.ge-image-menu::-webkit-scrollbar-thumb, +.ge-layer-menu::-webkit-scrollbar-thumb, +.ge-filter-menu::-webkit-scrollbar-thumb, +.ge-edit-menu::-webkit-scrollbar-thumb, +.ge-selection-menu::-webkit-scrollbar-thumb, +.ge-type-menu::-webkit-scrollbar-thumb, +.ge-3d-menu::-webkit-scrollbar-thumb, +.ge-view-menu::-webkit-scrollbar-thumb, +.ge-window-menu::-webkit-scrollbar-thumb, +.ge-plugins-menu::-webkit-scrollbar-thumb, +.ge-help-menu::-webkit-scrollbar-thumb { + background-color: var(--red); + border-radius: 4px; +} +.ge-image-menu::-webkit-scrollbar, +.ge-layer-menu::-webkit-scrollbar, +.ge-filter-menu::-webkit-scrollbar, +.ge-edit-menu::-webkit-scrollbar, +.ge-selection-menu::-webkit-scrollbar, +.ge-type-menu::-webkit-scrollbar, +.ge-3d-menu::-webkit-scrollbar, +.ge-view-menu::-webkit-scrollbar, +.ge-window-menu::-webkit-scrollbar, +.ge-plugins-menu::-webkit-scrollbar, +.ge-help-menu::-webkit-scrollbar { + width: 8px; +} +.ge-image-menu::-webkit-scrollbar-track, +.ge-layer-menu::-webkit-scrollbar-track, +.ge-filter-menu::-webkit-scrollbar-track, +.ge-edit-menu::-webkit-scrollbar-track, +.ge-selection-menu::-webkit-scrollbar-track, +.ge-type-menu::-webkit-scrollbar-track, +.ge-3d-menu::-webkit-scrollbar-track, +.ge-view-menu::-webkit-scrollbar-track, +.ge-window-menu::-webkit-scrollbar-track, +.ge-plugins-menu::-webkit-scrollbar-track, +.ge-help-menu::-webkit-scrollbar-track { + background: var(--panel); +} +.ge-image-menu::-webkit-scrollbar-thumb, +.ge-layer-menu::-webkit-scrollbar-thumb, +.ge-filter-menu::-webkit-scrollbar-thumb, +.ge-edit-menu::-webkit-scrollbar-thumb, +.ge-selection-menu::-webkit-scrollbar-thumb, +.ge-type-menu::-webkit-scrollbar-thumb, +.ge-3d-menu::-webkit-scrollbar-thumb, +.ge-view-menu::-webkit-scrollbar-thumb, +.ge-window-menu::-webkit-scrollbar-thumb, +.ge-plugins-menu::-webkit-scrollbar-thumb, +.ge-help-menu::-webkit-scrollbar-thumb { + background: var(--red); + border-radius: 4px; + border: 2px solid var(--panel); +} +/* Custom scroll indicator: macOS/headless browsers hide overlay scrollbars, so + draw a thin 3px thumb driven by scrollTop inside each dropdown menu. */ +.ge-menu-thumb { + position: absolute; + top: 0; + right: 2px; + width: 3px; + min-height: 18px; + border-radius: 2px; + background: var(--red); + pointer-events: none; + display: none; + z-index: 3; +} + +/* Busy veil shown while a heavy pixel filter commits on a large document. */ +.ge-busy-veil { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + z-index: 60; + pointer-events: none; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--fg); + font: inherit; + font-size: 12px; +} +.ge-busy-spin { + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, var(--fg) 25%, transparent); + border-top-color: var(--red); + border-radius: 50%; + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { + to { transform: rotate(360deg); } +} + +/* Flyout submenu panels: compact rows + a viewport-bounded height so long + lists (the 19 Image > Adjustments entries) all fit or scroll. */ +.ge-menu-parent + .ge-submenu .dropdown-item-compact { + min-height: 0 !important; + height: auto !important; + padding: 3px 10px !important; + line-height: 1.2 !important; + font-size: 12px !important; +} +.ge-submenu { + max-height: calc(100vh - 180px) !important; + overflow-y: auto !important; + scrollbar-width: auto; + scrollbar-color: auto; +} +.ge-submenu::-webkit-scrollbar { width: 8px; height: 8px; } +.ge-submenu::-webkit-scrollbar-track { background: var(--panel); } +.ge-submenu::-webkit-scrollbar-thumb { background: var(--red); border-radius: 4px; border: 2px solid var(--panel); } +.ge-filter-menu:has(.ge-menu-parent.open), +.ge-image-menu:has(.ge-menu-parent.open) { + overflow: visible !important; +} + +/* Flyout submenu panels: compact rows so long lists (e.g. the 19 Image > + Adjustments entries) fit without clipping, and let the panel scroll. */ +.ge-submenu .dropdown-item-compact { + padding-top: 2px; + padding-bottom: 2px; +} +.ge-submenu { + scrollbar-width: auto; + scrollbar-color: auto; +} +.ge-submenu::-webkit-scrollbar { width: 8px; height: 8px; } +.ge-submenu::-webkit-scrollbar-track { background: var(--panel); } +.ge-submenu::-webkit-scrollbar-thumb { background: var(--red); border-radius: 4px; border: 2px solid var(--panel); } +/* Release the parent menu's overflow clip while a flyout is open so the + panel is not cut off at the menu edge. */ +.ge-filter-menu:has(.ge-menu-parent.open), +.ge-image-menu:has(.ge-menu-parent.open) { + overflow: visible !important; +} + +/* The global "Pro tip" hint card can overlap the editor menus; hide it while + the editor is open. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* The global "Pro tip" hint card can overlap the editor menus; hide it while + the editor is open. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* The onboarding "Pro tip" tour hint floats over the editor menus; hide it + while the editor is open so it never occludes a dropdown or flyout. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* Phone editor: while the Quick Edit card is open it owns the bottom of the + canvas. Force the layers sheet back to its peek height and hide the floating + zoom/Setup footer so neither the sheet nor the footer covers the card. */ +@media (max-width: 700px) { + body.ge-qe-open .ge-editor-footer.ge-footer-floating { + display: none !important; + } + body.ge-qe-open .ge-right-panel:not(.minimized) { + transform: translateY(calc(100% - var(--peek-height, 110px))) !important; + } +} + +/* Long dropdown menus (Filter especially) can grow past the viewport bottom; + cap them to the space below the topbar and let them scroll. */ +.ge-image-menu, +.ge-layer-menu, +.ge-filter-menu, +.ge-edit-menu, +.ge-selection-menu, +.ge-type-menu, +.ge-3d-menu, +.ge-view-menu, +.ge-window-menu, +.ge-plugins-menu, +.ge-help-menu { + max-height: min(560px, 70vh) !important; + overflow-y: auto !important; + overscroll-behavior: contain; + scrollbar-width: auto !important; + scrollbar-color: auto !important; + -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent); + mask-image: linear-gradient(to bottom, #000 calc(100% - 22px), transparent); +} +.ge-image-menu::-webkit-scrollbar, +.ge-layer-menu::-webkit-scrollbar, +.ge-filter-menu::-webkit-scrollbar, +.ge-edit-menu::-webkit-scrollbar, +.ge-selection-menu::-webkit-scrollbar, +.ge-type-menu::-webkit-scrollbar, +.ge-3d-menu::-webkit-scrollbar, +.ge-view-menu::-webkit-scrollbar, +.ge-window-menu::-webkit-scrollbar, +.ge-plugins-menu::-webkit-scrollbar, +.ge-help-menu::-webkit-scrollbar { + width: 8px; + height: 8px; +} +.ge-image-menu::-webkit-scrollbar-track, +.ge-layer-menu::-webkit-scrollbar-track, +.ge-filter-menu::-webkit-scrollbar-track, +.ge-edit-menu::-webkit-scrollbar-track, +.ge-selection-menu::-webkit-scrollbar-track, +.ge-type-menu::-webkit-scrollbar-track, +.ge-3d-menu::-webkit-scrollbar-track, +.ge-view-menu::-webkit-scrollbar-track, +.ge-window-menu::-webkit-scrollbar-track, +.ge-plugins-menu::-webkit-scrollbar-track, +.ge-help-menu::-webkit-scrollbar-track { + background: var(--panel); +} +.ge-image-menu::-webkit-scrollbar-thumb, +.ge-layer-menu::-webkit-scrollbar-thumb, +.ge-filter-menu::-webkit-scrollbar-thumb, +.ge-edit-menu::-webkit-scrollbar-thumb, +.ge-selection-menu::-webkit-scrollbar-thumb, +.ge-type-menu::-webkit-scrollbar-thumb, +.ge-3d-menu::-webkit-scrollbar-thumb, +.ge-view-menu::-webkit-scrollbar-thumb, +.ge-window-menu::-webkit-scrollbar-thumb, +.ge-plugins-menu::-webkit-scrollbar-thumb, +.ge-help-menu::-webkit-scrollbar-thumb { + background-color: var(--red); + border-radius: 4px; +} +.ge-image-menu::-webkit-scrollbar, +.ge-layer-menu::-webkit-scrollbar, +.ge-filter-menu::-webkit-scrollbar, +.ge-edit-menu::-webkit-scrollbar, +.ge-selection-menu::-webkit-scrollbar, +.ge-type-menu::-webkit-scrollbar, +.ge-3d-menu::-webkit-scrollbar, +.ge-view-menu::-webkit-scrollbar, +.ge-window-menu::-webkit-scrollbar, +.ge-plugins-menu::-webkit-scrollbar, +.ge-help-menu::-webkit-scrollbar { + width: 8px; +} +.ge-image-menu::-webkit-scrollbar-track, +.ge-layer-menu::-webkit-scrollbar-track, +.ge-filter-menu::-webkit-scrollbar-track, +.ge-edit-menu::-webkit-scrollbar-track, +.ge-selection-menu::-webkit-scrollbar-track, +.ge-type-menu::-webkit-scrollbar-track, +.ge-3d-menu::-webkit-scrollbar-track, +.ge-view-menu::-webkit-scrollbar-track, +.ge-window-menu::-webkit-scrollbar-track, +.ge-plugins-menu::-webkit-scrollbar-track, +.ge-help-menu::-webkit-scrollbar-track { + background: var(--panel); +} +.ge-image-menu::-webkit-scrollbar-thumb, +.ge-layer-menu::-webkit-scrollbar-thumb, +.ge-filter-menu::-webkit-scrollbar-thumb, +.ge-edit-menu::-webkit-scrollbar-thumb, +.ge-selection-menu::-webkit-scrollbar-thumb, +.ge-type-menu::-webkit-scrollbar-thumb, +.ge-3d-menu::-webkit-scrollbar-thumb, +.ge-view-menu::-webkit-scrollbar-thumb, +.ge-window-menu::-webkit-scrollbar-thumb, +.ge-plugins-menu::-webkit-scrollbar-thumb, +.ge-help-menu::-webkit-scrollbar-thumb { + background: var(--red); + border-radius: 4px; + border: 2px solid var(--panel); +} +/* Custom scroll indicator: macOS/headless browsers hide overlay scrollbars, so + draw a thin 3px thumb driven by scrollTop inside each dropdown menu. */ +.ge-menu-thumb { + position: absolute; + top: 0; + right: 2px; + width: 3px; + min-height: 18px; + border-radius: 2px; + background: var(--red); + pointer-events: none; + display: none; + z-index: 3; +} + +/* Busy veil shown while a heavy pixel filter commits on a large document. */ +.ge-busy-veil { + position: fixed; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.35); + z-index: 60; + pointer-events: none; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 14px; + border: 1px solid var(--border); + border-radius: 6px; + background: var(--panel); + color: var(--fg); + font: inherit; + font-size: 12px; +} +.ge-busy-spin { + width: 14px; + height: 14px; + border: 2px solid color-mix(in srgb, var(--fg) 25%, transparent); + border-top-color: var(--red); + border-radius: 50%; + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { + to { transform: rotate(360deg); } +} + +/* Flyout submenu panels: compact rows + a viewport-bounded height so long + lists (the 19 Image > Adjustments entries) all fit or scroll. */ +.ge-menu-parent + .ge-submenu .dropdown-item-compact { + min-height: 0 !important; + height: auto !important; + padding: 3px 10px !important; + line-height: 1.2 !important; + font-size: 12px !important; +} +.ge-submenu { + max-height: calc(100vh - 180px) !important; + overflow-y: auto !important; + scrollbar-width: auto; + scrollbar-color: auto; +} +.ge-submenu::-webkit-scrollbar { width: 8px; height: 8px; } +.ge-submenu::-webkit-scrollbar-track { background: var(--panel); } +.ge-submenu::-webkit-scrollbar-thumb { background: var(--red); border-radius: 4px; border: 2px solid var(--panel); } +.ge-filter-menu:has(.ge-menu-parent.open), +.ge-image-menu:has(.ge-menu-parent.open) { + overflow: visible !important; +} + +/* Flyout submenu panels: compact rows so long lists (e.g. the 19 Image > + Adjustments entries) fit without clipping, and let the panel scroll. */ +.ge-submenu .dropdown-item-compact { + padding-top: 2px; + padding-bottom: 2px; +} +.ge-submenu { + scrollbar-width: auto; + scrollbar-color: auto; +} +.ge-submenu::-webkit-scrollbar { width: 8px; height: 8px; } +.ge-submenu::-webkit-scrollbar-track { background: var(--panel); } +.ge-submenu::-webkit-scrollbar-thumb { background: var(--red); border-radius: 4px; border: 2px solid var(--panel); } +/* Release the parent menu's overflow clip while a flyout is open so the + panel is not cut off at the menu edge. */ +.ge-filter-menu:has(.ge-menu-parent.open), +.ge-image-menu:has(.ge-menu-parent.open) { + overflow: visible !important; +} + +/* The global "Pro tip" hint card can overlap the editor menus; hide it while + the editor is open. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* The global "Pro tip" hint card can overlap the editor menus; hide it while + the editor is open. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* The onboarding "Pro tip" tour hint floats over the editor menus; hide it + while the editor is open so it never occludes a dropdown or flyout. */ +body.ge-editor-open .tour-hint { + display: none !important; +} + +/* Busy veil shown while a heavy filter renders on a large document. */ +.ge-busy-veil { + position: absolute; + inset: 0; + display: none; + align-items: center; + justify-content: center; + background: rgba(8, 10, 14, 0.45); + z-index: 40; +} +.ge-busy-veil.is-on { display: flex; } +.ge-busy-card { + display: flex; + align-items: center; + gap: 10px; + padding: 10px 16px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--panel); + color: var(--fg); + font-size: 12px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); +} +.ge-busy-spin { + width: 14px; + height: 14px; + border-radius: 50%; + border: 2px solid color-mix(in srgb, var(--fg) 30%, transparent); + border-top-color: var(--red); + animation: ge-busy-spin 0.8s linear infinite; +} +@keyframes ge-busy-spin { to { transform: rotate(360deg); } } + +/* Photoshop-style nested submenus (Filter groups + Image > Adjustments). + A parent row shows a chevron and reveals a flyout panel on hover/click. + Children stay inside the same menu subtree so the delegated + [data-filter-action] / [data-image-action] handlers still fire. */ +.dropdown-item-compact.ge-menu-parent { + position: relative; + justify-content: space-between; +} +.ge-submenu-caret { + margin-left: auto; + color: var(--color-muted); +} +.ge-submenu { + display: none; + position: absolute; + left: calc(100% - 2px); + top: 0; + min-width: 190px; + max-height: min(70vh, 560px); + overflow-y: auto; + background: var(--panel); + border: 1px solid var(--border); + border-radius: 6px; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35); + z-index: 20; + padding: 4px; +} +.ge-menu-parent.open + .ge-submenu, +.ge-menu-parent:hover + .ge-submenu, +.ge-menu-parent:focus-within + .ge-submenu, +.ge-submenu:hover { + display: block; +} +/* The dropdowns clip overflow to draw the custom scroll thumb; release the + clip while a flyout is open/hovered so it can extend past the menu edge. */ +.ge-filter-menu:has(.ge-menu-parent.open), +.ge-filter-menu:has(.ge-menu-parent:hover), +.ge-filter-menu:has(.ge-submenu:hover), +.ge-image-menu:has(.ge-menu-parent.open), +.ge-image-menu:has(.ge-menu-parent:hover), +.ge-image-menu:has(.ge-submenu:hover), +.ge-selection-menu:has(.ge-menu-parent.open), +.ge-selection-menu:has(.ge-menu-parent:hover), +.ge-selection-menu:has(.ge-submenu:hover) { + overflow: visible !important; + -webkit-mask-image: none; + mask-image: none; +} +/* The Window menu's Workspace flyout needs the same clip release. */ +.ge-window-menu:has(.ge-menu-parent.open), +.ge-window-menu:has(.ge-menu-parent:hover), +.ge-window-menu:has(.ge-submenu:hover) { + overflow: visible !important; + -webkit-mask-image: none; + mask-image: none; +} + +/* Perspective Crop overlay (tools/perspective-crop.js). */ +.ge-pcrop-overlay { + position: fixed; + inset: 0; + z-index: 10050; + pointer-events: none; +} +.ge-pcrop-svg { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + pointer-events: none; +} +.ge-pcrop-svg polygon { + fill: color-mix(in srgb, var(--red) 12%, transparent); + stroke: var(--red); + stroke-width: 1.5; + vector-effect: non-scaling-stroke; +} +.ge-pcrop-handle { + position: absolute; + width: 14px; + height: 14px; + margin: -7px 0 0 -7px; + border-radius: 50%; + background: #fff; + border: 2px solid var(--red); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.5); + cursor: grab; + pointer-events: auto; + touch-action: none; +} +.ge-pcrop-handle:active { cursor: grabbing; } +.ge-pcrop-bar { + position: absolute; + display: flex; + align-items: center; + gap: 8px; + padding: 5px 8px; + border-radius: 8px; + background: var(--panel, #222); + border: 1px solid rgba(255, 255, 255, 0.12); + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.45); + pointer-events: auto; + transform: translateY(-100%); +} +.ge-pcrop-bar .ge-pcrop-title { + font-size: 12px; + font-weight: 600; + color: var(--fg); +} +.ge-pcrop-bar .ge-pcrop-size { + font-size: 11px; + opacity: 0.75; + font-variant-numeric: tabular-nums; +} +.ge-pcrop-spacer { flex: 1 1 auto; min-width: 12px; } + +/* Mobile (390) usability: several editor controls sat well below the 44px + touch-target guideline, so they were easy to miss with a thumb. Give the + primary top-menu buttons, the Layers-header action row, the layer-opacity + slider and the Quick Edit field/run button a comfortable hit area on phones. + Sizing only — no layout/order change. */ +@media (max-width: 700px) { + .ge-topbar .ge-btn-sm, + .ge-topbar .ge-stacked-btn { + min-height: 34px; + } + #ge-undo, + #ge-redo, + #ge-history-btn, + #ge-compare-btn { + min-width: 38px; + min-height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + } + .ge-layer-opacity { + height: 30px; + } + #ge-ai-command-input { + min-height: 40px; + } + #ge-ai-command-run { + min-width: 42px; + min-height: 42px; + } +} diff --git a/static/css/documents-gallery-editor.css b/static/css/documents-gallery-editor.css index 702bb331a..76c0e01b1 100644 --- a/static/css/documents-gallery-editor.css +++ b/static/css/documents-gallery-editor.css @@ -978,3 +978,342 @@ body.doc-find-active mark.doc-find-mark.current { color: var(--accent, var(--red)); vertical-align: -2px; } + +/* Colour chips: every pickable colour input renders as a circle, regardless of + the container it sits in (options bar .ge-text-field, Character panel, + gradient stops, export matte, etc.). Forced with !important so parent + input[type="color"] rules can't square them off. */ +input.ge-color-picker, +input.ge-color-picker.cp-swatch-input, +.ge-text-field input.ge-color-picker, +.ge-swatch input.ge-color-picker, +.ge-filter-row input.ge-color-picker, +.ge-gradient-color-row input.ge-color-picker { + border-radius: 50% !important; +} +input.ge-color-picker::-webkit-color-swatch { + border: 0 !important; + border-radius: 50% !important; +} +input.ge-color-picker::-moz-color-swatch { + border: 0 !important; + border-radius: 50% !important; +} + +/* --------------------------------------------------------------------------- + Editor theme alignment: map the editor's controls onto the app's design + tokens (static/css/00-tokens.css) so the editor matches the rest of the + product. No new colours — only the shared CSS variables. +--------------------------------------------------------------------------- */ + +/* Checkboxes + radios → the app's circular dot (see 17-research.css). */ +body.ge-editor-open input[type="checkbox"], +body.ge-editor-open input[type="radio"] { + -webkit-appearance: none; + appearance: none; + width: 14px; + height: 14px; + margin: 0; + padding: 0; + border: 1.5px solid var(--border); + border-radius: 50%; + background: var(--bg); + cursor: pointer; + position: relative; + flex: 0 0 auto; + vertical-align: middle; + transition: background 0.12s ease, border-color 0.12s ease; +} +body.ge-editor-open input[type="checkbox"]:checked, +body.ge-editor-open input[type="radio"]:checked { + background: var(--fg); + border-color: var(--fg); +} +body.ge-editor-open input[type="checkbox"]:focus-visible, +body.ge-editor-open input[type="radio"]:focus-visible { + outline: 2px solid var(--red); + outline-offset: 1px; +} + +/* Selects → the app's select tokens. */ +body.ge-editor-open select { + background: var(--select-bg, var(--bg)); + color: var(--select-fg, var(--fg)); + border: 1px solid var(--border); + border-radius: 6px; +} +body.ge-editor-open select option { + background: var(--select-option-bg, var(--panel)); + color: var(--select-option-fg, var(--fg)); +} +body.ge-editor-open select option:checked, +body.ge-editor-open select option:hover { + background: var(--select-option-active-bg, var(--panel)); +} +body.ge-editor-open select:focus-visible { + outline: 1px solid var(--red); + outline-offset: 1px; +} + +/* Text / number inputs → token background, border and focus ring. */ +body.ge-editor-open input[type="text"], +body.ge-editor-open input[type="number"], +body.ge-editor-open input[type="search"], +body.ge-editor-open textarea { + background: var(--bg); + color: var(--fg); + border: 1px solid var(--border); + border-radius: 6px; +} +body.ge-editor-open input[type="text"]:focus-visible, +body.ge-editor-open input[type="number"]:focus-visible, +body.ge-editor-open input[type="search"]:focus-visible, +body.ge-editor-open textarea:focus-visible { + outline: 1px solid var(--red); + outline-offset: 1px; +} + +/* Range sliders → the app's accent. */ +body.ge-editor-open input[type="range"] { + accent-color: var(--accent, var(--red)); +} + +/* Scrollbars → the app's 8px red-thumb recipe (00-tokens.css). */ +body.ge-editor-open * { + scrollbar-width: thin; + scrollbar-color: var(--red) var(--panel); +} +body.ge-editor-open *::-webkit-scrollbar { + width: 8px; + height: 8px; +} +body.ge-editor-open *::-webkit-scrollbar-track { + background: var(--panel); +} +body.ge-editor-open *::-webkit-scrollbar-thumb { + background-color: var(--red); + border-radius: 4px; + border: 2px solid var(--panel); +} +body.ge-editor-open *::-webkit-scrollbar-thumb:hover { + background-color: color-mix(in srgb, var(--red) 80%, white); +} + +/* --------------------------------------------------------------------------- + Editor theme alignment (part 2): buttons, segmented toggles, tabs, list + rows, panel headers, badges, focus rings, number fields and colour-picker + chrome mapped onto the app's shared design tokens. Existing tokens only — + no new colours, and no geometry beyond colour / border / radius / focus. +--------------------------------------------------------------------------- */ + +/* The editor references two secondary / muted variables that the app token + layer never defines; map them onto the real tokens so every + var(--fg-muted) / var(--bg-secondary) editor rule resolves. */ +body.ge-editor-open { + --fg-muted: var(--color-muted); + --bg-secondary: var(--panel); +} + +/* Buttons → the app's `.cal-btn` recipe (5px radius; the primary button + uses the accent fill with a --bg foreground instead of a hard-coded #fff). */ +body.ge-editor-open .ge-btn { + border-radius: 5px; +} +body.ge-editor-open .ge-btn-primary { + background: var(--accent, var(--red)); + border-color: var(--accent, var(--red)); + color: var(--bg); +} +body.ge-editor-open .ge-btn-primary:hover { + background: var(--accent, var(--red)); + opacity: 0.9; +} +body.ge-editor-open .ge-win-close:hover { + background: var(--accent, var(--red)); + color: var(--bg); +} + +/* Segmented / option toggles, command palette and channel rows → the app's + accent token (--red) instead of the editor's blue #4c8bf5 fallback. */ +body.ge-editor-open .ge-opt-mode.active, +body.ge-editor-open .ge-crop-preset.active, +body.ge-editor-open .ge-command-item.active, +body.ge-editor-open .ge-command-item:hover { + background: var(--accent, var(--red)); + border-color: var(--accent, var(--red)); + color: var(--bg); +} +body.ge-editor-open .ge-channel-row.active, +body.ge-editor-open .ge-dock-tabs-menu-item:hover, +body.ge-editor-open .ge-filter-menu .dropdown-item-compact:focus-visible, +body.ge-editor-open .ge-view-menu .dropdown-item-compact:focus-visible, +body.ge-editor-open .ge-window-menu .dropdown-item-compact:focus-visible, +body.ge-editor-open .ge-help-menu .dropdown-item-compact:focus-visible, +body.ge-editor-open .ge-3d-menu .dropdown-item-compact:focus-visible, +body.ge-editor-open .ge-plugins-menu .dropdown-item-compact:focus-visible { + background: color-mix(in srgb, var(--accent, var(--red)) 22%, transparent); +} +body.ge-editor-open .ge-sam-brush-add.active, +body.ge-editor-open .ge-sam-brush-subtract.active, +body.ge-editor-open .ge-actions-controls .ge-btn.active { + border-color: var(--accent, var(--red)); + color: var(--accent, var(--red)); +} + +/* Tabs → the app tab convention: the active tab's underline is the accent. */ +body.ge-editor-open .ge-dock-tab.active { + border-bottom-color: var(--accent, var(--red)); +} + +/* Colour-picker chrome → the token accent instead of the blue fallback. */ +body.ge-editor-open .ge-swatch.active { + outline-color: var(--accent, var(--red)); +} + +/* Badges / status pills → the app's --bg foreground on an accent fill. */ +body.ge-editor-open .ge-tool-clear { + color: var(--bg); +} + +/* Panel headers → the app's muted section-title token. */ +body.ge-editor-open .ge-panel-title, +body.ge-editor-open .ge-info-label { + color: var(--color-muted); +} + +/* Focus rings → the app's 1px --red ring (matching the earlier input/select + block) on the controls that previously fell back to the UA outline. */ +body.ge-editor-open .ge-btn:focus-visible, +body.ge-editor-open .ge-tool-btn:focus-visible, +body.ge-editor-open .ge-doc-tab:focus-visible, +body.ge-editor-open .ge-history-row:focus-visible, +body.ge-editor-open .ge-swatch:focus-visible, +body.ge-editor-open .ge-opt-mode:focus-visible, +body.ge-editor-open .ge-stepper button:focus-visible { + outline: 1px solid var(--red); + outline-offset: 1px; +} + +/* --------------------------------------------------------------------------- + Editor theme alignment (part 3): the setup / preflight checklist and the + remaining checkbox list-row / badge chrome mapped onto the app's shared + design tokens. Existing tokens only — no new colours, and no geometry beyond + colour / border / radius / focus. Scoped to body.ge-editor-open. +--------------------------------------------------------------------------- */ + +/* Checklist card + rows → the app's panel / border / muted tokens. */ +body.ge-editor-open .ge-preflight-card { + background: var(--panel); + color: var(--fg); + border-color: var(--border); +} +body.ge-editor-open .ge-preflight-head p, +body.ge-editor-open .ge-preflight-hint, +body.ge-editor-open .ge-preflight-detail, +body.ge-editor-open .ge-preflight-remember, +body.ge-editor-open .ge-preflight-close { + color: var(--color-muted); +} +body.ge-editor-open .ge-preflight-close:hover { + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: var(--fg); +} +body.ge-editor-open .ge-preflight-row { + background: color-mix(in srgb, var(--panel) 92%, var(--fg) 4%); + border-color: var(--border); +} +body.ge-editor-open .ge-preflight-row.is-done { + border-color: color-mix(in srgb, var(--green) 45%, var(--border)); +} +body.ge-editor-open .ge-preflight-row.is-todo { + border-color: var(--red); +} +body.ge-editor-open .ge-preflight-row.is-optional { + border-color: color-mix(in srgb, var(--accent-warm) 45%, var(--border)); +} + +/* Row badges → the semantic tokens (done = green, todo = red, optional = warm). */ +body.ge-editor-open .ge-preflight-badge { + background: color-mix(in srgb, var(--fg) 10%, transparent); + color: var(--fg); +} +body.ge-editor-open .ge-preflight-row.is-done .ge-preflight-badge { + background: color-mix(in srgb, var(--green) 20%, transparent); + color: var(--green); +} +body.ge-editor-open .ge-preflight-row.is-todo .ge-preflight-badge { + background: color-mix(in srgb, var(--red) 18%, transparent); + color: var(--red); +} +body.ge-editor-open .ge-preflight-row.is-optional .ge-preflight-badge { + background: color-mix(in srgb, var(--accent-warm) 16%, transparent); + color: var(--accent-warm); +} +body.ge-editor-open .ge-preflight-opt { + background: color-mix(in srgb, var(--fg) 8%, transparent); + color: var(--color-muted); +} + +/* Checklist actions / footer → token buttons and borders. */ +body.ge-editor-open .ge-preflight-act { + background: color-mix(in srgb, var(--fg) 8%, transparent); + border-color: var(--border); + color: var(--fg); +} +body.ge-editor-open .ge-preflight-act:hover { + background: color-mix(in srgb, var(--fg) 16%, transparent); +} +body.ge-editor-open .ge-preflight-foot { + border-top-color: var(--border); +} +body.ge-editor-open .ge-preflight-summary { + color: var(--green); +} +body.ge-editor-open .ge-preflight-start { + background: var(--accent, var(--red)); + color: var(--bg); +} +body.ge-editor-open .ge-preflight-start:hover { + background: var(--accent, var(--red)); + opacity: 0.9; +} +body.ge-editor-open .ge-setup-btn { + border-color: var(--border); + color: var(--color-muted); +} +body.ge-editor-open .ge-setup-btn:hover { + background: color-mix(in srgb, var(--fg) 10%, transparent); + color: var(--fg); +} + +/* The options-bar checkbox overrode the shared dot with a square + a drawn + ::after tick; force it back to the app's 14px circular dot (part 1). */ +body.ge-editor-open .ge-opt input[type="checkbox"], +body.ge-editor-open .ge-opt input[type="radio"] { + border-radius: 50%; + border-width: 1.5px; +} +body.ge-editor-open .ge-opt input[type="checkbox"]:checked, +body.ge-editor-open .ge-opt input[type="radio"]:checked { + background: var(--fg); + border-color: var(--fg); +} +body.ge-editor-open .ge-opt input[type="checkbox"]:checked::after { + content: none; +} + +/* Remaining list-row / thumbnail chrome → panel + border + muted tokens. */ +body.ge-editor-open .ge-history-row-dot { + background: var(--color-muted); +} +body.ge-editor-open .ge-layer-inline-thumb { + background: var(--panel); + border-color: var(--border); +} +body.ge-editor-open .ge-channel-row, +body.ge-editor-open .ge-channel-name { + color: var(--fg); +} +body.ge-editor-open .ge-channel-eye { + color: var(--color-muted); +} diff --git a/static/fonts/BebasNeue-Regular.woff2 b/static/fonts/BebasNeue-Regular.woff2 new file mode 100644 index 000000000..92d6a0f72 Binary files /dev/null and b/static/fonts/BebasNeue-Regular.woff2 differ diff --git a/static/fonts/Lobster-Regular.woff2 b/static/fonts/Lobster-Regular.woff2 new file mode 100644 index 000000000..656ed22de Binary files /dev/null and b/static/fonts/Lobster-Regular.woff2 differ diff --git a/static/fonts/PlayfairDisplay-Bold.woff2 b/static/fonts/PlayfairDisplay-Bold.woff2 new file mode 100644 index 000000000..5a3fbbdae Binary files /dev/null and b/static/fonts/PlayfairDisplay-Bold.woff2 differ diff --git a/static/fonts/PlayfairDisplay-Regular.woff2 b/static/fonts/PlayfairDisplay-Regular.woff2 new file mode 100644 index 000000000..5a3fbbdae Binary files /dev/null and b/static/fonts/PlayfairDisplay-Regular.woff2 differ diff --git a/static/fonts/Roboto-Bold.woff2 b/static/fonts/Roboto-Bold.woff2 new file mode 100644 index 000000000..9b0f14165 Binary files /dev/null and b/static/fonts/Roboto-Bold.woff2 differ diff --git a/static/fonts/Roboto-Regular.woff2 b/static/fonts/Roboto-Regular.woff2 new file mode 100644 index 000000000..9b0f14165 Binary files /dev/null and b/static/fonts/Roboto-Regular.woff2 differ diff --git a/static/fonts/RobotoMono-Bold.woff2 b/static/fonts/RobotoMono-Bold.woff2 new file mode 100644 index 000000000..bfa169c30 Binary files /dev/null and b/static/fonts/RobotoMono-Bold.woff2 differ diff --git a/static/fonts/RobotoMono-Regular.woff2 b/static/fonts/RobotoMono-Regular.woff2 new file mode 100644 index 000000000..bfa169c30 Binary files /dev/null and b/static/fonts/RobotoMono-Regular.woff2 differ diff --git a/static/fonts/custom/GohuFont.ttf b/static/fonts/custom/GohuFont.ttf new file mode 100644 index 000000000..710f09f08 Binary files /dev/null and b/static/fonts/custom/GohuFont.ttf differ diff --git a/static/icons/ollama-mark-crop.png b/static/icons/ollama-mark-crop.png new file mode 100644 index 000000000..2554b38a1 Binary files /dev/null and b/static/icons/ollama-mark-crop.png differ diff --git a/static/icons/ollama-mark.png b/static/icons/ollama-mark.png new file mode 100644 index 000000000..8cd2cf1ed Binary files /dev/null and b/static/icons/ollama-mark.png differ diff --git a/static/icons/sglang-logo.png b/static/icons/sglang-logo.png new file mode 100644 index 000000000..c13afe571 Binary files /dev/null and b/static/icons/sglang-logo.png differ diff --git a/static/icons/sglang-mark.png b/static/icons/sglang-mark.png new file mode 100644 index 000000000..3e0fe3eda Binary files /dev/null and b/static/icons/sglang-mark.png differ diff --git a/static/js/editor/actions-panel.js b/static/js/editor/actions-panel.js new file mode 100644 index 000000000..a41d86817 --- /dev/null +++ b/static/js/editor/actions-panel.js @@ -0,0 +1,239 @@ +/** + * Actions panel (PhotoCraft parity, Phase 11) — a lightweight macro + * recorder/player. Every step maps to a known editor command by CSS + * selector; recording captures clicks on those selectors, and play + * replays them in order. Named actions persist to localStorage. + * + * The module owns no editor state: it only dispatches clicks on the + * selectors it is given. + */ + +export const ACTION_COMMANDS = Object.freeze([ + ['flip-h', 'Flip Horizontal', '[data-image-action="flip-h"]'], + ['flip-v', 'Flip Vertical', '[data-image-action="flip-v"]'], + ['rotate-90', 'Rotate 90° CW', '[data-image-action="rotate-90"]'], + ['rotate-180', 'Rotate 180°', '[data-image-action="rotate-180"]'], + ['fill', 'Fill Foreground', '[data-image-action="fill"]'], + ['merge-down', 'Merge Down', '#ge-merge-down'], + ['flatten', 'Flatten', '#ge-flatten'], + ['select-all', 'Select All', '[data-selection-action="all"]'], + ['deselect', 'Deselect', '[data-selection-action="deselect"]'], + ['invert-selection', 'Invert Selection', '[data-selection-action="invert"]'], + ['blur-gaussian', 'Gaussian Blur', '[data-filter-action="blur-gaussian"]'], + ['blur-motion', 'Motion Blur', '[data-filter-action="blur-motion"]'], + ['noise-add', 'Add Noise', '[data-filter-action="noise-add"]'], + ['pixelate-mosaic', 'Pixelate', '[data-filter-action="pixelate-mosaic"]'], + ['sharpen', 'Sharpen', '[data-filter-action="sharpen"]'], + ['stylize-emboss', 'Emboss', '[data-filter-action="stylize-emboss"]'], + ['distort-twirl', 'Twirl', '[data-filter-action="distort-twirl"]'], + ['rembg', 'Remove Background', '[data-tool-option="rembg"]'], + ['inpaint', 'Inpaint', '[data-tool="inpaint"]'], +]); + +const STORAGE_KEY = 'ge-actions'; + +function el(tag, className, html) { + const node = document.createElement(tag); + if (className) node.className = className; + if (html != null) node.innerHTML = html; + return node; +} + +function commandById(id) { + return ACTION_COMMANDS.find(command => command[0] === id) || null; +} + +export function createActionsPanel({ showToast = () => {}, getLayers = null, selectLayer = null } = {}) { + let steps = []; + let recording = false; + let saved = loadSaved(); + + const body = el('div', 'ge-panel-body ge-actions-body'); + + const nameRow = el('div', 'ge-actions-name-row'); + const nameInput = document.createElement('input'); + nameInput.type = 'text'; + nameInput.placeholder = 'Action name'; + nameInput.className = 'ge-actions-name'; + const saveButton = el('button', 'ge-btn ge-btn-sm', 'Save'); + saveButton.type = 'button'; + nameRow.append(nameInput, saveButton); + + const addRow = el('div', 'ge-actions-add-row'); + const addSelect = document.createElement('select'); + addSelect.className = 'ge-actions-command'; + ACTION_COMMANDS.forEach(([id, label]) => addSelect.appendChild(new Option(label, id))); + const addButton = el('button', 'ge-btn ge-btn-sm', 'Add'); + addButton.type = 'button'; + addRow.append(addSelect, addButton); + + const controls = el('div', 'ge-actions-controls'); + const recordButton = el('button', 'ge-btn ge-btn-sm', 'Record'); + recordButton.type = 'button'; + const playButton = el('button', 'ge-btn ge-btn-sm', 'Play'); + playButton.type = 'button'; + const clearButton = el('button', 'ge-btn ge-btn-sm', 'Clear'); + clearButton.type = 'button'; + controls.append(recordButton, playButton, clearButton); + + const batchRow = el('div', 'ge-actions-batch-row'); + const batchSelect = document.createElement('select'); + batchSelect.className = 'ge-actions-batch-select'; + const batchButton = el('button', 'ge-btn ge-btn-sm', 'Batch'); + batchButton.type = 'button'; + batchButton.title = 'Apply the selected action to every layer'; + const batchDocsButton = el('button', 'ge-btn ge-btn-sm', 'Batch docs'); + batchDocsButton.type = 'button'; + batchDocsButton.title = 'Apply the selected action to every open document'; + batchRow.append(batchSelect, batchButton, batchDocsButton); + + const list = el('div', 'ge-actions-list'); + const savedList = el('div', 'ge-actions-saved'); + + body.append(nameRow, addRow, controls, batchRow, list, savedList); + + function renderList() { + list.innerHTML = ''; + if (!steps.length) { + list.appendChild(el('p', 'ge-panel-empty', 'No steps. Add commands or record.')); + return; + } + steps.forEach((step, index) => { + const command = commandById(step); + const row = el('div', 'ge-actions-step', `${index + 1}${command ? command[1] : step}`); + const remove = el('button', 'ge-action-remove', '✕'); + remove.type = 'button'; + remove.addEventListener('click', () => { steps.splice(index, 1); renderList(); }); + row.appendChild(remove); + list.appendChild(row); + }); + } + + function renderSaved() { + savedList.innerHTML = ''; + syncBatchSelect(); + const names = Object.keys(saved); + if (!names.length) return; + savedList.appendChild(el('div', 'ge-panel-title', 'SAVED')); + names.forEach(name => { + const row = el('div', 'ge-actions-saved-row'); + const loadButton = el('button', 'ge-btn ge-btn-sm', name); + loadButton.type = 'button'; + loadButton.addEventListener('click', () => { steps = saved[name].slice(); renderList(); }); + const deleteButton = el('button', 'ge-action-remove', '✕'); + deleteButton.type = 'button'; + deleteButton.addEventListener('click', () => { delete saved[name]; persist(); renderSaved(); }); + row.append(loadButton, deleteButton); + savedList.appendChild(row); + }); + } + + function persist() { + try { localStorage.setItem(STORAGE_KEY, JSON.stringify(saved)); } catch { /* ignore */ } + } + + function syncBatchSelect() { + const names = Object.keys(saved); + const previous = batchSelect.value; + batchSelect.innerHTML = ''; + if (!names.length) { + batchSelect.appendChild(new Option('No saved actions', '')); + batchButton.disabled = true; + batchDocsButton.disabled = true; + return; + } + batchButton.disabled = false; + batchDocsButton.disabled = false; + names.forEach(name => batchSelect.appendChild(new Option(name, name))); + if (names.includes(previous)) batchSelect.value = previous; + } + + function loadSaved() { + try { + const raw = localStorage.getItem(STORAGE_KEY); + const parsed = raw ? JSON.parse(raw) : null; + return parsed && typeof parsed === 'object' ? parsed : {}; + } catch { return {}; } + } + + function runStep(step) { + const command = commandById(step); + if (!command) return; + document.querySelector(command[2])?.click(); + } + + function play() { + steps.forEach((step, index) => setTimeout(() => runStep(step), index * 60)); + showToast(`Playing ${steps.length} action${steps.length === 1 ? '' : 's'}`); + } + + async function runOnAllLayers() { + const name = batchSelect.value; + const action = name && saved[name] ? saved[name].slice() : null; + if (!action || !action.length) { showToast('Select a saved action to batch'); return; } + if (!getLayers || !selectLayer) { showToast('Batch is unavailable'); return; } + const layers = getLayers() || []; + if (layers.length < 1) { showToast('No layers to batch'); return; } + showToast(`Batching "${name}" over ${layers.length} layer${layers.length === 1 ? '' : 's'}`); + for (const layer of layers) { + selectLayer(layer.id); + for (const step of action) { + runStep(step); + await new Promise(resolve => setTimeout(resolve, 80)); + } + } + showToast('Batch complete'); + } + + function onDocumentClick(event) { + if (!recording) return; + const match = ACTION_COMMANDS.find(command => event.target.closest?.(command[2])); + if (match && !steps.includes(match[0])) { + steps.push(match[0]); + renderList(); + } + } + + function toggleRecord() { + recording = !recording; + recordButton.classList.toggle('active', recording); + recordButton.textContent = recording ? 'Stop' : 'Record'; + showToast(recording ? 'Recording actions…' : 'Recording stopped'); + } + + addButton.addEventListener('click', () => { steps.push(addSelect.value); renderList(); }); + recordButton.addEventListener('click', toggleRecord); + playButton.addEventListener('click', play); + batchButton.addEventListener('click', () => { runOnAllLayers(); }); + batchDocsButton.addEventListener('click', async () => { + const name = batchSelect.value; + if (!name) { showToast('Select a saved action to batch'); return; } + try { + const response = await window.geEditorControl?.invoke?.('actions.batchDocuments', { name }); + const results = (response && response.result) || []; + const ok = results.filter((entry) => entry.ok).length; + showToast(`Batch docs: ${ok}/${results.length} document${results.length === 1 ? '' : 's'}`); + } catch (error) { + showToast('Batch docs failed: ' + (error?.message || error)); + } + }); + clearButton.addEventListener('click', () => { steps = []; renderList(); }); + saveButton.addEventListener('click', () => { + const name = (nameInput.value || '').trim(); + if (!name) return; + saved[name] = steps.slice(); + persist(); + renderSaved(); + showToast(`Saved action "${name}"`); + }); + document.addEventListener('click', onDocumentClick, true); + + const element = el('div', 'ge-panel'); + element.id = 'ge-panel-actions'; + element.appendChild(el('div', 'ge-panel-title', 'ACTIONS')); + element.appendChild(body); + renderList(); + renderSaved(); + + return { element, play, run: runStep, runOnAllLayers, getSteps: () => steps.slice(), setSteps: (next) => { steps = next.slice(); renderList(); } }; +} diff --git a/static/js/editor/adjustment-layer.js b/static/js/editor/adjustment-layer.js index e13e4ca53..71a27c99a 100644 --- a/static/js/editor/adjustment-layer.js +++ b/static/js/editor/adjustment-layer.js @@ -2,10 +2,12 @@ import { applyAdjustment } from './fx/pixel-pass.js'; import { defaultAdjParams } from './layer-helpers.js'; import { renderWithLayerMasks } from './composite-helpers.js'; +import { blendDraw } from './blend-modes.js'; const TYPES = new Set([ 'brightness-contrast', 'exposure', + 'develop', 'white-balance', 'hue-saturation', 'vibrance', @@ -16,6 +18,12 @@ const TYPES = new Set([ 'color-balance', 'selective-color', 'gradient-map', + 'invert', + 'posterize', + 'threshold', + 'photo-filter', + 'channel-mixer', + 'color-lookup', ]); function clone(value) { @@ -52,6 +60,11 @@ export function normalizeAdjustmentData(value) { params.ranges[name] = { ...clone(defaults.ranges[name]), ...(params.ranges[name] || {}) }; } } + if (type === 'channel-mixer') { + for (const name of ['red', 'green', 'blue', 'constant']) { + params[name] = { ...clone(defaults[name]), ...(params[name] || {}) }; + } + } return { type, params }; } @@ -109,11 +122,8 @@ export function drawAdjustmentLayer(target, editorState, layer, clippingBase, re adjusted = clipped; } - target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1; - target.globalCompositeOperation = layer.blendMode || 'source-over'; - target.drawImage(adjusted, 0, 0); - target.globalAlpha = 1; - target.globalCompositeOperation = 'source-over'; + const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1; + blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0); return true; } @@ -154,11 +164,8 @@ export async function drawAdjustmentLayerAsync(target, editorState, layer, clipp clippedCtx.globalCompositeOperation = 'source-over'; adjusted = clipped; } - target.globalAlpha = Number.isFinite(layer.opacity) ? layer.opacity : 1; - target.globalCompositeOperation = layer.blendMode || 'source-over'; - target.drawImage(adjusted, 0, 0); - target.globalAlpha = 1; - target.globalCompositeOperation = 'source-over'; + const opacity = Number.isFinite(layer.opacity) ? layer.opacity : 1; + blendDraw(target, adjusted, layer.blendMode, opacity, 0, 0); return true; } diff --git a/static/js/editor/ai-models.js b/static/js/editor/ai-models.js index 5b4252f6b..6da7d0e03 100644 --- a/static/js/editor/ai-models.js +++ b/static/js/editor/ai-models.js @@ -27,6 +27,12 @@ import { state } from './state.js'; import { sortModelIds } from '../modelSort.js'; +// Last result of loadAIModels() — consumed by the editor setup checklist +// (static/js/editor/preflight.js) to decide whether an image provider is +// reachable and which image models are available. +let _aiModelsStatus = { ready: false, models: [], error: null, at: 0 }; +export function getAIModelsStatus() { return _aiModelsStatus; } + export function resolveInpaintModel(select) { const options = Array.from(select?.options || []).filter(option => !option.disabled && option.value && option.value !== '__serve_cookbook__'); @@ -34,6 +40,71 @@ export function resolveInpaintModel(select) { return explicit || options.find(option => /inpaint|edit|fill/i.test(option.value)) || options[0] || null; } +// Resolve the model the Quick Edit box will use. Prefers the explicit Quick +// Edit picker (#ge-ai-command-model); falls back to the inpaint picker / Auto. +export function resolveQuickEditModel() { + const sel = document.getElementById('ge-ai-command-model'); + if (sel && sel.value && sel.value !== '__serve_cookbook__') { + const opt = sel.options[sel.selectedIndex]; + const raw = sel.value || ''; + const idx = raw.indexOf('::'); + return { + endpoint: idx < 0 ? raw : raw.slice(0, idx), + model: idx < 0 ? '' : raw.slice(idx + 2), + label: ((opt && opt.textContent) || '').trim(), + }; + } + const inp = document.getElementById('ge-ai-inpaint'); + const opt = resolveInpaintModel(inp); + if (!opt) return { endpoint: '', model: '', label: '' }; + const raw = opt.value || ''; + const idx = raw.indexOf('::'); + return { + endpoint: idx < 0 ? raw : raw.slice(0, idx), + model: idx < 0 ? '' : raw.slice(idx + 2), + label: ((opt.textContent || '').trim().replace(/^Auto\s*\((.*)\)$/, '$1')), + }; +} + +// Resolve the Quick Edit AGENT (planner) model — the vision-language model +// that reads the canvas screenshot and plans the edit. Default is the local +// Qwen3.5-9B VL server on seneca; "Server default" keeps the HARNESS planner. +export function resolveQuickEditAgentModel() { + const sel = document.getElementById('ge-ai-command-agent-model'); + const raw = (sel && sel.value) || ''; + if (!raw || raw === 'default') { + return { agent: '', endpoint: '', label: 'Server default' }; + } + const idx = raw.indexOf('::'); + const endpoint = idx < 0 ? '' : raw.slice(0, idx); + const agent = idx < 0 ? raw : raw.slice(idx + 2); + const opt = sel.options[sel.selectedIndex]; + return { agent, endpoint, label: ((opt && opt.textContent) || '').trim() }; +} + +// Quick Edit agent (planner) model options. Kept small + explicit; the local +// Qwen3.5-9B VL is the default. `value` encodes "::". +export const QUICK_EDIT_AGENT_OPTIONS = [ + { value: 'http://100.69.120.65:8097/v1::Qwen/Qwen3.5-9B', label: 'Qwen3.5-9B VL · local' }, + { value: 'default', label: 'Server default (hosted planner)' }, +]; + + +// can see which model Quick Edit / inpaint will call. +export function updateFooterEditModel() { + const el = document.getElementById('ge-status-model'); + if (!el) return; + const info = resolveQuickEditModel(); + if (!info.label && !info.model) { + el.textContent = 'Edit: none'; + el.title = 'No AI edit model available'; + return; + } + const label = info.label || info.model; + el.textContent = `Edit: ${label}`; + el.title = `AI edit model: ${label}`; +} + // Heuristic classifier on a model id + endpoint name. A model can be: // - gen: text-to-image generation // - inpaint: image+mask edit (inpaint / img2img) @@ -92,11 +163,40 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const aiGenSelect = document.getElementById('ge-ai-model'); const aiInpaintSelect = document.getElementById('ge-ai-inpaint'); + const aiCommandSelect = document.getElementById('ge-ai-command-model'); + const aiCommandAgentSelect = document.getElementById('ge-ai-command-agent-model'); + if (aiInpaintSelect) aiInpaintSelect.addEventListener('change', () => updateFooterEditModel()); + if (aiCommandSelect) aiCommandSelect.addEventListener('change', () => updateFooterEditModel()); + // The Quick Edit agent (planner) picker is static for now (local VL default, + // server default option); restore/save its choice from localStorage. + const populateAgentSelect = () => { + if (!aiCommandAgentSelect) return; + aiCommandAgentSelect.innerHTML = ''; + for (const opt of QUICK_EDIT_AGENT_OPTIONS) { + const el = document.createElement('option'); + el.value = opt.value; + el.textContent = opt.label; + aiCommandAgentSelect.appendChild(el); + } + try { + const saved = localStorage.getItem('ge-ai-command-agent-model'); + if (saved && [...aiCommandAgentSelect.options].some(o => o.value === saved)) { + aiCommandAgentSelect.value = saved; + } + } catch {} + if (!aiCommandAgentSelect._agentWired) { + aiCommandAgentSelect._agentWired = true; + aiCommandAgentSelect.addEventListener('change', () => { + try { localStorage.setItem('ge-ai-command-agent-model', aiCommandAgentSelect.value); } catch {} + }); + } + }; // The global Gen model dropdown was removed from the editor topbar; // only bail if there's nothing to populate at all (neither the Gen // select nor the inpaint select nor any per-tool select). - if (!aiGenSelect && !aiInpaintSelect && + if (!aiGenSelect && !aiInpaintSelect && !aiCommandSelect && !aiCommandAgentSelect && !document.querySelector('select.ge-tool-model')) return; + populateAgentSelect(); async function loadAIModels(opts = {}) { try { @@ -108,11 +208,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const endpoints = await res.json(); if (aiGenSelect) aiGenSelect.innerHTML = ''; if (aiInpaintSelect) aiInpaintSelect.innerHTML = ''; + if (aiCommandSelect) aiCommandSelect.innerHTML = ''; const perToolSelects = Array.from(document.querySelectorAll('select.ge-tool-model')); for (const ts of perToolSelects) ts.innerHTML = ''; let firstGen = null; let selectedGen = null; let selectedInpaint = null; + const loaded = []; for (const ep of endpoints) { if (!ep.is_enabled) continue; const hasListedModels = Array.isArray(ep.models) && ep.models.length; @@ -130,6 +232,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const shortModel = modelId ? String(modelId).split('/').pop() : (ep.name || ep.base_url); const epHint = modelId && ep.name && ep.name !== modelId ? ` · ${ep.name}` : ''; const label = `${shortModel}${epHint}${epUsable ? '' : ' (offline)'}`; + loaded.push({ value, label, gen: caps.gen, inpaint: caps.inpaint, usable: epUsable, endpoint: ep.base_url, model: modelId }); if (caps.gen && aiGenSelect) { const opt = document.createElement('option'); opt.value = value; @@ -158,6 +261,13 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im opt.disabled = !epUsable; ts.appendChild(opt); } + if (aiCommandSelect) { + const opt = document.createElement('option'); + opt.value = value; + opt.textContent = label; + opt.disabled = !epUsable; + aiCommandSelect.appendChild(opt); + } } } } @@ -173,6 +283,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const auto = resolveInpaintModel({ options: aiInpaintSelect.options, value: '' }); aiInpaintSelect.options[0].textContent = auto ? `Auto (${auto.textContent})` : 'Auto (no available model)'; } + updateFooterEditModel(); // Append the "Serve a model in Cookbook…" sentinel at the // bottom of every model dropdown. const appendServeSentinel = (sel) => { @@ -188,6 +299,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im for (const ts of perToolSelects) appendServeSentinel(ts); if (aiGenSelect) appendServeSentinel(aiGenSelect); if (aiInpaintSelect) appendServeSentinel(aiInpaintSelect); + if (aiCommandSelect) appendServeSentinel(aiCommandSelect); // Wire the sentinel on the Gen + Inpaint selects too. const wireServeSentinel = (sel) => { if (!sel) return; @@ -203,6 +315,18 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im }; wireServeSentinel(aiGenSelect); wireServeSentinel(aiInpaintSelect); + wireServeSentinel(aiCommandSelect); + if (aiCommandSelect) { + try { + const saved = localStorage.getItem('ge-ai-command-model'); + if (saved && [...aiCommandSelect.options].some(o => o.value === saved)) aiCommandSelect.value = saved; + } catch {} + aiCommandSelect.addEventListener('change', () => { + if (aiCommandSelect.value === '__serve_cookbook__') return; + try { localStorage.setItem('ge-ai-command-model', aiCommandSelect.value); } catch {} + updateFooterEditModel(); + }); + } // Restore each per-tool selection from localStorage. for (const ts of perToolSelects) { const key = 'ge-tool-model-' + ts.dataset.geToolModel; @@ -223,6 +347,8 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im try { localStorage.setItem(key, ts.value); } catch {} }); } + _aiModelsStatus = { ready: true, models: loaded, error: null, at: Date.now() }; + try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} } catch (e) { // Fetch failed — still give the user the affordance to set up // a model. Otherwise the dropdown shows only "Auto" with no @@ -230,6 +356,7 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im const fallback = ''; if (aiGenSelect) aiGenSelect.innerHTML = fallback; if (aiInpaintSelect) aiInpaintSelect.innerHTML = fallback; + if (aiCommandSelect) aiCommandSelect.innerHTML = fallback; document.querySelectorAll('select.ge-tool-model').forEach(ts => { ts.innerHTML = fallback; }); const wireServe = (sel) => { if (!sel) return; @@ -245,7 +372,12 @@ export function wireAIModelSelectors({ container, apiBase, openCookbookForImg2im }; wireServe(aiGenSelect); wireServe(aiInpaintSelect); + wireServe(aiCommandSelect); document.querySelectorAll('select.ge-tool-model').forEach(wireServe); + populateAgentSelect(); + updateFooterEditModel(); + _aiModelsStatus = { ready: false, models: [], error: String(e?.message || e), at: Date.now() }; + try { window.dispatchEvent(new CustomEvent('ge:ai-models-status')); } catch {} } } loadAIModels(); diff --git a/static/js/editor/ai-settings.js b/static/js/editor/ai-settings.js new file mode 100644 index 000000000..ec8cd846c --- /dev/null +++ b/static/js/editor/ai-settings.js @@ -0,0 +1,85 @@ +/** + * AI settings dialog. + * + * Holds the image-edit model, the AI agent (vision) model, and the opt-in + * "Double-check AI edits" toggle. Opened from the Quick Edit gear button and + * from the editor setup checklist; keeps the Quick Edit box itself uncluttered. + */ +export function createAiSettings({ container }) { + const el = document.createElement('div'); + el.className = 'ge-ai-settings'; + el.hidden = true; + el.setAttribute('role', 'dialog'); + el.setAttribute('aria-modal', 'true'); + el.setAttribute('aria-label', 'AI settings'); + el.innerHTML = ` +
+
+

AI settings

+ +
+
+ + + + +

Recommended, not required — the vision agent plans edits and can double-check results.

+ +
+ + 1.4× +
+

How strongly named looks (Kodachrome, teal and orange, …) are graded.

+ +
+
+ +
+
`; + + function open(focusId) { + el.hidden = false; + if (focusId) { + const target = document.getElementById(focusId); + if (target) { + try { target.focus({ preventScroll: true }); } catch (_) { target.focus(); } + } + } + } + function close() { el.hidden = true; } + + el.addEventListener('click', (e) => { + if (e.target === el || e.target.closest('[data-ai-settings-close]')) { + e.preventDefault(); + close(); + } + }); + const onKey = (e) => { if (e.key === 'Escape' && !el.hidden) close(); }; + document.addEventListener('keydown', onKey); + + container.appendChild(el); + + const strengthInput = el.querySelector('#ge-look-strength'); + const strengthVal = el.querySelector('#ge-look-strength-val'); + if (strengthInput) { + try { + const saved = parseFloat(localStorage.getItem('ge-look-strength')); + if (Number.isFinite(saved) && saved > 0) strengthInput.value = String(saved); + } catch (_) {} + if (strengthVal) strengthVal.textContent = Number(strengthInput.value).toFixed(1) + '×'; + strengthInput.addEventListener('input', () => { + const v = Number(strengthInput.value); + if (strengthVal) strengthVal.textContent = v.toFixed(1) + '×'; + try { localStorage.setItem('ge-look-strength', String(v)); } catch (_) {} + }); + } + + function destroy() { + document.removeEventListener('keydown', onKey); + el.remove(); + } + + return { element: el, open, close, destroy }; +} diff --git a/static/js/editor/ai-tools-misc.js b/static/js/editor/ai-tools-misc.js index b9cb9fea1..0e7429782 100644 --- a/static/js/editor/ai-tools-misc.js +++ b/static/js/editor/ai-tools-misc.js @@ -220,16 +220,11 @@ export function wireAIToolsMisc({ renderLayerPanel(); composite(); } - const addButton = document.getElementById('ge-add-layer'); - addButton?.addEventListener('click', event => { - event.preventDefault(); - event.stopPropagation(); + const buildFxMenu = (anchor) => { document.querySelector('.ge-add-layer-menu')?.remove(); const menu = document.createElement('div'); menu.className = 'ge-add-layer-menu ge-frosted'; menu.innerHTML = ` - - @@ -242,9 +237,16 @@ export function wireAIToolsMisc({ + + + + + + + `; document.body.appendChild(menu); - const rect = addButton.getBoundingClientRect(); + const rect = anchor.getBoundingClientRect(); const menuRect = menu.getBoundingClientRect(); menu.style.left = `${Math.max(8, Math.min(window.innerWidth - menuRect.width - 8, rect.right - menuRect.width))}px`; menu.style.top = `${Math.max(8, Math.min(window.innerHeight - menuRect.height - 8, rect.bottom + 5))}px`; @@ -253,7 +255,7 @@ export function wireAIToolsMisc({ document.removeEventListener('pointerdown', away, true); }; const away = pointerEvent => { - if (!menu.contains(pointerEvent.target) && pointerEvent.target !== addButton) close(); + if (!menu.contains(pointerEvent.target) && pointerEvent.target !== anchor) close(); }; requestAnimationFrame(() => document.addEventListener('pointerdown', away, true)); menu.addEventListener('click', clickEvent => { @@ -261,10 +263,26 @@ export function wireAIToolsMisc({ if (!button) return; clickEvent.preventDefault(); clickEvent.stopPropagation(); - if (button.dataset.layerKind === 'raster') addEmptyLayer(); - else if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, addButton); + if (button.dataset.adjustmentType) openAdjustmentLayer?.(button.dataset.adjustmentType, anchor); close(); }); + }; + + // "+" adds a plain pixel layer directly. + const addButton = document.getElementById('ge-add-layer'); + addButton?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + document.querySelector('.ge-add-layer-menu')?.remove(); + addEmptyLayer(); + }); + + // "fx" opens the adjustment-layer chooser. + const addFxButton = document.getElementById('ge-add-fx'); + addFxButton?.addEventListener('click', event => { + event.preventDefault(); + event.stopPropagation(); + buildFxMenu(addFxButton); }); return { addEmptyLayer }; diff --git a/static/js/editor/aspect-fit.js b/static/js/editor/aspect-fit.js new file mode 100644 index 000000000..55933f855 --- /dev/null +++ b/static/js/editor/aspect-fit.js @@ -0,0 +1,35 @@ +// Pure aspect-fitting geometry for the editor. When a whole-image resize is +// asked for a target aspect that differs from the current image, content must +// be cropped to that aspect (centred on a focus point) and then scaled +// UNIFORMLY — never stretched. +// +// aspectFitCropRect returns the document-space rectangle to crop FIRST so a +// subsequent uniform scale-to-{targetW,targetH} preserves the subject aspect +// ratio. Returns the full image unchanged when the aspect already matches. + +export function aspectFitCropRect({ width, height, focusX, focusY, targetAspect, tolerance = 0.005 } = {}) { + const w = Number(width); + const h = Number(height); + const a = Number(targetAspect); + if (!(w > 0) || !(h > 0) || !(a > 0)) return null; + const curAspect = w / h; + if (Math.abs(curAspect - a) / a <= tolerance) return { x: 0, y: 0, width: Math.round(w), height: Math.round(h) }; + const fx = Number.isFinite(Number(focusX)) ? Number(focusX) : w / 2; + const fy = Number.isFinite(Number(focusY)) ? Number(focusY) : h / 2; + let cw; + let ch; + if (curAspect > a) { + ch = h; + cw = Math.max(1, Math.round(ch * a)); + } else { + cw = w; + ch = Math.max(1, Math.round(cw / a)); + } + let x = Math.round(fx - cw / 2); + let y = Math.round(fy - ch / 2); + x = Math.max(0, Math.min(w - cw, x)); + y = Math.max(0, Math.min(h - ch, y)); + return { x, y, width: cw, height: ch }; +} + +export default { aspectFitCropRect }; diff --git a/static/js/editor/batch-runner.js b/static/js/editor/batch-runner.js new file mode 100644 index 000000000..f8dd421a4 --- /dev/null +++ b/static/js/editor/batch-runner.js @@ -0,0 +1,35 @@ +/** + * Batch runner — a small, dependency-free orchestrator that applies one action + * to every item in a list (open documents), isolating each item so a failure on + * one never aborts the batch. Pure async control flow: the caller supplies + * `runOne`, so the same loop is unit-testable without an editor. + */ + +/** + * @param {object} options + * @param {Array<{id:string,name?:string}>} options.documents + * @param {(doc:object, index:number)=>Promise} options.runOne + * @param {number} [options.delayMs] + * @param {(done:number,total:number,doc:object)=>void} [options.onProgress] + * @returns {Promise>} + */ +export async function runBatchOverDocuments({ documents, runOne, delayMs = 0, onProgress = null } = {}) { + const list = Array.isArray(documents) ? documents.filter(Boolean) : []; + const results = []; + for (let index = 0; index < list.length; index += 1) { + const doc = list[index]; + try { + const output = await runOne(doc, index); + results.push({ id: doc.id, name: doc.name, ok: true, output: output === undefined ? null : output }); + } catch (error) { + results.push({ id: doc.id, name: doc.name, ok: false, error: String((error && error.message) || error) }); + } + if (typeof onProgress === 'function') { + try { onProgress(index + 1, list.length, doc); } catch { /* progress is best-effort */ } + } + if (delayMs > 0) await new Promise(resolve => setTimeout(resolve, delayMs)); + } + return results; +} + +export default { runBatchOverDocuments }; diff --git a/static/js/editor/blend-modes.js b/static/js/editor/blend-modes.js new file mode 100644 index 000000000..ee3f3f8e6 --- /dev/null +++ b/static/js/editor/blend-modes.js @@ -0,0 +1,256 @@ +/** + * Layer/graphic blend modes. + * + * Canvas2D can only composite a subset of the Photoshop blend modes natively. + * This module exposes that native set plus the "extra" modes PhotoCraft offers + * (Dissolve, Linear Burn, Darker Color, Linear Dodge/Add, Lighter Color, + * Vivid Light, Linear Light, Pin Light, Hard Mix, Subtract, Divide) and a + * single `blendDraw` entry point that both paths go through. + * + * The non-native modes follow the W3C compositing spec (separable + the two + * non-separable luminance modes) and operate on ImageData in the overlap of + * the source and the destination so rendering stays reasonably cheap. + */ + +export const NATIVE_BLEND_MODES = new Set([ + 'source-over', + 'multiply', + 'screen', + 'overlay', + 'darken', + 'lighten', + 'color-dodge', + 'color-burn', + 'hard-light', + 'soft-light', + 'difference', + 'exclusion', + 'hue', + 'saturation', + 'color', + 'luminosity', +]); + +const NATIVE_ALIASES = { + 'linear-dodge': 'lighter', + add: 'lighter', +}; + +export const EXTRA_BLEND_MODES = [ + { value: 'linear-dodge', label: 'Linear Dodge (Add)' }, + { value: 'linear-burn', label: 'Linear Burn' }, + { value: 'darker-color', label: 'Darker Color' }, + { value: 'lighter-color', label: 'Lighter Color' }, + { value: 'vivid-light', label: 'Vivid Light' }, + { value: 'linear-light', label: 'Linear Light' }, + { value: 'pin-light', label: 'Pin Light' }, + { value: 'hard-mix', label: 'Hard Mix' }, + { value: 'subtract', label: 'Subtract' }, + { value: 'divide', label: 'Divide' }, + { value: 'dissolve', label: 'Dissolve' }, +]; + +const NON_SEPARABLE = new Set(['darker-color', 'lighter-color']); + +export function isNativeBlendMode(mode) { + if (!mode) return true; + return NATIVE_BLEND_MODES.has(mode) || Object.prototype.hasOwnProperty.call(NATIVE_ALIASES, mode); +} + +export function isKnownBlendMode(mode) { + if (!mode) return true; + return isNativeBlendMode(mode) || EXTRA_BLEND_MODES.some(entry => entry.value === mode); +} + +function clamp01(value) { + return value < 0 ? 0 : value > 1 ? 1 : value; +} + +function lum(r, g, b) { + return 0.299 * r + 0.587 * g + 0.114 * b; +} + +// Separable per-channel blend functions on normalized [0,1] values. +function blendChannel(mode, cb, cs) { + switch (mode) { + case 'normal': + return cs; + case 'multiply': + return cb * cs; + case 'screen': + return cb + cs - cb * cs; + case 'overlay': + return cb <= 0.5 ? 2 * cb * cs : 1 - 2 * (1 - cb) * (1 - cs); + case 'darken': + return Math.min(cb, cs); + case 'lighten': + return Math.max(cb, cs); + case 'color-dodge': + if (cb === 0) return 0; + if (cs === 1) return 1; + return Math.min(1, cb / (1 - cs)); + case 'color-burn': + if (cb === 1) return 1; + if (cs === 0) return 0; + return 1 - Math.min(1, (1 - cb) / cs); + case 'hard-light': + return cs <= 0.5 ? 2 * cs * cb : 1 - 2 * (1 - cs) * (1 - cb); + case 'soft-light': { + if (cs <= 0.5) return cb - (1 - 2 * cs) * cb * (1 - cb); + const d = cb <= 0.25 ? ((16 * cb - 12) * cb + 4) * cb : Math.sqrt(cb); + return cb + (2 * cs - 1) * (d - cb); + } + case 'difference': + return Math.abs(cb - cs); + case 'exclusion': + return cb + cs - 2 * cb * cs; + case 'linear-burn': + return clamp01(cb + cs - 1); + case 'linear-dodge': + return clamp01(cb + cs); + case 'vivid-light': + return cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + case 'linear-light': + return clamp01(cb + 2 * cs - 1); + case 'pin-light': + return cs <= 0.5 ? Math.min(cb, 2 * cs) : Math.max(cb, 2 * cs - 1); + case 'hard-mix': { + const vivid = cs <= 0.5 + ? (cs === 0 ? 0 : 1 - Math.min(1, (1 - cb) / (2 * cs))) + : (cs === 1 ? 1 : Math.min(1, cb / (2 * (1 - cs)))); + return vivid < 0.5 ? 0 : 1; + } + case 'subtract': + return clamp01(cb - cs); + case 'divide': + return cs === 0 ? 1 : clamp01(cb / cs); + default: + return cs; + } +} + +function dissolveRandom(i) { + let seed = (i * 2654435761) >>> 0; + seed ^= seed >>> 13; + seed = (seed * 1274126177) >>> 0; + return seed / 4294967296; +} + +function ensureSourceData(source) { + const sw = source.width || source.naturalWidth || 0; + const sh = source.height || source.naturalHeight || 0; + if (!sw || !sh) return null; + const scratch = document.createElement('canvas'); + scratch.width = sw; + scratch.height = sh; + const sctx = scratch.getContext('2d'); + sctx.drawImage(source, 0, 0, sw, sh); + return { data: sctx.getImageData(0, 0, sw, sh).data, sw, sh }; +} + +function blendCustom(ctx, source, mode, alpha, dx, dy) { + if (alpha <= 0) return; + const src = ensureSourceData(source); + if (!src) return; + const tw = ctx.canvas.width; + const th = ctx.canvas.height; + const sw = src.sw; + const sh = src.sh; + const sx0 = Math.max(0, -dx); + const sy0 = Math.max(0, -dy); + const dx0 = Math.max(0, dx); + const dy0 = Math.max(0, dy); + const w = Math.min(sw - sx0, tw - dx0); + const h = Math.min(sh - sy0, th - dy0); + if (w <= 0 || h <= 0) return; + + const dst = ctx.getImageData(dx0, dy0, w, h); + const dd = dst.data; + const sd = src.data; + const nonSeparable = NON_SEPARABLE.has(mode); + const dissolve = mode === 'dissolve'; + + for (let y = 0; y < h; y += 1) { + for (let x = 0; x < w; x += 1) { + const si = ((y + sy0) * sw + (x + sx0)) * 4; + const di = (y * w + x) * 4; + const sa = (sd[si + 3] / 255) * alpha; + const ba = dd[di + 3] / 255; + + if (dissolve) { + const seed = (y + dy0) * 73856093 ^ (x + dx0) * 19349663; + if (dissolveRandom(seed) >= sa) continue; + dd[di] = sd[si]; + dd[di + 1] = sd[si + 1]; + dd[di + 2] = sd[si + 2]; + dd[di + 3] = 255; + continue; + } + + if (sa === 0) continue; + + const sr = sd[si] / 255; + const sg = sd[si + 1] / 255; + const sb = sd[si + 2] / 255; + const br = dd[di] / 255; + const bg = dd[di + 1] / 255; + const bb = dd[di + 2] / 255; + + let mergedR; + let mergedG; + let mergedB; + if (nonSeparable) { + const backdropLum = lum(br, bg, bb); + const sourceLum = lum(sr, sg, sb); + const useSource = mode === 'darker-color' ? sourceLum < backdropLum : sourceLum > backdropLum; + mergedR = useSource ? sr : br; + mergedG = useSource ? sg : bg; + mergedB = useSource ? sb : bb; + } else { + mergedR = blendChannel(mode, br, sr); + mergedG = blendChannel(mode, bg, sg); + mergedB = blendChannel(mode, bb, sb); + } + + const srEff = (1 - ba) * sr + ba * mergedR; + const sgEff = (1 - ba) * sg + ba * mergedG; + const sbEff = (1 - ba) * sb + ba * mergedB; + + const ao = sa + ba * (1 - sa); + if (ao <= 0) continue; + const cr = (sa * srEff + ba * br * (1 - sa)) / ao; + const cg = (sa * sgEff + ba * bg * (1 - sa)) / ao; + const cbOut = (sa * sbEff + ba * bb * (1 - sa)) / ao; + + dd[di] = Math.round(clamp01(cr) * 255); + dd[di + 1] = Math.round(clamp01(cg) * 255); + dd[di + 2] = Math.round(clamp01(cbOut) * 255); + dd[di + 3] = Math.round(ao * 255); + } + } + ctx.putImageData(dst, dx0, dy0); +} + +/** + * Composite `source` onto a 2D context (or canvas) using `mode` at `alpha`. + * Native modes use globalCompositeOperation; the rest fall back to pixel math. + */ +export function blendDraw(target, source, mode, alpha = 1, dx = 0, dy = 0) { + if (!target || !source) return; + const ctx = typeof target.getContext === 'function' ? target.getContext('2d') : target; + const a = Number.isFinite(alpha) ? clamp01(alpha) : 1; + const normalized = mode || 'source-over'; + if (isNativeBlendMode(normalized)) { + const prevAlpha = ctx.globalAlpha; + const prevOp = ctx.globalCompositeOperation; + ctx.globalAlpha = a; + ctx.globalCompositeOperation = NATIVE_ALIASES[normalized] || normalized; + ctx.drawImage(source, dx, dy); + ctx.globalAlpha = prevAlpha; + ctx.globalCompositeOperation = prevOp; + return; + } + blendCustom(ctx, source, normalized, a, dx, dy); +} diff --git a/static/js/editor/brush-engine.js b/static/js/editor/brush-engine.js index 3c6950f98..97c9d093a 100644 --- a/static/js/editor/brush-engine.js +++ b/static/js/editor/brush-engine.js @@ -24,6 +24,32 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit emit(emitted); }; + // Emit evenly spaced dabs from the last emitted sample toward `target`, + // carrying the sub-spacing remainder forward. Shared by drag updates and + // the final pointer-up so a stroke ends on the same spacing grid instead + // of stamping one detached dab (which reads as a dot where drawing stopped). + const emitSpaced = (target) => { + if (!emitted) return; + const start = { ...emitted }; + const dx = target.x - start.x; + const dy = target.y - start.y; + const distance = Math.hypot(dx, dy); + if (!distance) return; + const diameter = Math.max(1, Number(getDiameter()) || 1); + const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01)); + let travelled = spacing - remainder; + while (travelled <= distance) { + const t = travelled / distance; + output({ + x: start.x + dx * t, + y: start.y + dy * t, + pressure: start.pressure + (target.pressure - start.pressure) * t, + }); + travelled += spacing; + } + remainder = Math.max(0, distance - (travelled - spacing)); + }; + const begin = (sample) => { emitted = null; filtered = { ...sample, pressure: normalisePressure(sample.pressure) }; @@ -41,30 +67,14 @@ export function createBrushSampler({ getDiameter, getSpacing, getSmoothing, emit y: filtered.y + (raw.y - filtered.y) * response, pressure: filtered.pressure + (raw.pressure - filtered.pressure) * response, }; - const start = { ...emitted }; - const dx = filtered.x - start.x; - const dy = filtered.y - start.y; - const distance = Math.hypot(dx, dy); - if (!distance) return; - const diameter = Math.max(1, Number(getDiameter()) || 1); - const spacing = Math.max(0.5, diameter * Math.max(0.01, Number(getSpacing()) || 0.01)); - let travelled = spacing - remainder; - while (travelled <= distance) { - const t = travelled / distance; - output({ - x: start.x + dx * t, - y: start.y + dy * t, - pressure: start.pressure + (filtered.pressure - start.pressure) * t, - }); - travelled += spacing; - } - remainder = Math.max(0, distance - (travelled - spacing)); + emitSpaced(filtered); }; const end = (sample) => { + // Reach the true pointer-up point along the spacing grid rather than with + // a single far dab, so the stroke does not terminate in a detached dot. if (sample && emitted) { - const finalSample = { ...sample, pressure: normalisePressure(sample.pressure) }; - if (Math.hypot(finalSample.x - emitted.x, finalSample.y - emitted.y) > 0.25) output(finalSample); + emitSpaced({ ...sample, pressure: normalisePressure(sample.pressure) }); } emitted = null; filtered = null; diff --git a/static/js/editor/brush-presets.js b/static/js/editor/brush-presets.js index 5c1eab246..9533714bc 100644 --- a/static/js/editor/brush-presets.js +++ b/static/js/editor/brush-presets.js @@ -4,6 +4,7 @@ const BUILT_INS = [ { id: 'soft-round', name: 'Soft Round', size: 80, opacity: 100, flow: 35, softness: 100, spacing: 12, smoothing: 25, blendMode: 'source-over' }, { id: 'hard-round', name: 'Hard Round', size: 24, opacity: 100, flow: 100, softness: 0, spacing: 18, smoothing: 15, blendMode: 'source-over' }, { id: 'detail', name: 'Detail', size: 6, opacity: 100, flow: 70, softness: 20, spacing: 10, smoothing: 55, blendMode: 'source-over' }, + { id: 'pixel-1px', name: 'Pixel', size: 1, opacity: 100, flow: 100, softness: 0, spacing: 1, smoothing: 0, blendMode: 'source-over', pixel: true }, ]; export function loadBrushPresets(storage = globalThis.localStorage) { @@ -31,6 +32,7 @@ export function captureBrushPreset(state, name) { spacing: state.brushSpacing, smoothing: state.brushSmoothing, blendMode: state.brushBlendMode, + pixel: !!state.brushPixel, }; } @@ -43,4 +45,6 @@ export function applyBrushPreset(state, preset) { state.brushSpacing = preset.spacing; state.brushSmoothing = preset.smoothing; state.brushBlendMode = preset.blendMode || 'source-over'; + state.brushPixel = !!preset.pixel; } + diff --git a/static/js/editor/build/command-palette.js b/static/js/editor/build/command-palette.js new file mode 100644 index 000000000..2bef2c873 --- /dev/null +++ b/static/js/editor/build/command-palette.js @@ -0,0 +1,80 @@ +/** Command palette (Ctrl/Cmd+K): fuzzy list of tools, panels and actions. */ + +export function createCommandPalette({ commands = [], onRun = null } = {}) { + const overlay = document.createElement('div'); + overlay.className = 'ge-command-palette'; + overlay.hidden = true; + overlay.setAttribute('role', 'dialog'); + overlay.setAttribute('aria-modal', 'true'); + overlay.setAttribute('aria-label', 'Command palette'); + overlay.innerHTML = ` +
+ +
+
`; + document.body.appendChild(overlay); + + const input = overlay.querySelector('.ge-command-input'); + const list = overlay.querySelector('.ge-command-list'); + let filtered = []; + let activeIndex = 0; + + function render() { + const query = input.value.trim().toLowerCase(); + filtered = commands.filter(cmd => !query || String(cmd.label).toLowerCase().includes(query)); + list.innerHTML = ''; + if (!filtered.length) { + const empty = document.createElement('p'); + empty.className = 'ge-command-empty'; + empty.textContent = 'No matching commands'; + list.appendChild(empty); + return; + } + if (activeIndex >= filtered.length) activeIndex = 0; + filtered.forEach((cmd, index) => { + const row = document.createElement('button'); + row.type = 'button'; + row.className = `ge-command-item${index === activeIndex ? ' active' : ''}`; + row.setAttribute('role', 'option'); + row.setAttribute('aria-selected', index === activeIndex ? 'true' : 'false'); + row.textContent = cmd.label; + row.addEventListener('click', () => run(cmd)); + list.appendChild(row); + }); + } + + function run(cmd) { + close(); + if (typeof cmd.run === 'function') cmd.run(); + else if (typeof onRun === 'function') onRun(cmd); + } + + function open() { + overlay.hidden = false; + input.value = ''; + activeIndex = 0; + render(); + input.focus(); + } + + function close() { + overlay.hidden = true; + } + + input.addEventListener('input', () => { activeIndex = 0; render(); }); + input.addEventListener('keydown', (event) => { + if (event.key === 'Escape') { event.preventDefault(); close(); } + else if (event.key === 'ArrowDown') { event.preventDefault(); activeIndex = Math.min(activeIndex + 1, filtered.length - 1); render(); } + else if (event.key === 'ArrowUp') { event.preventDefault(); activeIndex = Math.max(activeIndex - 1, 0); render(); } + else if (event.key === 'Enter') { event.preventDefault(); if (filtered[activeIndex]) run(filtered[activeIndex]); } + }); + overlay.addEventListener('pointerdown', (event) => { if (event.target === overlay) close(); }); + + return { + element: overlay, + open, + close, + toggle: () => (overlay.hidden ? open() : close()), + isOpen: () => !overlay.hidden, + }; +} diff --git a/static/js/editor/build/controls.js b/static/js/editor/build/controls.js index 74f9d1fcb..ba2c09330 100644 --- a/static/js/editor/build/controls.js +++ b/static/js/editor/build/controls.js @@ -6,10 +6,10 @@ * sections are all toggled `display:none` here; the tool-switch handler * in galleryEditor.js shows the section matching the active tool. * - * @param {{ color: string, brushSize: number, wandTolerance: number }} ctx + * @param {{ color: string, brushSize: number, wandTolerance: number, bucketTolerance: number, cropAspectPreset: string, eyedropperSampleSize: number }} ctx * @returns {string} */ -export function controlsHTML({ color, brushSize, wandTolerance }) { +export function controlsHTML({ color, brushSize, wandTolerance, bucketTolerance = 32, cropAspectPreset = 'free', eyedropperSampleSize = 1 }) { const brushSliderValue = Math.round(Math.log(Math.max(1, brushSize)) / Math.log(800) * 1000); return `
+
+ + + + + +
Edit
@@ -43,7 +45,7 @@ export function shortcutsPopupHTML() {
Ctrl+Shift+S Save to Gallery
Ctrl+Alt+J New Layer
Ctrl+Alt+G Clipping Mask
-
Ctrl+Alt+T Free Transform
+
Ctrl+T Free Transform
Ctrl+Shift+T Canvas size…
@@ -62,6 +64,13 @@ export function shortcutsPopupHTML() {
] Brush size +
Drag tolerance slider → live wand retune
+
+
Touch
+
Pinch → Zoom
+
Two-finger drag → Pan
+
Two-finger tap → Undo
+
Three-finger tap → Redo
+
Press ? or click the keyboard icon to toggle.
`; @@ -81,6 +90,7 @@ export function historyPanelHTML(historyIcon) { +
`; } @@ -95,6 +105,7 @@ export function canvasSizePromptHTML() {