diff --git a/README.md b/README.md
index cfff42b..ccce63c 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,10 @@
# Web Video Optimizer
+
+
+
+
+
[](https://github.com/Artsen/web-video-optimizer/actions/workflows/ci.yml)
[](LICENSE)

@@ -119,6 +124,7 @@ flowchart LR
- [API reference](docs/api.md)
- [Architecture](docs/architecture.md)
- [Testing](docs/testing.md)
+- [Brand system](docs/brand-system.md)
- [Troubleshooting](docs/troubleshooting.md)
## Privacy And Security
diff --git a/apps/web/index.html b/apps/web/index.html
index f068c9e..644b148 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -3,6 +3,7 @@
+
Local Web Video Optimizer
diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg
new file mode 100644
index 0000000..4492fc6
--- /dev/null
+++ b/apps/web/public/favicon.svg
@@ -0,0 +1,13 @@
+
diff --git a/apps/web/src/assets/brand/mark-monochrome.svg b/apps/web/src/assets/brand/mark-monochrome.svg
new file mode 100644
index 0000000..41a9a19
--- /dev/null
+++ b/apps/web/src/assets/brand/mark-monochrome.svg
@@ -0,0 +1,5 @@
+
diff --git a/apps/web/src/assets/brand/mark.svg b/apps/web/src/assets/brand/mark.svg
new file mode 100644
index 0000000..e4ce861
--- /dev/null
+++ b/apps/web/src/assets/brand/mark.svg
@@ -0,0 +1,14 @@
+
diff --git a/apps/web/src/assets/brand/wordmark-dark.svg b/apps/web/src/assets/brand/wordmark-dark.svg
new file mode 100644
index 0000000..712501c
--- /dev/null
+++ b/apps/web/src/assets/brand/wordmark-dark.svg
@@ -0,0 +1,16 @@
+
diff --git a/apps/web/src/assets/brand/wordmark-light.svg b/apps/web/src/assets/brand/wordmark-light.svg
new file mode 100644
index 0000000..8855109
--- /dev/null
+++ b/apps/web/src/assets/brand/wordmark-light.svg
@@ -0,0 +1,16 @@
+
diff --git a/apps/web/src/components/ui/AppMark.test.tsx b/apps/web/src/components/ui/AppMark.test.tsx
new file mode 100644
index 0000000..b97ede2
--- /dev/null
+++ b/apps/web/src/components/ui/AppMark.test.tsx
@@ -0,0 +1,21 @@
+import { render, screen } from "@testing-library/react";
+import { describe, expect, it } from "vitest";
+import { AppMark } from "./AppMark";
+
+describe("AppMark", () => {
+ it("renders the compression-frame brand mark as decorative SVG geometry", () => {
+ render(
+
+
+
Web Video Optimizer
+
+ );
+
+ const mark = document.querySelector(".app-mark");
+ expect(mark).toHaveAttribute("aria-hidden", "true");
+ expect(screen.getByText("Web Video Optimizer")).toBeVisible();
+ expect(document.querySelector(".app-mark-frame")).toBeInTheDocument();
+ expect(document.querySelectorAll(".app-mark-band")).toHaveLength(3);
+ expect(document.querySelector(".app-mark-play")).toBeInTheDocument();
+ });
+});
diff --git a/apps/web/src/components/ui/AppMark.tsx b/apps/web/src/components/ui/AppMark.tsx
index 22000b2..c1af220 100644
--- a/apps/web/src/components/ui/AppMark.tsx
+++ b/apps/web/src/components/ui/AppMark.tsx
@@ -1,9 +1,23 @@
-import { Clapperboard } from "lucide-react";
-
export function AppMark({ size = "default" }: { size?: "default" | "small" }) {
return (
-
+
);
}
diff --git a/apps/web/src/styles/components.css b/apps/web/src/styles/components.css
index 852388f..6e6bf55 100644
--- a/apps/web/src/styles/components.css
+++ b/apps/web/src/styles/components.css
@@ -15,16 +15,46 @@
width: 40px;
height: 40px;
flex: 0 0 auto;
+ padding: 3px;
+ overflow: hidden;
+ background:
+ linear-gradient(var(--color-surface-recessed), var(--color-surface-recessed)) padding-box,
+ var(--gradient-brand-spectral) border-box;
+ border-color: transparent;
}
.app-mark.small {
width: 32px;
height: 32px;
+ padding: 2px;
+}
+
+.app-mark-icon {
+ display: block;
+ width: 100%;
+ height: 100%;
+}
+
+.app-mark-frame {
+ fill: url("#app-mark-spectral");
+}
+
+.app-mark-band {
+ fill: #ffffff;
+ fill-opacity: 0.92;
+}
+
+.app-mark-play {
+ fill: var(--color-ember-text);
}
.upload-icon {
width: 58px;
height: 58px;
+ background:
+ linear-gradient(var(--color-surface-elevated), var(--color-surface-elevated)) padding-box,
+ var(--gradient-brand-spectral) border-box;
+ border-color: transparent;
}
.preset-icon {
@@ -46,6 +76,8 @@
}
.dropzone {
+ position: relative;
+ overflow: hidden;
min-height: 430px;
padding: var(--space-6);
border-style: dashed;
@@ -53,6 +85,20 @@
background: var(--color-workspace-bg);
}
+.dropzone.dropzone-empty::before {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background: var(--gradient-ambient-field);
+ content: "";
+}
+
+.dropzone > * {
+ position: relative;
+ z-index: 1;
+}
+
.dropzone:has(.upload-preview) {
min-height: auto;
place-items: stretch;
@@ -94,8 +140,9 @@
}
.primary {
- color: #ffffff;
+ color: var(--color-primary-text);
background: var(--color-primary-bg);
+ border-color: color-mix(in srgb, var(--color-primary) 62%, transparent);
}
.primary:hover:not(:disabled) {
@@ -164,6 +211,7 @@ textarea:disabled {
color: var(--color-text-primary);
background: var(--color-surface-selected);
border-color: color-mix(in srgb, var(--color-primary) 38%, transparent);
+ box-shadow: inset 3px 0 0 var(--color-primary);
}
.workflow-step.primary-path {
@@ -304,13 +352,18 @@ progress {
}
progress::-webkit-progress-value {
- background: var(--color-primary);
+ background: var(--gradient-processing);
+}
+
+progress::-moz-progress-bar {
+ background: var(--gradient-processing);
}
.job-row {
grid-template-columns: minmax(180px, 0.88fr) minmax(170px, 1fr) auto;
border-color: var(--color-border-subtle);
background: var(--color-surface-recessed);
+ box-shadow: inset 3px 0 0 var(--color-ember);
}
.job-progress {
@@ -332,8 +385,9 @@ progress::-webkit-progress-value {
}
.output-card.active {
- background: var(--color-surface);
+ background: var(--color-surface-selected);
border-color: color-mix(in srgb, var(--color-primary) 34%, var(--color-border-subtle));
+ box-shadow: inset 3px 0 0 var(--color-primary);
}
.output-card.failed {
@@ -503,6 +557,8 @@ progress::-webkit-progress-value {
}
.route-state-panel {
+ position: relative;
+ overflow: hidden;
display: grid;
gap: var(--space-4);
max-width: 680px;
@@ -514,6 +570,21 @@ progress::-webkit-progress-value {
box-shadow: var(--shadow-elevated);
}
+.route-state-panel::before {
+ position: absolute;
+ inset: 0;
+ z-index: 0;
+ pointer-events: none;
+ background: var(--gradient-ambient-field);
+ opacity: 0.55;
+ content: "";
+}
+
+.route-state-panel > * {
+ position: relative;
+ z-index: 1;
+}
+
.route-state-panel h2 {
margin: 0;
font: var(--font-section-title);
@@ -526,6 +597,14 @@ progress::-webkit-progress-value {
.startup-error-panel {
text-align: left;
+ border-color: color-mix(in srgb, var(--color-danger) 32%, var(--color-border-subtle));
+}
+
+.startup-error-panel::before {
+ background:
+ radial-gradient(circle at 18% 16%, color-mix(in srgb, var(--color-danger) 9%, transparent), transparent 28%),
+ radial-gradient(circle at 84% 20%, color-mix(in srgb, var(--color-ember) 8%, transparent), transparent 30%);
+ opacity: 0.7;
}
.startup-details {
@@ -561,6 +640,7 @@ progress::-webkit-progress-value {
gap: var(--space-2);
align-items: center;
justify-content: space-between;
+ border-color: color-mix(in srgb, var(--color-warning) 36%, var(--color-border-subtle));
}
.route-state-actions {
@@ -1044,6 +1124,7 @@ progress::-webkit-progress-value {
.sidebar-nav-item,
.mobile-bottom-nav button,
.mobile-brand-button {
+ position: relative;
display: flex;
align-items: center;
gap: var(--space-2);
@@ -1070,6 +1151,7 @@ progress::-webkit-progress-value {
.mobile-bottom-nav button.active {
color: var(--color-text-primary);
background: var(--color-surface-selected);
+ box-shadow: inset 3px 0 0 var(--color-primary);
}
.sidebar-nav-item span {
@@ -1846,7 +1928,7 @@ progress::-webkit-progress-value {
.wipe-before {
z-index: 2;
- border-right: 1px solid color-mix(in srgb, white 72%, var(--color-primary));
+ border-right: 1px solid color-mix(in srgb, white 68%, var(--color-ember));
box-shadow: 8px 0 22px rgba(0, 0, 0, 0.28);
clip-path: inset(0 calc(100% - var(--wipe-position, 50%)) 0 0);
}
@@ -1874,10 +1956,10 @@ progress::-webkit-progress-value {
left: var(--wipe-position, 50%);
z-index: 4;
width: 2px;
- background: linear-gradient(var(--color-primary), white, var(--color-primary));
+ background: linear-gradient(var(--color-primary), white, var(--color-ember));
box-shadow:
0 0 0 1px rgba(0, 0, 0, 0.55),
- 0 0 18px rgba(16, 163, 127, 0.45);
+ 0 0 16px color-mix(in srgb, var(--color-ember) 32%, transparent);
content: "";
pointer-events: none;
}
@@ -2533,14 +2615,14 @@ progress::-webkit-progress-value {
:root[data-theme="light"] .utility-button,
:root[data-theme="light"] .icon-button {
color: var(--color-text-primary);
- background: #ffffff;
+ background: var(--color-surface);
border-color: color-mix(in srgb, var(--color-text-primary) 18%, transparent);
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.04);
}
:root[data-theme="light"] .utility-button {
- color: #111827;
- background: #ffffff;
+ color: var(--color-text-primary);
+ background: var(--color-surface);
}
:root[data-theme="light"] .utility-button:hover,
@@ -2579,3 +2661,40 @@ progress::-webkit-progress-value {
.package-disclosure summary {
font-weight: 400;
}
+
+@media (forced-colors: active) {
+ .app-mark,
+ .upload-icon {
+ forced-color-adjust: auto;
+ color: CanvasText;
+ background: Canvas;
+ border-color: ButtonBorder;
+ }
+
+ .app-mark-frame,
+ .app-mark-band,
+ .app-mark-play {
+ fill: currentColor;
+ }
+
+ .dropzone.dropzone-empty::before,
+ .route-state-panel::before {
+ display: none;
+ }
+
+ .workflow-step.active,
+ .sidebar-file.active,
+ .sidebar-nav-item.active,
+ .mobile-bottom-nav button.active,
+ .output-card.active,
+ .artifact-list-row.selected {
+ outline: 2px solid Highlight;
+ outline-offset: 2px;
+ box-shadow: none;
+ }
+
+ progress::-webkit-progress-value,
+ progress::-moz-progress-bar {
+ background: Highlight;
+ }
+}
diff --git a/apps/web/src/styles/features.css b/apps/web/src/styles/features.css
index be0448f..8d41825 100644
--- a/apps/web/src/styles/features.css
+++ b/apps/web/src/styles/features.css
@@ -14,24 +14,17 @@
--primary-soft: color-mix(in srgb, var(--color-primary) 10%, transparent);
--success: var(--color-success);
--success-soft: color-mix(in srgb, var(--color-success) 10%, transparent);
- --warning: #d6a84f;
- --warning-soft: rgba(214, 168, 79, 0.14);
- --danger: #ff6b6b;
- --danger-soft: rgba(255, 107, 107, 0.14);
- --info: #c7c7c7;
- --info-soft: rgba(255, 255, 255, 0.07);
+ --warning: var(--color-warning);
+ --warning-soft: color-mix(in srgb, var(--color-warning) 14%, transparent);
+ --danger: var(--color-danger);
+ --danger-soft: color-mix(in srgb, var(--color-danger) 13%, transparent);
+ --info: var(--color-info);
+ --info-soft: color-mix(in srgb, var(--color-info) 12%, transparent);
--shadow: none;
--radius: 8px;
color: var(--text);
background: var(--bg);
- font-family:
- Inter,
- ui-sans-serif,
- system-ui,
- -apple-system,
- BlinkMacSystemFont,
- "Segoe UI",
- sans-serif;
+ font-family: var(--font-family-base);
font-synthesis: none;
text-rendering: optimizeLegibility;
}
@@ -52,12 +45,12 @@
--primary-soft: color-mix(in srgb, var(--color-primary) 10%, transparent);
--success: var(--color-success);
--success-soft: color-mix(in srgb, var(--color-success) 10%, transparent);
- --warning: #986f22;
- --warning-soft: #fbf2df;
- --danger: #b4232a;
- --danger-soft: #fde9eb;
- --info: #4b5563;
- --info-soft: #f3f4f6;
+ --warning: var(--color-warning);
+ --warning-soft: color-mix(in srgb, var(--color-warning) 12%, transparent);
+ --danger: var(--color-danger);
+ --danger-soft: color-mix(in srgb, var(--color-danger) 11%, transparent);
+ --info: var(--color-info);
+ --info-soft: color-mix(in srgb, var(--color-info) 10%, transparent);
--shadow: none;
}
diff --git a/apps/web/src/styles/foundations.css b/apps/web/src/styles/foundations.css
index a818968..e93f354 100644
--- a/apps/web/src/styles/foundations.css
+++ b/apps/web/src/styles/foundations.css
@@ -7,7 +7,7 @@ body {
}
::selection {
- color: #03100d;
+ color: var(--color-primary-text);
background: var(--color-primary);
}
diff --git a/apps/web/src/styles/layout.css b/apps/web/src/styles/layout.css
index 668875d..325729e 100644
--- a/apps/web/src/styles/layout.css
+++ b/apps/web/src/styles/layout.css
@@ -1,6 +1,6 @@
.app-shell {
grid-template-columns: var(--layout-sidebar-width) minmax(0, 1fr);
- background: var(--color-workspace-bg);
+ background: var(--color-app-bg);
}
.workspace {
@@ -8,6 +8,7 @@
max-width: 1280px;
margin: 0 auto;
padding: var(--space-5) var(--space-6) var(--space-9);
+ background: var(--color-workspace-bg);
}
.sidebar {
@@ -49,6 +50,7 @@
}
.sidebar-file {
+ position: relative;
align-self: start;
min-height: 46px;
height: auto;
@@ -68,6 +70,7 @@
color: var(--color-text-primary);
background: var(--color-surface-selected);
border-color: color-mix(in srgb, var(--color-primary) 26%, transparent);
+ box-shadow: inset 3px 0 0 var(--color-primary);
}
.sidebar-footer {
@@ -76,6 +79,7 @@
}
.topbar {
+ position: relative;
align-items: center;
margin-bottom: var(--space-4);
padding: var(--space-3) 0;
@@ -86,6 +90,21 @@
box-shadow: none;
}
+.topbar::after {
+ position: absolute;
+ right: 0;
+ bottom: -1px;
+ left: 0;
+ height: 1px;
+ background: linear-gradient(
+ 90deg,
+ color-mix(in srgb, var(--color-primary) 52%, transparent),
+ color-mix(in srgb, var(--color-ember) 18%, transparent),
+ transparent 70%
+ );
+ content: "";
+}
+
.brand {
align-items: center;
gap: var(--space-3);
diff --git a/apps/web/src/styles/tokens.css b/apps/web/src/styles/tokens.css
index c08d798..f4652ee 100644
--- a/apps/web/src/styles/tokens.css
+++ b/apps/web/src/styles/tokens.css
@@ -1,36 +1,46 @@
:root {
color-scheme: dark;
- --color-app-bg: #181818;
- --color-workspace-bg: #202020;
- --color-sidebar-bg: #171717;
- --color-surface: #242424;
- --color-surface-elevated: #2a2a2a;
- --color-surface-recessed: #1b1b1b;
- --color-surface-hover: #303030;
- --color-surface-selected: #26332f;
- --color-border-subtle: rgba(255, 255, 255, 0.1);
- --color-border-strong: rgba(255, 255, 255, 0.16);
- --color-primary: #10a37f;
- --color-primary-bg: #0b745d;
- --color-primary-hover: #08634f;
- --color-primary-pressed: #064f40;
- --color-success: #22c59a;
- --color-info: #8ab4f8;
- --color-warning: #d6a84f;
- --color-danger: #ef6b73;
- --color-text-primary: #f4f4f5;
- --color-text-secondary: #c7c7c7;
- --color-text-subtle: #929292;
- --color-text-disabled: #707070;
- --color-focus-ring: #10a37f;
- --color-media-bg: #111111;
+ --color-app-bg: #080c12;
+ --color-workspace-bg: #0e151f;
+ --color-sidebar-bg: #090e15;
+ --color-surface: #141d29;
+ --color-surface-elevated: #1a2533;
+ --color-surface-recessed: #0c121b;
+ --color-surface-hover: #202c3c;
+ --color-surface-selected: #1c2440;
+ --color-border-subtle: rgba(190, 205, 225, 0.1);
+ --color-border-strong: rgba(190, 205, 225, 0.18);
+ --color-border-emphasis: rgba(190, 205, 225, 0.28);
+ --color-primary: #6a5bcf;
+ --color-primary-bg: #6a5bcf;
+ --color-primary-hover: #5d4fc0;
+ --color-primary-pressed: #5144ae;
+ --color-primary-text: #ffffff;
+ --color-ember: #f2763f;
+ --color-ember-bright: #ff9664;
+ --color-ember-text: #111722;
+ --color-success: #35c981;
+ --color-info: #5aa7ff;
+ --color-warning: #e8b44d;
+ --color-danger: #f06a72;
+ --color-text-primary: #f2f5f8;
+ --color-text-secondary: #b8c2cf;
+ --color-text-subtle: #7e8b9b;
+ --color-text-disabled: #5e6978;
+ --color-focus-ring: #9b8eff;
+ --color-media-bg: #06090e;
--color-overlay: rgba(0, 0, 0, 0.72);
--color-scrim: rgba(0, 0, 0, 0.62);
+ --gradient-brand-spectral: linear-gradient(120deg, #5f6fe5 0%, #765bc8 54%, #e66a3a 100%);
+ --gradient-processing: var(--gradient-brand-spectral);
+ --gradient-ambient-field:
+ radial-gradient(circle at 24% 18%, color-mix(in srgb, var(--color-primary) 12%, transparent), transparent 34%),
+ radial-gradient(circle at 82% 24%, color-mix(in srgb, var(--color-ember) 10%, transparent), transparent 30%);
--layout-sidebar-width: 300px;
--layout-sidebar-medium-width: 260px;
- --font-family-base: "Segoe UI Variable Text", "Segoe UI", Inter, system-ui, sans-serif;
+ --font-family-base: "Segoe UI Variable", "Segoe UI Variable Text", "Segoe UI", Inter, system-ui, sans-serif;
--font-family-mono: ui-monospace, SFMono-Regular, Consolas, "Liberation Mono", monospace;
--font-product-title: 550 0.95rem/1.2 var(--font-family-base);
--font-page-title: 600 clamp(1.45rem, 2vw, 1.75rem) / 1.15 var(--font-family-base);
@@ -54,14 +64,14 @@
--space-9: 48px;
--radius-control: 8px;
- --radius-card: 8px;
- --radius-hero: 12px;
+ --radius-card: 10px;
+ --radius-hero: 14px;
--radius-pill: 999px;
--radius-dialog: 12px;
--radius-media: 8px;
--shadow-subtle: none;
- --shadow-elevated: 0 1px 2px rgba(0, 0, 0, 0.16);
+ --shadow-elevated: 0 10px 30px rgba(0, 0, 0, 0.18);
--shadow-dialog: 0 24px 80px rgba(0, 0, 0, 0.44);
--motion-fast: 120ms ease;
@@ -81,8 +91,8 @@
--color-control-quiet-hover: color-mix(in srgb, var(--color-text-primary) 13%, transparent);
--color-control-disabled: color-mix(in srgb, var(--color-text-disabled) 10%, transparent);
--color-border-interactive: color-mix(in srgb, var(--color-text-primary) 18%, transparent);
- --color-media-controls-bg: color-mix(in srgb, #101010 78%, transparent);
- --color-media-controls-text: #f4f4f5;
+ --color-media-controls-bg: color-mix(in srgb, var(--color-media-bg) 78%, transparent);
+ --color-media-controls-text: var(--color-text-primary);
--color-media-controls-secondary: rgba(255, 255, 255, 0.72);
--bg: var(--color-app-bg);
@@ -98,6 +108,8 @@
--primary: var(--color-primary);
--primary-strong: var(--color-primary-bg);
--primary-soft: color-mix(in srgb, var(--color-primary) 12%, transparent);
+ --ember: var(--color-ember);
+ --ember-soft: color-mix(in srgb, var(--color-ember) 12%, transparent);
--success: var(--color-success);
--success-soft: color-mix(in srgb, var(--color-success) 12%, transparent);
--warning: var(--color-warning);
@@ -113,41 +125,49 @@
:root[data-theme="light"] {
color-scheme: light;
- --color-app-bg: #f7f7f7;
+ --color-app-bg: #f2f4f7;
--color-workspace-bg: #ffffff;
- --color-sidebar-bg: #f4f4f5;
+ --color-sidebar-bg: #e9edf2;
--color-surface: #ffffff;
--color-surface-elevated: #ffffff;
- --color-surface-recessed: #f4f4f5;
- --color-surface-hover: #eeeeef;
- --color-surface-selected: #e9f7f3;
- --color-border-subtle: rgba(0, 0, 0, 0.1);
- --color-border-strong: rgba(0, 0, 0, 0.16);
- --color-primary: #0f8068;
- --color-primary-bg: #0b745d;
- --color-primary-hover: #08634f;
- --color-primary-pressed: #064f40;
- --color-success: #0f8068;
- --color-info: #2563eb;
- --color-warning: #9a6700;
- --color-danger: #b4232a;
- --color-text-primary: #202123;
- --color-text-secondary: #4b4b4f;
- --color-text-subtle: #6f6f76;
- --color-text-disabled: #9a9aa0;
- --color-focus-ring: #0f8068;
- --color-media-bg: #111111;
+ --color-surface-recessed: #eef1f5;
+ --color-surface-hover: #e5eaf0;
+ --color-surface-selected: #eeeafe;
+ --color-border-subtle: rgba(18, 30, 48, 0.1);
+ --color-border-strong: rgba(18, 30, 48, 0.18);
+ --color-border-emphasis: rgba(18, 30, 48, 0.28);
+ --color-primary: #5848b8;
+ --color-primary-bg: #5848b8;
+ --color-primary-hover: #49399f;
+ --color-primary-pressed: #3f318a;
+ --color-primary-text: #ffffff;
+ --color-ember: #b84c1f;
+ --color-ember-bright: #ca5c2a;
+ --color-ember-text: #ffffff;
+ --color-success: #187a4b;
+ --color-info: #2463c5;
+ --color-warning: #8b5c00;
+ --color-danger: #b72c3b;
+ --color-text-primary: #151b24;
+ --color-text-secondary: #465366;
+ --color-text-subtle: #536174;
+ --color-text-disabled: #7f8a9a;
+ --color-focus-ring: #6958ca;
+ --color-media-bg: #090d13;
--color-overlay: rgba(0, 0, 0, 0.62);
--color-scrim: rgba(0, 0, 0, 0.42);
- --color-control-quiet: rgba(0, 0, 0, 0.04);
- --color-control-quiet-hover: rgba(0, 0, 0, 0.075);
- --color-control-disabled: rgba(0, 0, 0, 0.055);
- --color-border-interactive: rgba(0, 0, 0, 0.18);
- --color-media-controls-bg: rgba(17, 17, 17, 0.78);
+ --gradient-ambient-field:
+ radial-gradient(circle at 24% 18%, color-mix(in srgb, var(--color-primary) 7%, transparent), transparent 34%),
+ radial-gradient(circle at 82% 24%, color-mix(in srgb, var(--color-ember) 5%, transparent), transparent 30%);
+ --color-control-quiet: rgba(18, 30, 48, 0.045);
+ --color-control-quiet-hover: rgba(18, 30, 48, 0.08);
+ --color-control-disabled: #f5f7fa;
+ --color-border-interactive: rgba(18, 30, 48, 0.18);
+ --color-media-controls-bg: rgba(9, 13, 19, 0.78);
--color-media-controls-text: #ffffff;
--color-media-controls-secondary: rgba(255, 255, 255, 0.76);
--shadow-subtle: none;
- --shadow-elevated: 0 1px 2px rgba(0, 0, 0, 0.08);
+ --shadow-elevated: 0 8px 22px rgba(18, 30, 48, 0.1);
--shadow-dialog: 0 24px 80px rgba(0, 0, 0, 0.24);
}
diff --git a/docs/README.md b/docs/README.md
index ef88c2a..878b791 100644
--- a/docs/README.md
+++ b/docs/README.md
@@ -19,4 +19,5 @@ Start here when you want the app to run, understand it, change it, or debug it.
- [Architecture](architecture.md): service boundaries, storage model, job lifecycle, frontend route model, and extension points.
- [API Reference](api.md): local API endpoints and response behavior.
- [Testing](testing.md): unit, coverage, browser E2E, real-media integration, and screenshot review commands.
+- [Brand System](brand-system.md): visual identity, palette, logo rules, interaction states, and screenshot asset guidance.
- [Contributing](../CONTRIBUTING.md): branch hygiene, review expectations, and files that must not be committed.
diff --git a/docs/assets/brand/mark.svg b/docs/assets/brand/mark.svg
new file mode 100644
index 0000000..e4ce861
--- /dev/null
+++ b/docs/assets/brand/mark.svg
@@ -0,0 +1,14 @@
+
diff --git a/docs/assets/brand/wordmark-dark.svg b/docs/assets/brand/wordmark-dark.svg
new file mode 100644
index 0000000..712501c
--- /dev/null
+++ b/docs/assets/brand/wordmark-dark.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs/assets/brand/wordmark-light.svg b/docs/assets/brand/wordmark-light.svg
new file mode 100644
index 0000000..8855109
--- /dev/null
+++ b/docs/assets/brand/wordmark-light.svg
@@ -0,0 +1,16 @@
+
diff --git a/docs/assets/screenshots/README.md b/docs/assets/screenshots/README.md
index 8cee1c5..3ab01b5 100644
--- a/docs/assets/screenshots/README.md
+++ b/docs/assets/screenshots/README.md
@@ -1,7 +1,19 @@
-# Screenshots
+# Screenshot Assets
These curated WebP screenshots are copied from deterministic `npm run review:ui-screens` output and compressed for repository documentation.
+Only mocked review data may be used for committed screenshots. Do not capture real user media, personal filenames, local filesystem paths, storage roots, secrets, raw API logs, browser chrome, or anything from `data/`.
+
+## Refresh Workflow
+
+1. Run `npm run review:ui-screens`.
+2. Inspect the generated PNG files under `.tmp/ui-review/`.
+3. Convert only the curated captures listed below to WebP.
+4. Keep the committed set to roughly five to seven screenshots.
+5. Leave `.tmp/ui-review/` and any contact sheets uncommitted.
+
+Review captures are full-page unless the Playwright screenshot suite names them as narrow or dialog-specific viewport captures.
+
| Asset | Source capture |
| -------------------------- | ---------------------------------------------- |
| `prepare-dark.webp` | `.tmp/ui-review/source-dark-desktop.png` |
@@ -11,4 +23,6 @@ These curated WebP screenshots are copied from deterministic `npm run review:ui-
| `custom-export-dark.webp` | `.tmp/ui-review/custom-dark-desktop.png` |
| `mobile-results-dark.webp` | `.tmp/ui-review/results-narrow.png` |
-Do not commit the full `.tmp/ui-review/` directory.
+The current curated set balances dark workflow coverage, one light-theme result, Compare theatre, Custom Export density, and mobile bottom chrome. Add an empty-state screenshot only when it materially improves the README.
+
+Current curated set: 6 WebP files, 260,638 bytes total.
diff --git a/docs/assets/screenshots/compare-wipe-dark.webp b/docs/assets/screenshots/compare-wipe-dark.webp
index 9ea5bf2..a8be3bf 100644
Binary files a/docs/assets/screenshots/compare-wipe-dark.webp and b/docs/assets/screenshots/compare-wipe-dark.webp differ
diff --git a/docs/assets/screenshots/custom-export-dark.webp b/docs/assets/screenshots/custom-export-dark.webp
index b5f3cf8..b8e30b6 100644
Binary files a/docs/assets/screenshots/custom-export-dark.webp and b/docs/assets/screenshots/custom-export-dark.webp differ
diff --git a/docs/assets/screenshots/mobile-results-dark.webp b/docs/assets/screenshots/mobile-results-dark.webp
index b1e1537..9f67d81 100644
Binary files a/docs/assets/screenshots/mobile-results-dark.webp and b/docs/assets/screenshots/mobile-results-dark.webp differ
diff --git a/docs/assets/screenshots/prepare-dark.webp b/docs/assets/screenshots/prepare-dark.webp
index b6c54d3..70e57bc 100644
Binary files a/docs/assets/screenshots/prepare-dark.webp and b/docs/assets/screenshots/prepare-dark.webp differ
diff --git a/docs/assets/screenshots/results-dark.webp b/docs/assets/screenshots/results-dark.webp
index f2c9bee..803f076 100644
Binary files a/docs/assets/screenshots/results-dark.webp and b/docs/assets/screenshots/results-dark.webp differ
diff --git a/docs/assets/screenshots/results-light.webp b/docs/assets/screenshots/results-light.webp
index 6d61846..d6b3995 100644
Binary files a/docs/assets/screenshots/results-light.webp and b/docs/assets/screenshots/results-light.webp differ
diff --git a/docs/brand-system.md b/docs/brand-system.md
new file mode 100644
index 0000000..c9acb18
--- /dev/null
+++ b/docs/brand-system.md
@@ -0,0 +1,108 @@
+# Brand System
+
+Web Video Optimizer uses a "precision slate with a controlled spectral transformation" direction: calm local tooling, clear media operations, and restrained moments of color where work is selected, processing, or complete.
+
+## Principles
+
+- Slate neutrals carry most of the interface.
+- Iris marks primary interaction, selection, and focus.
+- Ember appears sparingly as the transformation accent.
+- Green is reserved for genuine success.
+- Blue, amber, and red stay semantic for information, caution, and failure.
+- Dense workspaces stay operational and quiet; empty states may express the brand more strongly.
+
+The intended balance is roughly 80% slate structure, 15% tonal elevation and borders, 4% iris interaction, and 1% ember accent.
+
+## Logo
+
+The mark is a compression-frame symbol: an outer video frame, three narrowing source bands, and a terminal output/play shape. It should suggest media reduction and website-ready delivery without resembling a generic clapperboard or AI sparkle.
+
+Canonical app assets live in `apps/web/src/assets/brand/`. Documentation copies live in `docs/assets/brand/` so GitHub can render README imagery.
+
+Logo variants:
+
+- `mark.svg`: spectral icon mark.
+- `mark-monochrome.svg`: one-color app mark.
+- `wordmark-dark.svg`: horizontal logo for dark backgrounds.
+- `wordmark-light.svg`: horizontal logo for light backgrounds.
+- `apps/web/public/favicon.svg`: favicon-compatible mark.
+
+When the mark sits next to visible "Web Video Optimizer" text, it should be `aria-hidden` to avoid duplicate announcements. Use text alternatives for standalone documentation imagery.
+
+Clear space should be at least one quarter of the mark width on all sides. Minimum practical sizes are 16px for the favicon mark, 20px for navigation chrome, 32px for prominent UI placement, and 180px wide for the wordmark.
+
+Do not rotate, stretch, recolor outside the approved palette, add glow effects, place on low-contrast imagery, replace the compression bands, or use the spectral gradient as ordinary text fill.
+
+## Palette
+
+Dark theme:
+
+| Token | Value |
+| -------------------------- | --------- |
+| `--color-app-bg` | `#080c12` |
+| `--color-sidebar-bg` | `#090e15` |
+| `--color-workspace-bg` | `#0e151f` |
+| `--color-surface` | `#141d29` |
+| `--color-surface-elevated` | `#1a2533` |
+| `--color-surface-recessed` | `#0c121b` |
+| `--color-surface-hover` | `#202c3c` |
+| `--color-surface-selected` | `#1c2440` |
+| `--color-primary` | `#6a5bcf` |
+| `--color-ember` | `#f2763f` |
+| `--color-success` | `#35c981` |
+| `--color-info` | `#5aa7ff` |
+| `--color-warning` | `#e8b44d` |
+| `--color-danger` | `#f06a72` |
+
+Light theme:
+
+| Token | Value |
+| -------------------------- | --------- |
+| `--color-app-bg` | `#f2f4f7` |
+| `--color-sidebar-bg` | `#e9edf2` |
+| `--color-workspace-bg` | `#ffffff` |
+| `--color-surface` | `#ffffff` |
+| `--color-surface-elevated` | `#ffffff` |
+| `--color-surface-recessed` | `#eef1f5` |
+| `--color-surface-hover` | `#e5eaf0` |
+| `--color-surface-selected` | `#eeeafe` |
+| `--color-primary` | `#5848b8` |
+| `--color-ember` | `#b84c1f` |
+| `--color-success` | `#187a4b` |
+| `--color-info` | `#2463c5` |
+| `--color-warning` | `#8b5c00` |
+| `--color-danger` | `#b72c3b` |
+
+## Gradients
+
+The canonical spectral gradient is:
+
+```css
+linear-gradient(120deg, #5f6fe5 0%, #765bc8 54%, #e66a3a 100%)
+```
+
+Use it for the logo, thin brand accents, active processing progress, empty-state atmosphere, and selected transformation indicators. Do not use it as every button fill, panel border, page background, ordinary label treatment, or semantic status substitute.
+
+Ambient fields may appear behind empty or startup states only. They must stay subtle, static, behind content, and disabled under forced-colors.
+
+## Typography And Shape
+
+The app uses a system stack centered on Segoe UI Variable, Segoe UI, Inter when locally available, system-ui, and sans-serif. Use weight and spacing before adding extra color.
+
+Controls use approximately 8px radius, cards 10px, and dialogs or major empty states 14px. Dark mode relies on tonal elevation and borders more than shadows.
+
+## Interaction And Status
+
+Primary actions are solid iris with white text. Secondary actions use quiet slate surfaces. Selected states use an iris rail or border plus a tonal shift. Processing may use iris-to-ember progress. Completed states use green with text or icon support. Warning, danger, and destructive states stay amber or red and must not be replaced with ember.
+
+Focus uses the iris focus ring and remains visible in dark, light, and forced-colors modes. Do not remove focus outlines.
+
+## Accessibility
+
+Normal text should meet at least 4.5:1 contrast and large text at least 3:1. Status must not rely on color alone. Reduced-motion and forced-colors users should receive simplified, readable treatments. Screenshot review and Playwright accessibility checks are part of the release process.
+
+## Screenshot Examples
+
+Curated README screenshots are stored in `docs/assets/screenshots/`. Full deterministic review captures are generated into `.tmp/ui-review/` by `npm run review:ui-screens` and must not be committed.
+
+The screenshot set should cover dark and light themes, Prepare, Results, Compare, Custom Export, mobile, startup failure, degraded startup, processing, completed, warning, and selected-output states.
diff --git a/e2e/specs/screenshot-review.spec.ts b/e2e/specs/screenshot-review.spec.ts
index 3133dcd..c592652 100644
--- a/e2e/specs/screenshot-review.spec.ts
+++ b/e2e/specs/screenshot-review.spec.ts
@@ -4,7 +4,7 @@ import { expect, test } from "@playwright/test";
import { fallbackJob, installMockApi, modernJob } from "../fixtures/api-routes";
if (process.env.UI_SCREENSHOT_REVIEW === "1") {
- test("captures deterministic UI-A review screenshots", async ({ page, browser }) => {
+ test("captures deterministic UI-B brand review screenshots", async ({ page, browser }) => {
const outputDir = path.join(process.cwd(), ".tmp", "ui-review");
await mkdir(outputDir, { recursive: true });
@@ -24,8 +24,8 @@ if (process.env.UI_SCREENSHOT_REVIEW === "1") {
position: relative;
overflow: hidden;
background:
- linear-gradient(135deg, rgba(16, 163, 127, 0.22), transparent 42%),
- linear-gradient(25deg, #1c3550 0 30%, #314f47 30% 54%, #6d6141 54% 70%, #222 70%);
+ linear-gradient(120deg, rgba(95, 111, 229, 0.24), transparent 38%),
+ linear-gradient(24deg, #0b1220 0 30%, #1c2440 30% 54%, #583d48 54% 68%, #101722 68%);
}
html[data-compare-fixture="visual"] .compare-media-stage::before {
position: absolute;
@@ -33,7 +33,7 @@ if (process.env.UI_SCREENSHOT_REVIEW === "1") {
border-radius: 18px;
background:
radial-gradient(circle at 28% 28%, rgba(255, 255, 255, 0.72) 0 5%, transparent 5.4%),
- linear-gradient(145deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.02)),
+ linear-gradient(145deg, rgba(255, 255, 255, 0.17), rgba(242, 118, 63, 0.04)),
repeating-linear-gradient(90deg, rgba(255, 255, 255, 0.11) 0 1px, transparent 1px 46px);
box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.16);
content: "";
@@ -64,11 +64,13 @@ if (process.env.UI_SCREENSHOT_REVIEW === "1") {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Web Video Optimizer" })).toBeVisible();
await capture("empty-dark-desktop");
+ await capture("empty-brand-field-dark");
await capture("empty-compact-dark");
await page.getByRole("button", { name: "Light Mode" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await capture("empty-light-desktop");
+ await capture("empty-brand-field-light");
await capture("empty-compact-light");
await page.getByLabel("Choose Video").setInputFiles({
@@ -88,6 +90,7 @@ if (process.env.UI_SCREENSHOT_REVIEW === "1") {
await capture("source-player-controls-dark");
await page.getByRole("button", { name: "Optimize For Website" }).click();
+ await capture("processing-progress-dark");
await page.evaluate(
(jobs) => {
const emit = (window as unknown as { __emitJobEvent(urlPart: string, payload: unknown): void }).__emitJobEvent;
@@ -100,6 +103,7 @@ if (process.env.UI_SCREENSHOT_REVIEW === "1") {
await expect(page.locator(".output-kind", { hasText: "MP4 fallback" })).toBeVisible();
await capture("results-dark-desktop", page, false);
await capture("results-selected-output-dark", page, false);
+ await capture("completed-success-dark", page, false);
await page.reload();
await expect(page).toHaveURL(/view=results/);
await expect(page.getByRole("heading", { name: "Results" })).toBeVisible();
@@ -164,6 +168,7 @@ if (process.env.UI_SCREENSHOT_REVIEW === "1") {
await expect(page.getByRole("heading", { name: "Library" })).toBeVisible();
await capture("library-dark-desktop");
await capture("storage-warning-dark");
+ await capture("warning-state-dark");
await page.getByRole("button", { name: "Light Mode" }).click();
await expect(page.locator("html")).toHaveAttribute("data-theme", "light");
await capture("library-light-desktop");
@@ -189,5 +194,41 @@ if (process.env.UI_SCREENSHOT_REVIEW === "1") {
element.scrollTop = element.scrollHeight;
});
await capture("mobile-bottom-chrome-results", page, false);
+
+ const unreachablePage = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ let apiReachable = false;
+ await installMockApi(unreachablePage);
+ await unreachablePage.route("**/api/**", async (route) => {
+ if (!apiReachable) {
+ await route.abort("failed");
+ return;
+ }
+ await route.fallback();
+ });
+ await unreachablePage.goto("/");
+ await expect(
+ unreachablePage.getByRole("alert").getByRole("heading", { name: "Cannot reach the local API" })
+ ).toBeVisible();
+ await capture("api-unreachable-dark-desktop", unreachablePage);
+ await unreachablePage.getByRole("button", { name: "Light Mode" }).click();
+ await expect(unreachablePage.locator("html")).toHaveAttribute("data-theme", "light");
+ await capture("api-unreachable-light-desktop", unreachablePage);
+ await unreachablePage.setViewportSize({ width: 390, height: 844 });
+ await capture("api-unreachable-mobile", unreachablePage);
+ apiReachable = true;
+ await unreachablePage.getByRole("button", { name: "Retry connection" }).click();
+ await expect(unreachablePage.getByRole("heading", { name: "Ready for a source video" })).toBeVisible();
+ await capture("retry-success-light-desktop", unreachablePage);
+ await unreachablePage.close();
+
+ const degradedPage = await browser.newPage({ viewport: { width: 1440, height: 900 } });
+ await installMockApi(degradedPage);
+ await degradedPage.route("**/api/storage", async (route) => {
+ await route.fulfill({ status: 500, headers: { "content-type": "application/json" }, body: "{}" });
+ });
+ await degradedPage.goto("/");
+ await expect(degradedPage.getByRole("status")).toContainText("Storage status");
+ await capture("degraded-bootstrap-dark-desktop", degradedPage);
+ await degradedPage.close();
});
}