diff --git a/Cargo.toml b/Cargo.toml index f2216b7..5f2fc3f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -6,6 +6,7 @@ members = [ "crates/ely_domain", "crates/ely_servo_host", ] +default-members = ["crates/ely_app"] resolver = "2" [workspace.package] diff --git a/DESIGN.md b/DESIGN.md deleted file mode 100644 index 7d598b0..0000000 --- a/DESIGN.md +++ /dev/null @@ -1,537 +0,0 @@ ---- -version: alpha -name: Cursor -description: An AI-first code editor whose marketing site reads like a quietly-confident developer-tools brand with a warm-cream editorial canvas (`#f7f7f4`) instead of the typical dark IDE atmosphere. Near-black warm ink (`#26251e`) carries body and display alike — display sits at weight 400 with negative letter-spacing for a magazine feel rather than a bold tech voice. The single brand voltage is **Cursor Orange** (`#f54e00`) reserved for primary CTAs and the wordmark. A signature pastel timeline palette (peach, mint, blue, lavender, gold) marks AI-action stages (Thinking / Reading / Editing / Grepping / Done) — only inside in-product timeline visualizations. Cards use minimal hairlines, no shadows, generous 80px section rhythm. CursorGothic for display/body, JetBrains Mono on every code surface (which is roughly half the page). - -colors: - primary: "#f54e00" - primary-active: "#d04200" - ink: "#26251e" - body: "#5a5852" - body-strong: "#26251e" - muted: "#807d72" - muted-soft: "#a09c92" - hairline: "#e6e5e0" - hairline-soft: "#efeee8" - hairline-strong: "#cfcdc4" - canvas: "#f7f7f4" - canvas-soft: "#fafaf7" - surface-card: "#ffffff" - surface-strong: "#e6e5e0" - on-primary: "#ffffff" - timeline-thinking: "#dfa88f" - timeline-grep: "#9fc9a2" - timeline-read: "#9fbbe0" - timeline-edit: "#c0a8dd" - timeline-done: "#c08532" - semantic-error: "#cf2d56" - semantic-success: "#1f8a65" - -typography: - display-mega: - fontFamily: "'CursorGothic', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif" - fontSize: 72px - fontWeight: 400 - lineHeight: 1.1 - letterSpacing: -2.16px - display-lg: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 36px - fontWeight: 400 - lineHeight: 1.2 - letterSpacing: -0.72px - display-md: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 26px - fontWeight: 400 - lineHeight: 1.25 - letterSpacing: -0.325px - display-sm: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 22px - fontWeight: 400 - lineHeight: 1.3 - letterSpacing: -0.11px - title-md: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 18px - fontWeight: 600 - lineHeight: 1.4 - letterSpacing: 0 - title-sm: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 16px - fontWeight: 600 - lineHeight: 1.4 - letterSpacing: 0 - body-md: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 16px - fontWeight: 400 - lineHeight: 1.5 - letterSpacing: 0 - body-tracked: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 16px - fontWeight: 400 - lineHeight: 1.5 - letterSpacing: 0.08px - body-sm: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 14px - fontWeight: 400 - lineHeight: 1.5 - letterSpacing: 0 - caption: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 13px - fontWeight: 400 - lineHeight: 1.4 - letterSpacing: 0 - caption-uppercase: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 11px - fontWeight: 600 - lineHeight: 1.4 - letterSpacing: 0.88px - textTransform: uppercase - code: - fontFamily: "'JetBrains Mono', 'Fira Code', monospace" - fontSize: 13px - fontWeight: 400 - lineHeight: 1.5 - letterSpacing: 0 - button: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 14px - fontWeight: 500 - lineHeight: 1.0 - letterSpacing: 0 - nav-link: - fontFamily: "'CursorGothic', sans-serif" - fontSize: 14px - fontWeight: 500 - lineHeight: 1.4 - letterSpacing: 0 - -rounded: - none: 0px - xs: 4px - sm: 6px - md: 8px - lg: 12px - xl: 16px - pill: 9999px - full: 9999px - -spacing: - xxs: 4px - xs: 8px - sm: 12px - base: 16px - md: 20px - lg: 24px - xl: 32px - xxl: 48px - section: 80px - -components: - top-nav: - backgroundColor: "{colors.canvas}" - textColor: "{colors.ink}" - typography: "{typography.nav-link}" - height: 64px - button-primary: - backgroundColor: "{colors.primary}" - textColor: "{colors.on-primary}" - typography: "{typography.button}" - rounded: "{rounded.md}" - padding: 10px 18px - height: 40px - button-primary-active: - backgroundColor: "{colors.primary-active}" - textColor: "{colors.on-primary}" - rounded: "{rounded.md}" - button-secondary: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.ink}" - typography: "{typography.button}" - rounded: "{rounded.md}" - padding: 9px 17px - height: 40px - button-tertiary-text: - backgroundColor: transparent - textColor: "{colors.ink}" - typography: "{typography.button}" - button-download: - backgroundColor: "{colors.ink}" - textColor: "{colors.canvas}" - typography: "{typography.button}" - rounded: "{rounded.md}" - padding: 12px 20px - height: 44px - hero-band: - backgroundColor: "{colors.canvas}" - textColor: "{colors.ink}" - typography: "{typography.display-mega}" - padding: 80px - ide-mockup-card: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.ink}" - rounded: "{rounded.lg}" - padding: 0 - ide-pane: - backgroundColor: "{colors.canvas-soft}" - textColor: "{colors.body}" - typography: "{typography.code}" - rounded: "{rounded.md}" - padding: 16px - feature-card: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.ink}" - typography: "{typography.title-md}" - rounded: "{rounded.lg}" - padding: 24px - comparison-card: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.ink}" - typography: "{typography.body-md}" - rounded: "{rounded.lg}" - padding: 24px - timeline-pill-thinking: - backgroundColor: "{colors.timeline-thinking}" - textColor: "{colors.ink}" - typography: "{typography.caption-uppercase}" - rounded: "{rounded.pill}" - padding: 4px 10px - timeline-pill-grep: - backgroundColor: "{colors.timeline-grep}" - textColor: "{colors.ink}" - typography: "{typography.caption-uppercase}" - rounded: "{rounded.pill}" - padding: 4px 10px - timeline-pill-read: - backgroundColor: "{colors.timeline-read}" - textColor: "{colors.ink}" - typography: "{typography.caption-uppercase}" - rounded: "{rounded.pill}" - padding: 4px 10px - timeline-pill-edit: - backgroundColor: "{colors.timeline-edit}" - textColor: "{colors.ink}" - typography: "{typography.caption-uppercase}" - rounded: "{rounded.pill}" - padding: 4px 10px - timeline-pill-done: - backgroundColor: "{colors.timeline-done}" - textColor: "{colors.on-primary}" - typography: "{typography.caption-uppercase}" - rounded: "{rounded.pill}" - padding: 4px 10px - code-block: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.ink}" - typography: "{typography.code}" - rounded: "{rounded.lg}" - padding: 20px - pricing-tier-card: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.ink}" - typography: "{typography.body-md}" - rounded: "{rounded.lg}" - padding: 32px - pricing-tier-featured: - backgroundColor: "{colors.ink}" - textColor: "{colors.canvas}" - typography: "{typography.body-md}" - rounded: "{rounded.lg}" - padding: 32px - text-input: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.ink}" - typography: "{typography.body-md}" - rounded: "{rounded.md}" - padding: 12px 16px - height: 44px - badge-pill: - backgroundColor: "{colors.surface-strong}" - textColor: "{colors.ink}" - typography: "{typography.caption-uppercase}" - rounded: "{rounded.pill}" - padding: 4px 10px - cta-band: - backgroundColor: "{colors.canvas}" - textColor: "{colors.ink}" - typography: "{typography.display-lg}" - padding: 96px - testimonial-card: - backgroundColor: "{colors.surface-card}" - textColor: "{colors.body}" - typography: "{typography.body-md}" - rounded: "{rounded.lg}" - padding: 24px - footer: - backgroundColor: "{colors.canvas}" - textColor: "{colors.body}" - typography: "{typography.body-sm}" - padding: 64px 48px - footer-link: - backgroundColor: transparent - textColor: "{colors.body}" - typography: "{typography.body-sm}" ---- - -## Overview - -Cursor's marketing site reads as a quietly-confident developer brand that believes in editorial calm over IDE-darkness. The base canvas is **warm cream** (`{colors.canvas}` — #f7f7f4) holding warm near-black ink (`{colors.ink}` — #26251e) for body and display alike. The single brand voltage is **Cursor Orange** (`{colors.primary}` — #f54e00) reserved for primary CTAs and the wordmark — used scarcely. - -Type runs **CursorGothic** as the single sans family. Display sits at weight 400 with negative letter-spacing — a magazine-editorial voice rather than tech-bombastic. JetBrains Mono carries every code surface (and code surfaces are roughly half the page). - -The brand's strongest visual signature is the **AI-timeline pill palette**: five pastel pills (peach `{colors.timeline-thinking}`, mint `{colors.timeline-grep}`, blue `{colors.timeline-read}`, lavender `{colors.timeline-edit}`, gold `{colors.timeline-done}`) marking AI-action stages inside in-product timeline visualizations. Used only in product UI — never as system action colors. - -**Key Characteristics:** -- Warm cream canvas, not white. Ink is warm (#26251e), not pure black. -- Single CTA color: `{colors.primary}` (Cursor Orange #f54e00). Used scarcely. -- Display weight stays at 400 — never bold. Magazine voice. -- AI timeline pastels: 5 dedicated tokens for in-product agent action stages. -- Compact 8px CTA radius — developer dialect. -- Hairline-only depth; no drop shadows. -- 80px section rhythm. - -## Colors - -### Brand & Accent -- **Cursor Orange** (`{colors.primary}` — #f54e00): Primary CTA pills, wordmark, hero accent. Used scarcely. -- **Cursor Orange Active** (`{colors.primary-active}` — #d04200): Press state. - -### Surface -- **Canvas** (`{colors.canvas}` — #f7f7f4): Warm cream page floor. -- **Canvas Soft** (`{colors.canvas-soft}` — #fafaf7): IDE-pane background inside mockups. -- **Surface Card** (`{colors.surface-card}` — #ffffff): Pure white card surface — slight contrast against the cream canvas. -- **Surface Strong** (`{colors.surface-strong}` — #e6e5e0): Badges, tag pills. - -### Hairlines -- **Hairline** (`{colors.hairline}` — #e6e5e0): 1px divider. -- **Hairline Soft** (`{colors.hairline-soft}` — #efeee8): Lighter divider. -- **Hairline Strong** (`{colors.hairline-strong}` — #cfcdc4): Stronger panel outline. - -### Text -- **Ink** (`{colors.ink}` — #26251e): Display, body emphasis. Warm near-black. -- **Body** (`{colors.body}` — #5a5852): Default running-text. -- **Body Strong** (`{colors.body-strong}` — #26251e): Same as ink. -- **Muted** (`{colors.muted}` — #807d72): Sub-titles. -- **Muted Soft** (`{colors.muted-soft}` — #a09c92): Disabled text. -- **On Primary** (`{colors.on-primary}` — #ffffff): White text on Cursor Orange. - -### Timeline (AI-action signature) -- **Thinking** (`{colors.timeline-thinking}` — #dfa88f): Peach. Used inside in-product agent timeline only. -- **Grep** (`{colors.timeline-grep}` — #9fc9a2): Mint. -- **Read** (`{colors.timeline-read}` — #9fbbe0): Pastel blue. -- **Edit** (`{colors.timeline-edit}` — #c0a8dd): Lavender. -- **Done** (`{colors.timeline-done}` — #c08532): Warm gold. - -### Semantic -- **Success** (`{colors.semantic-success}` — #1f8a65): Confirmation indicators. -- **Error** (`{colors.semantic-error}` — #cf2d56): Validation errors. - -## Typography - -### Font Family -**CursorGothic** is the licensed display + body family. Fallback: `system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif`. Code surfaces switch to **JetBrains Mono**. - -### Hierarchy - -| Token | Size | Weight | Line Height | Letter Spacing | Use | -|---|---|---|---|---|---| -| `{typography.display-mega}` | 72px | 400 | 1.1 | -2.16px | Homepage hero h1 | -| `{typography.display-lg}` | 36px | 400 | 1.2 | -0.72px | Section heads | -| `{typography.display-md}` | 26px | 400 | 1.25 | -0.325px | Sub-section heads | -| `{typography.display-sm}` | 22px | 400 | 1.3 | -0.11px | Card group titles | -| `{typography.title-md}` | 18px | 600 | 1.4 | 0 | Component titles | -| `{typography.title-sm}` | 16px | 600 | 1.4 | 0 | List labels | -| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default body | -| `{typography.body-tracked}` | 16px | 400 | 1.5 | 0.08px | Tracked editorial body | -| `{typography.body-sm}` | 14px | 400 | 1.5 | 0 | Footer body | -| `{typography.caption}` | 13px | 400 | 1.4 | 0 | Photo captions | -| `{typography.caption-uppercase}` | 11px | 600 | 1.4 | 0.88px | Section labels, timeline pill labels | -| `{typography.code}` | 13px | 400 | 1.5 | 0 | Code blocks — JetBrains Mono | -| `{typography.button}` | 14px | 500 | 1.0 | 0 | CTA pill labels | -| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu | - -### Principles -- **Display weight stays at 400.** Magazine voice, never bold. -- **Negative letter-spacing on display only.** -0.11px to -2.16px tracking. -- **JetBrains Mono on every code surface.** - -### Note on Font Substitutes -CursorGothic is licensed. Open-source substitute: **Inter** at weight 400 with letter-spacing -1.5%. Or **GT Sectra** for a more editorial feel. - -## Layout - -### Spacing System -- **Base unit:** 4px. -- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.base}` 16px · `{spacing.md}` 20px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px. -- **Section padding:** 80px. - -### Grid & Container -- Max content width: ~1200px. -- Editorial body: 12-column grid. -- Feature card grids: 2-up at desktop for splits, 3-up for benefits. -- Footer: 5-column at desktop. - -### Whitespace Philosophy -Generous editorial pacing — closer to a print magazine than a tech site. The cream canvas has plenty of breathing room; cards within bands sit close (16-24px gap). - -## Elevation & Depth - -The system uses **hairline-only depth**. No drop shadows, no elevation tiers. Cards float above the canvas via 1px hairlines and the slight white-on-cream contrast. - -| Level | Treatment | Use | -|---|---|---| -| Flat (canvas) | `{colors.canvas}` (#f7f7f4) | Body bands, footer | -| Card | `{colors.surface-card}` (#ffffff) | Content cards | -| Hairline border | 1px `{colors.hairline}` | Card outlines, dividers | -| IDE pane | `{colors.canvas-soft}` (#fafaf7) | Inside IDE mockup cards | - -### Decorative Depth -- **IDE-mockup cards** are the only "elevated" element. White card on cream canvas with internal pane structure mimicking the actual Cursor editor. -- **Timeline pastel pills** add chromatic depth without surface elevation. - -## Shapes - -### Border Radius Scale - -| Token | Value | Use | -|---|---|---| -| `{rounded.none}` | 0px | Reserved | -| `{rounded.xs}` | 4px | Inline tags | -| `{rounded.sm}` | 6px | Compact rows | -| `{rounded.md}` | 8px | CTA buttons, form inputs | -| `{rounded.lg}` | 12px | Cards, IDE panes | -| `{rounded.xl}` | 16px | Larger feature cards (rare) | -| `{rounded.pill}` | 9999px | Timeline pills, badges | -| `{rounded.full}` | 9999px | Avatars (rare) | - -## Components - -### Top Navigation - -**`top-nav`** — Background `{colors.canvas}`, text `{colors.ink}`, height 64px. Layout: Cursor wordmark left, primary horizontal menu (Pricing / Features / Enterprise / Blog / Forum / Careers), Sign In + Download primary CTA right. - -### Buttons - -**`button-primary`** — The signature Cursor Orange CTA. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button}` (14px / 500), padding 10px × 18px, height 40px, rounded `{rounded.md}` (8px). - -**`button-primary-active`** — Press state. Background `{colors.primary-active}`. - -**`button-secondary`** — White card pill on cream canvas. Background `{colors.surface-card}`, text `{colors.ink}`, 1px `{colors.hairline-strong}` border. - -**`button-tertiary-text`** — Inline ink text link. - -**`button-download`** — Larger ink-canvas CTA. Background `{colors.ink}`, text `{colors.canvas}`, padding 12px × 20px, height 44px. Used for "Download for macOS" type CTAs. - -### Hero & IDE Mockups - -**`hero-band`** — Background `{colors.canvas}`, full-width display headline in `{typography.display-mega}` (72px / 400 / -2.16px), subhead in `{typography.body-md}`, two CTAs (`button-download` + `button-tertiary-text`), and a centered IDE-mockup card below the hero copy. - -**`ide-mockup-card`** — A white card containing a multi-pane IDE mockup (sidebar + main editor + chat panel + terminal). Background `{colors.surface-card}`, rounded `{rounded.lg}` (12px), 1px `{colors.hairline}` border, no padding (panes fill the card edge-to-edge). - -**`ide-pane`** — Individual IDE pane inside the mockup. Background `{colors.canvas-soft}`, text `{colors.body}` in `{typography.code}` (JetBrains Mono 13px), rounded `{rounded.md}` (8px), padding 16px. - -### Cards - -**`feature-card`** — Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.title-md}`, rounded `{rounded.lg}`, padding 24px. 1px `{colors.hairline}` border. - -**`comparison-card`** — Side-by-side "Cursor vs other tools" card. Same surface and rounding; internally split into 2 columns. - -**`testimonial-card`** — Quote card. Background `{colors.surface-card}`, text `{colors.body}`, rounded `{rounded.lg}`, padding 24px. - -### AI Timeline (signature) - -**`timeline-pill-thinking`** — Peach pill. Background `{colors.timeline-thinking}`, text `{colors.ink}`, type `{typography.caption-uppercase}` (11px / 600 / 0.88px tracking, uppercase), rounded `{rounded.pill}`, padding 4px × 10px. Marks "Thinking" stage in product timeline. - -**`timeline-pill-grep`** — Mint pill. Same shape, background `{colors.timeline-grep}`. Marks "Grepping" stage. - -**`timeline-pill-read`** — Pastel-blue pill. Background `{colors.timeline-read}`. Marks "Reading" stage. - -**`timeline-pill-edit`** — Lavender pill. Background `{colors.timeline-edit}`. Marks "Editing" stage. - -**`timeline-pill-done`** — Gold pill. Background `{colors.timeline-done}`, text `{colors.on-primary}` white. Marks "Done" stage. - -### Code - -**`code-block`** — Inline code block. Background `{colors.surface-card}`, text `{colors.ink}` in `{typography.code}`, rounded `{rounded.lg}`, padding 20px, 1px `{colors.hairline}` border. - -### Pricing - -**`pricing-tier-card`** — Background `{colors.surface-card}`, rounded `{rounded.lg}`, padding 32px, 1px `{colors.hairline}` border. - -**`pricing-tier-featured`** — Featured tier inverts to ink. Background `{colors.ink}`, text `{colors.canvas}`. Same shape, dark inversion signals "highlighted" without colored ribbon. - -### Forms & Tags - -**`text-input`** — Background `{colors.surface-card}`, text `{colors.ink}`, rounded `{rounded.md}` (8px), padding 12px × 16px, height 44px. - -**`badge-pill`** — Small uppercase pill. Background `{colors.surface-strong}`, text `{colors.ink}`, type `{typography.caption-uppercase}`, rounded `{rounded.pill}`, padding 4px × 10px. - -### CTA / Footer - -**`cta-band`** — Pre-footer "Try Cursor now" band. Background `{colors.canvas}`, centered display headline in `{typography.display-lg}`, single Cursor Orange CTA. 96px vertical padding. - -**`footer`** — Closing footer. Background `{colors.canvas}`, text `{colors.body}`. 5-column link list. 64×48px padding. - -**`footer-link`** — Background transparent, text `{colors.body}`, type `{typography.body-sm}`. - -## Do's and Don'ts - -### Do -- Reserve `{colors.primary}` (Cursor Orange) for primary CTAs and brand wordmark. -- Keep display weight at 400. The editorial voice depends on this. -- Use the cream `{colors.canvas}` page floor — never pure white. -- Render every code surface (inline, blocks, IDE panes) in JetBrains Mono. -- Use timeline pastels only inside in-product agent visualizations — never as system action colors. - -### Don't -- Don't introduce a secondary brand action color. Cursor Orange is the only one. -- Don't drop display to bold weights (700+). Magazine voice depends on 400. -- Don't add drop shadows. Hairlines + ink-on-cream contrast carry the depth. -- Don't use timeline pastels on non-timeline UI. They're scoped to the agent timeline only. -- Don't extract a CTA color from a third-party widget (cookie consent, OneTrust). The brand's CTA is what appears on actual product CTAs. - -## Responsive Behavior - -### Breakpoints - -| Name | Width | Key Changes | -|---|---|---| -| Mobile | < 640px | Hero h1 72→32px; IDE mockup collapses to single pane preview; feature grid 1-up; nav hamburger. | -| Tablet | 640–1024px | Hero h1 56px; IDE mockup compresses; feature grid 2-up. | -| Desktop | 1024–1280px | Full hero h1 72px; full multi-pane IDE mockup; feature grid 3-up. | -| Wide | > 1280px | Content caps at 1200px. | - -### Touch Targets -- Primary CTA at 40px height — at WCAG AA, padded for AAA. -- Download CTA at 44px — at AAA. - -### Collapsing Strategy -- Top nav switches to hamburger below 768px. -- IDE mockup multi-pane collapses to a single primary pane preview on mobile. -- Feature grid: 3-up → 2-up → 1-up. - -## Iteration Guide - -1. Focus on a single component at a time. -2. CTAs default to `{rounded.md}` (8px). Cards use `{rounded.lg}` (12px). -3. Variants live as separate entries inside `components:`. -4. Use `{token.refs}` everywhere — never inline hex. -5. Hover state never documented. -6. CursorGothic 400 for display, 400/500/600 for body. JetBrains Mono on every code surface. -7. Cursor Orange stays scarce. -8. Timeline pastels stay scoped to in-product agent visualizations. - -## Known Gaps - -- CursorGothic is a licensed typeface; Inter is the substitute. -- Animation timings (timeline pill entrance, IDE pane reveal) out of scope. -- In-app surfaces (code editor, chat panel, agent timeline) only partially captured via marketing IDE mockups. -- Form validation states beyond focus not visible on captured surfaces. diff --git a/ELY Browser Design/ELY Browser.html b/ELY Browser Design/ELY Browser.html new file mode 100644 index 0000000..50141fd --- /dev/null +++ b/ELY Browser Design/ELY Browser.html @@ -0,0 +1,68 @@ + + + + +ELY Browser — Design System + + + + + + + + +
+ + + + + + + + + + + + + + diff --git a/ELY Browser Design/design-canvas.jsx b/ELY Browser Design/design-canvas.jsx new file mode 100644 index 0000000..5e685a6 --- /dev/null +++ b/ELY Browser Design/design-canvas.jsx @@ -0,0 +1,936 @@ + +// DesignCanvas.jsx — Figma-ish design canvas wrapper +// Warm gray grid bg + Sections + Artboards + PostIt notes. +// Artboards are reorderable (grip-drag), deletable, labels/titles are +// inline-editable, and any artboard can be opened in a fullscreen focus +// overlay (←/→/Esc). State persists to a .design-canvas.state.json sidecar +// via the host bridge. No assets, no deps. +// +// Usage: +// +// +// +// +// +// + +const DC = { + bg: '#f0eee9', + grid: 'rgba(0,0,0,0.06)', + label: 'rgba(60,50,40,0.7)', + title: 'rgba(40,30,20,0.85)', + subtitle: 'rgba(60,50,40,0.6)', + postitBg: '#fef4a8', + postitText: '#5a4a2a', + font: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif', +}; + +// One-time CSS injection (classes are dc-prefixed so they don't collide with +// the hosted design's own styles). +if (typeof document !== 'undefined' && !document.getElementById('dc-styles')) { + const s = document.createElement('style'); + s.id = 'dc-styles'; + s.textContent = [ + '.dc-editable{cursor:text;outline:none;white-space:nowrap;border-radius:3px;padding:0 2px;margin:0 -2px}', + '.dc-editable:focus{background:#fff;box-shadow:0 0 0 1.5px #c96442}', + '[data-dc-slot]{transition:transform .18s cubic-bezier(.2,.7,.3,1)}', + '[data-dc-slot].dc-dragging{transition:none;z-index:10;pointer-events:none}', + '[data-dc-slot].dc-dragging .dc-card{box-shadow:0 12px 40px rgba(0,0,0,.25),0 0 0 2px #c96442;transform:scale(1.02)}', + // isolation:isolate contains artboard content's z-indexes so a + // z-indexed child (sticky navbar etc.) can't paint over .dc-header or + // the .dc-menu popover that drops into the top of the card. + '.dc-card{isolation:isolate;transition:box-shadow .15s,transform .15s}', + '.dc-card *{scrollbar-width:none}', + '.dc-card *::-webkit-scrollbar{display:none}', + // Per-artboard header: grip + label on the left, delete/expand on the + // right. Single flex row; when the artboard's on-screen width is too + // narrow for both the label yields (ellipsis, then hidden entirely below + // ~4ch via the container query) and the buttons stay on the row. + '.dc-header{position:absolute;bottom:100%;left:-4px;margin-bottom:calc(4px * var(--dc-inv-zoom,1));z-index:2;', + ' display:flex;align-items:center;container-type:inline-size}', + '.dc-labelrow{display:flex;align-items:center;gap:4px;height:24px;flex:1 1 auto;min-width:0}', + '.dc-grip{flex:0 0 auto;cursor:grab;display:flex;align-items:center;padding:5px 4px;border-radius:4px;transition:background .12s,opacity .12s}', + '.dc-grip:hover{background:rgba(0,0,0,.08)}', + '.dc-grip:active{cursor:grabbing}', + '.dc-labeltext{flex:1 1 auto;min-width:0;cursor:pointer;border-radius:4px;padding:3px 6px;', + ' display:flex;align-items:center;transition:background .12s;overflow:hidden}', + // Below ~4ch of label room: hide the label entirely, and drop the grip to + // hover-only (same reveal rule as .dc-btns) so a narrow header is clean + // until the card is moused. + '@container (max-width: 110px){', + ' .dc-labeltext{display:none}', + ' .dc-grip{opacity:0}', + ' [data-dc-slot]:hover .dc-grip{opacity:1}', + '}', + '.dc-labeltext:hover{background:rgba(0,0,0,.05)}', + '.dc-labeltext .dc-editable{overflow:hidden;text-overflow:ellipsis;max-width:100%}', + '.dc-labeltext .dc-editable:focus{overflow:visible;text-overflow:clip}', + '.dc-btns{flex:0 0 auto;margin-left:auto;display:flex;gap:2px;opacity:0;transition:opacity .12s}', + '[data-dc-slot]:hover .dc-btns,.dc-btns:has(.dc-menu){opacity:1}', + '.dc-expand,.dc-kebab{width:22px;height:22px;border-radius:5px;border:none;cursor:pointer;padding:0;', + ' background:transparent;color:rgba(60,50,40,.7);display:flex;align-items:center;justify-content:center;', + ' font:inherit;transition:background .12s,color .12s}', + '.dc-expand:hover,.dc-kebab:hover{background:rgba(0,0,0,.06);color:#2a251f}', + // Slot hosting an open menu floats above later siblings (which otherwise + // paint on top — same z-index:auto, later DOM order) so the popup isn't + // clipped by the next card. + '[data-dc-slot]:has(.dc-menu){z-index:10}', + '.dc-menu{position:absolute;top:100%;right:0;margin-top:4px;background:#fff;border-radius:8px;', + ' box-shadow:0 8px 28px rgba(0,0,0,.18),0 0 0 1px rgba(0,0,0,.05);padding:4px;min-width:160px;z-index:10}', + '.dc-menu button{display:block;width:100%;padding:7px 10px;border:0;background:transparent;', + ' border-radius:5px;font-family:inherit;font-size:13px;font-weight:500;line-height:1.2;', + ' color:#29261b;cursor:pointer;text-align:left;transition:background .12s;white-space:nowrap}', + '.dc-menu button:hover{background:rgba(0,0,0,.05)}', + '.dc-menu hr{border:0;border-top:1px solid rgba(0,0,0,.08);margin:4px 2px}', + '.dc-menu .dc-danger{color:#c96442}', + '.dc-menu .dc-danger:hover{background:rgba(201,100,66,.1)}', + // Chrome (titles / labels / buttons) counter-scales against the viewport + // zoom so it stays a constant on-screen size. --dc-inv-zoom is set by + // DCViewport on every transform update and inherits to all descendants — + // any overlay inside the world (e.g. a TweaksPanel on an artboard) can use + // it the same way. + // + // The header uses transform:scale (out-of-flow, so layout impact doesn't + // matter) with its world-space width set to card-width / inv-zoom so that + // after counter-scaling its on-screen width exactly matches the card's — + // that's what lets the container query + text-overflow behave against the + // card's visible edge at every zoom level. + // + // The section head uses CSS zoom instead of transform so its layout box + // grows with the counter-scale, pushing the card row down — otherwise the + // constant-screen-size title would overflow into the (shrinking) world- + // space gap and overlap the artboard headers at low zoom. + '.dc-header{width:calc((100% + 4px) / var(--dc-inv-zoom,1));', + ' transform:scale(var(--dc-inv-zoom,1));transform-origin:bottom left}', + '.dc-sectionhead{zoom:var(--dc-inv-zoom,1)}', + ].join('\n'); + document.head.appendChild(s); +} + +const DCCtx = React.createContext(null); + +// ───────────────────────────────────────────────────────────── +// DesignCanvas — stateful wrapper around the pan/zoom viewport. +// Owns runtime state (per-section order, renamed titles/labels, hidden +// artboards, focused artboard). Order/titles/labels/hidden persist to a +// .design-canvas.state.json +// sidecar next to the HTML. Reads go via plain fetch() so the saved +// arrangement is visible anywhere the HTML + sidecar are served together +// (omelette preview, direct link, downloaded zip). Writes go through the +// host's window.omelette bridge — editing requires the omelette runtime. +// Focus is ephemeral. +// ───────────────────────────────────────────────────────────── +const DC_STATE_FILE = '.design-canvas.state.json'; + +function DesignCanvas({ children, minScale, maxScale, style }) { + const [state, setState] = React.useState({ sections: {}, focus: null }); + // Hold rendering until the sidecar read settles so the saved order/titles + // appear on first paint (no source-order flash). didRead gates writes until + // the read settles so the empty initial state can't clobber a slow read; + // skipNextWrite suppresses the one echo-write that would otherwise follow + // hydration. + const [ready, setReady] = React.useState(false); + const didRead = React.useRef(false); + const skipNextWrite = React.useRef(false); + + React.useEffect(() => { + let off = false; + fetch('./' + DC_STATE_FILE) + .then((r) => (r.ok ? r.json() : null)) + .then((saved) => { + if (off || !saved || !saved.sections) return; + skipNextWrite.current = true; + setState((s) => ({ ...s, sections: saved.sections })); + }) + .catch(() => {}) + .finally(() => { didRead.current = true; if (!off) setReady(true); }); + const t = setTimeout(() => { if (!off) setReady(true); }, 150); + return () => { off = true; clearTimeout(t); }; + }, []); + + React.useEffect(() => { + if (!didRead.current) return; + if (skipNextWrite.current) { skipNextWrite.current = false; return; } + const t = setTimeout(() => { + window.omelette?.writeFile(DC_STATE_FILE, JSON.stringify({ sections: state.sections })).catch(() => {}); + }, 250); + return () => clearTimeout(t); + }, [state.sections]); + + // Build registries synchronously from children so FocusOverlay can read + // them in the same render. Only direct DCSection > DCArtboard children are + // walked — wrapping them in other elements opts out of focus/reorder. + const registry = {}; // slotId -> { sectionId, artboard } + const sectionMeta = {}; // sectionId -> { title, subtitle, slotIds[] } + const sectionOrder = []; + React.Children.forEach(children, (sec) => { + if (!sec || sec.type !== DCSection) return; + const sid = sec.props.id ?? sec.props.title; + if (!sid) return; + sectionOrder.push(sid); + const persisted = state.sections[sid] || {}; + const abs = []; + React.Children.forEach(sec.props.children, (ab) => { + if (!ab || ab.type !== DCArtboard) return; + const aid = ab.props.id ?? ab.props.label; + if (aid) abs.push([aid, ab]); + }); + // hidden is scoped to one source revision — when the agent regenerates + // (artboard-ID set changes), prior deletes don't apply to new content. + const srcKey = abs.map(([k]) => k).join('\x1f'); + const hidden = persisted.srcKey === srcKey ? (persisted.hidden || []) : []; + const srcIds = []; + abs.forEach(([aid, ab]) => { + if (hidden.includes(aid)) return; + registry[`${sid}/${aid}`] = { sectionId: sid, artboard: ab }; + srcIds.push(aid); + }); + const kept = (persisted.order || []).filter((k) => srcIds.includes(k)); + sectionMeta[sid] = { + title: persisted.title ?? sec.props.title, + subtitle: sec.props.subtitle, + slotIds: [...kept, ...srcIds.filter((k) => !kept.includes(k))], + }; + }); + + const api = React.useMemo(() => ({ + state, + section: (id) => state.sections[id] || {}, + patchSection: (id, p) => setState((s) => ({ + ...s, + sections: { ...s.sections, [id]: { ...s.sections[id], ...(typeof p === 'function' ? p(s.sections[id] || {}) : p) } }, + })), + setFocus: (slotId) => setState((s) => ({ ...s, focus: slotId })), + }), [state]); + + // Esc exits focus; any outside pointerdown commits an in-progress rename. + React.useEffect(() => { + const onKey = (e) => { if (e.key === 'Escape') api.setFocus(null); }; + const onPd = (e) => { + const ae = document.activeElement; + if (ae && ae.isContentEditable && !ae.contains(e.target)) ae.blur(); + }; + document.addEventListener('keydown', onKey); + document.addEventListener('pointerdown', onPd, true); + return () => { + document.removeEventListener('keydown', onKey); + document.removeEventListener('pointerdown', onPd, true); + }; + }, [api]); + + return ( + + {ready && children} + {state.focus && registry[state.focus] && ( + + )} + + ); +} + +// ───────────────────────────────────────────────────────────── +// DCViewport — transform-based pan/zoom (internal) +// +// Input mapping (Figma-style): +// • trackpad pinch → zoom (ctrlKey wheel; Safari gesture* events) +// • trackpad scroll → pan (two-finger) +// • mouse wheel → zoom (notched; distinguished from trackpad scroll) +// • middle-drag / primary-drag-on-bg → pan +// +// Transform state lives in a ref and is written straight to the DOM +// (translate3d + will-change) so wheel ticks don't go through React — +// keeps pans at 60fps on dense canvases. +// ───────────────────────────────────────────────────────────── +function DCViewport({ children, minScale = 0.1, maxScale = 8, style = {} }) { + const vpRef = React.useRef(null); + const worldRef = React.useRef(null); + const tf = React.useRef({ x: 0, y: 0, scale: 1 }); + // Persist viewport across reloads so the user lands back where they were + // after an agent edit or browser refresh. The sandbox origin is already + // per-project; pathname keeps multiple canvas files in one project apart. + const tfKey = 'dc-viewport:' + location.pathname; + const saveT = React.useRef(0); + + const lastPostedScale = React.useRef(); + const apply = React.useCallback(() => { + const { x, y, scale } = tf.current; + const el = worldRef.current; + if (!el) return; + el.style.transform = `translate3d(${x}px, ${y}px, 0) scale(${scale})`; + // Exposed for zoom-invariant chrome (labels, buttons, TweaksPanel). + el.style.setProperty('--dc-inv-zoom', String(1 / scale)); + // Keep the host toolbar's % readout in sync with the canvas scale. Pan + // ticks leave scale unchanged — skip the cross-frame post for those. + if (lastPostedScale.current !== scale) { + lastPostedScale.current = scale; + window.parent.postMessage({ type: '__dc_zoom', scale }, '*'); + } + clearTimeout(saveT.current); + saveT.current = setTimeout(() => { + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }, 200); + }, [tfKey]); + + React.useLayoutEffect(() => { + const flush = () => { + clearTimeout(saveT.current); + try { localStorage.setItem(tfKey, JSON.stringify(tf.current)); } catch {} + }; + try { + const s = JSON.parse(localStorage.getItem(tfKey) || 'null'); + if (s && Number.isFinite(s.x) && Number.isFinite(s.y) && Number.isFinite(s.scale)) { + tf.current = { x: s.x, y: s.y, scale: Math.min(maxScale, Math.max(minScale, s.scale)) }; + apply(); + } + } catch {} + // Flush on pagehide and unmount so a reload within the 200ms debounce + // window doesn't drop the last pan/zoom. + window.addEventListener('pagehide', flush); + return () => { window.removeEventListener('pagehide', flush); flush(); }; + }, []); + + React.useEffect(() => { + const vp = vpRef.current; + if (!vp) return; + + const zoomAt = (cx, cy, factor) => { + const r = vp.getBoundingClientRect(); + const px = cx - r.left, py = cy - r.top; + const t = tf.current; + const next = Math.min(maxScale, Math.max(minScale, t.scale * factor)); + const k = next / t.scale; + // keep the world point under the cursor fixed + t.x = px - (px - t.x) * k; + t.y = py - (py - t.y) * k; + t.scale = next; + apply(); + }; + + // Mouse-wheel vs trackpad-scroll heuristic. A physical wheel sends + // line-mode deltas (Firefox) or large integer pixel deltas with no X + // component (Chrome/Safari, typically multiples of 100/120). Trackpad + // two-finger scroll sends small/fractional pixel deltas, often with + // non-zero deltaX. ctrlKey is set by the browser for trackpad pinch. + const isMouseWheel = (e) => + e.deltaMode !== 0 || + (e.deltaX === 0 && Number.isInteger(e.deltaY) && Math.abs(e.deltaY) >= 40); + + const onWheel = (e) => { + e.preventDefault(); + if (isGesturing) return; // Safari: gesture* owns the pinch — discard concurrent wheels + if ((e.ctrlKey || e.metaKey) && !isMouseWheel(e)) { + // trackpad pinch, or ctrl/cmd + smooth-scroll mouse. Notched + // wheels fall through to the fixed-step branch below. + zoomAt(e.clientX, e.clientY, Math.exp(-e.deltaY * 0.01)); + } else if (isMouseWheel(e)) { + // notched mouse wheel — fixed-ratio step per click + zoomAt(e.clientX, e.clientY, Math.exp(-Math.sign(e.deltaY) * 0.18)); + } else { + // trackpad two-finger scroll — pan + tf.current.x -= e.deltaX; + tf.current.y -= e.deltaY; + apply(); + } + }; + + // Safari sends native gesture* events for trackpad pinch with a smooth + // e.scale; preferring these over the ctrl+wheel fallback gives a much + // better feel there. No-ops on other browsers. Safari also fires + // ctrlKey wheel events during the same pinch — isGesturing makes + // onWheel drop those entirely so they neither zoom nor pan. + let gsBase = 1; + let isGesturing = false; + const onGestureStart = (e) => { e.preventDefault(); isGesturing = true; gsBase = tf.current.scale; }; + const onGestureChange = (e) => { + e.preventDefault(); + zoomAt(e.clientX, e.clientY, (gsBase * e.scale) / tf.current.scale); + }; + const onGestureEnd = (e) => { e.preventDefault(); isGesturing = false; }; + + // Drag-pan: middle button anywhere, or primary button on canvas + // background (anything that isn't an artboard or an inline editor). + let drag = null; + const onPointerDown = (e) => { + const onBg = !e.target.closest('[data-dc-slot], .dc-editable'); + if (!(e.button === 1 || (e.button === 0 && onBg))) return; + e.preventDefault(); + vp.setPointerCapture(e.pointerId); + drag = { id: e.pointerId, lx: e.clientX, ly: e.clientY }; + vp.style.cursor = 'grabbing'; + }; + const onPointerMove = (e) => { + if (!drag || e.pointerId !== drag.id) return; + tf.current.x += e.clientX - drag.lx; + tf.current.y += e.clientY - drag.ly; + drag.lx = e.clientX; drag.ly = e.clientY; + apply(); + }; + const onPointerUp = (e) => { + if (!drag || e.pointerId !== drag.id) return; + vp.releasePointerCapture(e.pointerId); + drag = null; + vp.style.cursor = ''; + }; + + // Host-driven zoom (toolbar % menu). Zooms around viewport centre so the + // visible midpoint stays fixed — matching the host's iframe-zoom feel. + const onHostMsg = (e) => { + const d = e.data; + if (d && d.type === '__dc_set_zoom' && typeof d.scale === 'number') { + const r = vp.getBoundingClientRect(); + zoomAt(r.left + r.width / 2, r.top + r.height / 2, d.scale / tf.current.scale); + } else if (d && d.type === '__dc_probe') { + // Host's [readyGen] reset asks whether a canvas is present; it + // fires on the iframe's native 'load', which for canvases with + // images/fonts is after our mount-time announce, so re-announce. + // Clear the pan-tick guard so apply() re-posts the current scale + // even if it's unchanged — the host just reset dcScale to 1. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + } + }; + window.addEventListener('message', onHostMsg); + // Announce canvas mode so the host toolbar proxies its % control here + // instead of scaling the iframe element (which would just shrink the + // viewport window of an infinite canvas). The apply() that follows emits + // the initial __dc_zoom so the toolbar % is correct before first pinch. + // lastPostedScale reset mirrors the __dc_probe handler: the layout + // effect's restore-path apply() may already have posted the restored + // scale (before __dc_present), so clear the guard to re-post it in order. + window.parent.postMessage({ type: '__dc_present' }, '*'); + lastPostedScale.current = undefined; + apply(); + + vp.addEventListener('wheel', onWheel, { passive: false }); + vp.addEventListener('gesturestart', onGestureStart, { passive: false }); + vp.addEventListener('gesturechange', onGestureChange, { passive: false }); + vp.addEventListener('gestureend', onGestureEnd, { passive: false }); + vp.addEventListener('pointerdown', onPointerDown); + vp.addEventListener('pointermove', onPointerMove); + vp.addEventListener('pointerup', onPointerUp); + vp.addEventListener('pointercancel', onPointerUp); + return () => { + window.removeEventListener('message', onHostMsg); + vp.removeEventListener('wheel', onWheel); + vp.removeEventListener('gesturestart', onGestureStart); + vp.removeEventListener('gesturechange', onGestureChange); + vp.removeEventListener('gestureend', onGestureEnd); + vp.removeEventListener('pointerdown', onPointerDown); + vp.removeEventListener('pointermove', onPointerMove); + vp.removeEventListener('pointerup', onPointerUp); + vp.removeEventListener('pointercancel', onPointerUp); + }; + }, [apply, minScale, maxScale]); + + const gridSvg = `url("data:image/svg+xml,%3Csvg width='120' height='120' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M120 0H0v120' fill='none' stroke='${encodeURIComponent(DC.grid)}' stroke-width='1'/%3E%3C/svg%3E")`; + return ( +
+
+
+ {children} +
+
+ ); +} + +// ───────────────────────────────────────────────────────────── +// DCSection — editable title + h-row of artboards in persisted order +// ───────────────────────────────────────────────────────────── +function DCSection({ id, title, subtitle, children, gap = 48 }) { + const ctx = React.useContext(DCCtx); + const sid = id ?? title; + const all = React.Children.toArray(children); + const artboards = all.filter((c) => c && c.type === DCArtboard); + const rest = all.filter((c) => !(c && c.type === DCArtboard)); + const sec = (ctx && sid && ctx.section(sid)) || {}; + // Must match DesignCanvas's srcKey computation exactly (it filters falsy + // IDs), or onDelete persists a srcKey that DesignCanvas never recognizes. + const allIds = artboards.map((a) => a.props.id ?? a.props.label).filter(Boolean); + const srcKey = allIds.join('\x1f'); + const hidden = sec.srcKey === srcKey ? (sec.hidden || []) : []; + const srcOrder = allIds.filter((k) => !hidden.includes(k)); + + const order = React.useMemo(() => { + const kept = (sec.order || []).filter((k) => srcOrder.includes(k)); + return [...kept, ...srcOrder.filter((k) => !kept.includes(k))]; + }, [sec.order, srcOrder.join('|')]); + + const byId = Object.fromEntries(artboards.map((a) => [a.props.id ?? a.props.label, a])); + + // marginBottom counter-scales so the on-screen gap between sections stays + // constant — otherwise at low zoom the (world-space) gap collapses while + // the screen-constant sectionhead below it doesn't, and the title reads as + // belonging to the section above. paddingBottom below is just enough for + // the 24px artboard-header (abs-positioned above each card) plus ~8px, so + // the title sits tight against its own row at every zoom. + return ( +
+
+
+ ctx && sid && ctx.patchSection(sid, { title: v })} + style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} /> + {subtitle &&
{subtitle}
} +
+
+
+ {order.map((k) => ( + ctx && ctx.patchSection(sid, (x) => ({ labels: { ...x.labels, [k]: v } }))} + onReorder={(next) => ctx && ctx.patchSection(sid, { order: next })} + onDelete={() => ctx && ctx.patchSection(sid, (x) => ({ + hidden: [...(x.srcKey === srcKey ? (x.hidden || []) : []), k], + srcKey, + }))} + onFocus={() => ctx && ctx.setFocus(`${sid}/${k}`)} /> + ))} +
+ {rest} +
+ ); +} + +// DCArtboard — marker; rendered by DCArtboardFrame via DCSection. +function DCArtboard() { return null; } + +// Per-artboard export (kind: 'png' | 'html'). Both paths share the same +// self-contained clone: computed styles baked in, @font-face / / +// inline-style background-image urls inlined as data URIs. PNG wraps the +// clone in foreignObject→canvas at 3× the artboard's natural width×height +// (same pipeline the host uses for page captures); HTML wraps it in a +// minimal standalone document. Both are independent of viewport zoom. +async function dcExport(node, w, h, name, kind) { + try { await document.fonts.ready; } catch {} + const toDataURL = (url) => fetch(url).then((r) => r.blob()).then((b) => new Promise((res) => { + const fr = new FileReader(); fr.onload = () => res(fr.result); fr.onerror = () => res(url); fr.readAsDataURL(b); + })).catch(() => url); + + // Collect @font-face rules. ss.cssRules throws SecurityError on + // cross-origin sheets (e.g. fonts.googleapis.com) — in that case fetch + // the CSS text directly (those endpoints send ACAO:*) and regex-extract + // the blocks. @import and @media/@supports are walked so nested + // @font-face rules aren't missed. + const fontRules = [], pending = [], seen = new Set(); + const scrapeCss = (href) => { + if (seen.has(href)) return; seen.add(href); + pending.push(fetch(href).then((r) => r.text()).then((css) => { + for (const m of css.match(/@font-face\s*{[^}]*}/g) || []) fontRules.push({ css: m, base: href }); + for (const m of css.matchAll(/@import\s+(?:url\()?['"]?([^'")\s;]+)/g)) + scrapeCss(new URL(m[1], href).href); + }).catch(() => {})); + }; + const walk = (rules, base) => { + for (const r of rules) { + if (r.type === CSSRule.FONT_FACE_RULE) fontRules.push({ css: r.cssText, base }); + else if (r.type === CSSRule.IMPORT_RULE && r.styleSheet) { + const ibase = r.styleSheet.href || base; + try { walk(r.styleSheet.cssRules, ibase); } catch { scrapeCss(ibase); } + } else if (r.cssRules) walk(r.cssRules, base); + } + }; + for (const ss of document.styleSheets) { + const base = ss.href || location.href; + try { walk(ss.cssRules, base); } catch { if (ss.href) scrapeCss(ss.href); } + } + while (pending.length) await pending.shift(); + const fontCss = (await Promise.all(fontRules.map(async (rule) => { + let out = rule.css, m; const re = /url\((['"]?)([^'")]+)\1\)/g; + while ((m = re.exec(rule.css))) { + if (m[2].indexOf('data:') === 0) continue; + let abs; try { abs = new URL(m[2], rule.base).href; } catch { continue; } + out = out.split(m[0]).join('url("' + await toDataURL(abs) + '")'); + } + return out; + }))).join('\n'); + + const cloneStyled = (src) => { + if (src.nodeType === 8 || (src.nodeType === 1 && src.tagName === 'SCRIPT')) return document.createTextNode(''); + const dst = src.cloneNode(false); + if (src.nodeType === 1) { + const cs = getComputedStyle(src); let txt = ''; + for (let i = 0; i < cs.length; i++) txt += cs[i] + ':' + cs.getPropertyValue(cs[i]) + ';'; + dst.setAttribute('style', txt + 'animation:none;transition:none;'); + if (src.tagName === 'CANVAS') try { const im = document.createElement('img'); im.src = src.toDataURL(); im.setAttribute('style', txt); return im; } catch {} + } + for (let c = src.firstChild; c; c = c.nextSibling) dst.appendChild(cloneStyled(c)); + return dst; + }; + const clone = cloneStyled(node); + clone.setAttribute('xmlns', 'http://www.w3.org/1999/xhtml'); + // Drop the card's own shadow/radius so the export is a flush w×h rect; + // the artboard's own background (if any) is already in the computed style. + clone.style.boxShadow = 'none'; clone.style.borderRadius = '0'; + + const jobs = []; + clone.querySelectorAll('img').forEach((el) => { + const s = el.getAttribute('src'); + if (s && s.indexOf('data:') !== 0) jobs.push(toDataURL(el.src).then((d) => el.setAttribute('src', d))); + }); + [clone, ...clone.querySelectorAll('*')].forEach((el) => { + const bg = el.style.backgroundImage; if (!bg) return; + let m; const re = /url\(["']?([^"')]+)["']?\)/g; + while ((m = re.exec(bg))) { + const tok = m[0], url = m[1]; + if (url.indexOf('data:') === 0) continue; + jobs.push(toDataURL(url).then((d) => { el.style.backgroundImage = el.style.backgroundImage.split(tok).join('url("' + d + '")'); })); + } + }); + await Promise.all(jobs); + + const xml = new XMLSerializer().serializeToString(clone); + const save = (blob, ext) => { + if (!blob) return; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); a.download = name + '.' + ext; a.click(); + setTimeout(() => URL.revokeObjectURL(a.href), 1000); + }; + + if (kind === 'html') { + const html = '' + name + '' + + (fontCss ? '' : '') + + '' + xml + ''; + return save(new Blob([html], { type: 'text/html' }), 'html'); + } + + // PNG: the SVG's own width/height must be the output resolution — an + // -loaded SVG rasterizes at its intrinsic size, so sizing it at 1× + // and ctx.scale()-ing up would just upscale a 1× bitmap. viewBox maps the + // w×h foreignObject onto the px·w × px·h SVG canvas so the browser renders + // the HTML at full resolution. + const px = 3; + const svg = '' + + (fontCss ? '' : '') + xml + ''; + const img = new Image(); + await new Promise((res, rej) => { + img.onload = res; img.onerror = () => rej(new Error('svg load failed')); + img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); + }); + const cv = document.createElement('canvas'); + cv.width = w * px; cv.height = h * px; + cv.getContext('2d').drawImage(img, 0, 0); + cv.toBlob((blob) => save(blob, 'png'), 'image/png'); +} + +function DCArtboardFrame({ sectionId, artboard, label, order, onRename, onReorder, onFocus, onDelete }) { + const { id: rawId, label: rawLabel, width = 260, height = 480, children, style = {} } = artboard.props; + const id = rawId ?? rawLabel; + const ref = React.useRef(null); + const cardRef = React.useRef(null); + const menuRef = React.useRef(null); + const [menuOpen, setMenuOpen] = React.useState(false); + const [confirming, setConfirming] = React.useState(false); + + // ⋯ menu: close on any outside pointerdown. Two-click delete lives inside + // the menu — first click arms the row, second commits; closing disarms. + React.useEffect(() => { + if (!menuOpen) { setConfirming(false); return; } + const off = (e) => { if (!menuRef.current || !menuRef.current.contains(e.target)) setMenuOpen(false); }; + document.addEventListener('pointerdown', off, true); + return () => document.removeEventListener('pointerdown', off, true); + }, [menuOpen]); + + const doExport = (kind) => { + setMenuOpen(false); + if (!cardRef.current) return; + const name = String(label || id || 'artboard').replace(/[^\w\s.-]+/g, '_'); + dcExport(cardRef.current, width, height, name, kind) + .catch((e) => console.error('[design-canvas] export failed:', e)); + }; + + // Live drag-reorder: dragged card sticks to cursor; siblings slide into + // their would-be slots in real time via transforms. DOM order only + // changes on drop. + const onGripDown = (e) => { + e.preventDefault(); e.stopPropagation(); + const me = ref.current; + // translateX is applied in local (pre-scale) space but pointer deltas and + // getBoundingClientRect().left are screen-space — divide by the viewport's + // current scale so the dragged card tracks the cursor at any zoom level. + const scale = me.getBoundingClientRect().width / me.offsetWidth || 1; + const peers = Array.from(document.querySelectorAll(`[data-dc-section="${sectionId}"] [data-dc-slot]`)); + const homes = peers.map((el) => ({ el, id: el.dataset.dcSlot, x: el.getBoundingClientRect().left })); + const slotXs = homes.map((h) => h.x); + const startIdx = order.indexOf(id); + const startX = e.clientX; + let liveOrder = order.slice(); + me.classList.add('dc-dragging'); + + const layout = () => { + for (const h of homes) { + if (h.id === id) continue; + const slot = liveOrder.indexOf(h.id); + h.el.style.transform = `translateX(${(slotXs[slot] - h.x) / scale}px)`; + } + }; + + const move = (ev) => { + const dx = ev.clientX - startX; + me.style.transform = `translateX(${dx / scale}px)`; + const cur = homes[startIdx].x + dx; + let nearest = 0, best = Infinity; + for (let i = 0; i < slotXs.length; i++) { + const d = Math.abs(slotXs[i] - cur); + if (d < best) { best = d; nearest = i; } + } + if (liveOrder.indexOf(id) !== nearest) { + liveOrder = order.filter((k) => k !== id); + liveOrder.splice(nearest, 0, id); + layout(); + } + }; + + const up = () => { + document.removeEventListener('pointermove', move); + document.removeEventListener('pointerup', up); + const finalSlot = liveOrder.indexOf(id); + me.classList.remove('dc-dragging'); + me.style.transform = `translateX(${(slotXs[finalSlot] - homes[startIdx].x) / scale}px)`; + // After the settle transition, kill transitions + clear transforms + + // commit the reorder in the same frame so there's no visual snap-back. + setTimeout(() => { + for (const h of homes) { h.el.style.transition = 'none'; h.el.style.transform = ''; } + if (liveOrder.join('|') !== order.join('|')) onReorder(liveOrder); + requestAnimationFrame(() => requestAnimationFrame(() => { + for (const h of homes) h.el.style.transition = ''; + })); + }, 180); + }; + document.addEventListener('pointermove', move); + document.addEventListener('pointerup', up); + }; + + return ( +
+
e.stopPropagation()}> +
+
+ +
+
+ e.stopPropagation()} + style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} /> +
+
+
+
+ + {menuOpen && ( +
e.stopPropagation()}> + + +
+ +
+ )} +
+ +
+
+
+ {children ||
{id}
} +
+
+ ); +} + +// Inline rename — commits on blur or Enter. +function DCEditable({ value, onChange, style, tag = 'span', onClick }) { + const T = tag; + return ( + e.stopPropagation()} + onBlur={(e) => onChange && onChange(e.currentTarget.textContent)} + onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }} + style={style}>{value} + ); +} + +// ───────────────────────────────────────────────────────────── +// Focus mode — overlay one artboard; ←/→ within section, ↑/↓ across +// sections, Esc or backdrop click to exit. +// ───────────────────────────────────────────────────────────── +function DCFocusOverlay({ entry, sectionMeta, sectionOrder }) { + const ctx = React.useContext(DCCtx); + const { sectionId, artboard } = entry; + const sec = ctx.section(sectionId); + const meta = sectionMeta[sectionId]; + const peers = meta.slotIds; + const aid = artboard.props.id ?? artboard.props.label; + const idx = peers.indexOf(aid); + const secIdx = sectionOrder.indexOf(sectionId); + + const go = (d) => { const n = peers[(idx + d + peers.length) % peers.length]; if (n) ctx.setFocus(`${sectionId}/${n}`); }; + const goSection = (d) => { + // Sections whose artboards are all deleted have slotIds:[] — step past + // them to the next non-empty section so ↑/↓ doesn't dead-end. + const n = sectionOrder.length; + for (let i = 1; i < n; i++) { + const ns = sectionOrder[(((secIdx + d * i) % n) + n) % n]; + const first = sectionMeta[ns] && sectionMeta[ns].slotIds[0]; + if (first) { ctx.setFocus(`${ns}/${first}`); return; } + } + }; + + React.useEffect(() => { + const k = (e) => { + if (e.key === 'ArrowLeft') { e.preventDefault(); go(-1); } + if (e.key === 'ArrowRight') { e.preventDefault(); go(1); } + if (e.key === 'ArrowUp') { e.preventDefault(); goSection(-1); } + if (e.key === 'ArrowDown') { e.preventDefault(); goSection(1); } + }; + document.addEventListener('keydown', k); + return () => document.removeEventListener('keydown', k); + }); + + const { width = 260, height = 480, children } = artboard.props; + const [vp, setVp] = React.useState({ w: window.innerWidth, h: window.innerHeight }); + React.useEffect(() => { const r = () => setVp({ w: window.innerWidth, h: window.innerHeight }); window.addEventListener('resize', r); return () => window.removeEventListener('resize', r); }, []); + const scale = Math.max(0.1, Math.min((vp.w - 200) / width, (vp.h - 260) / height, 2)); + + const [ddOpen, setDd] = React.useState(false); + const Arrow = ({ dir, onClick }) => ( + + ); + + // Portal to body so position:fixed is the real viewport regardless of any + // transform on DesignCanvas's ancestors (including the canvas zoom itself). + return ReactDOM.createPortal( +
ctx.setFocus(null)} + onWheel={(e) => e.preventDefault()} + style={{ position: 'fixed', inset: 0, zIndex: 100, background: 'rgba(24,20,16,.6)', backdropFilter: 'blur(14px)', + fontFamily: DC.font, color: '#fff' }}> + + {/* top bar: section dropdown (left) · close (right) */} +
e.stopPropagation()} + style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}> +
+ + {ddOpen && ( +
+ {sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => ( + + ))} +
+ )} +
+
+ +
+ + {/* card centered, label + index below — only the card itself stops + propagation so any backdrop click (including the margins around + the card) exits focus */} +
+
e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}> +
+ {children ||
{aid}
} +
+
+
e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}> + {(sec.labels || {})[aid] ?? artboard.props.label} + {idx + 1} / {peers.length} +
+
+ + go(-1)} /> + go(1)} /> + + {/* dots */} +
e.stopPropagation()} + style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}> + {peers.map((p, i) => ( +
+
, + document.body, + ); +} + +// ───────────────────────────────────────────────────────────── +// Post-it — absolute-positioned sticky note +// ───────────────────────────────────────────────────────────── +function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) { + return ( +
{children}
+ ); +} + +Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt }); + diff --git a/ELY Browser Design/screens/browsing.jsx b/ELY Browser Design/screens/browsing.jsx new file mode 100644 index 0000000..e212592 --- /dev/null +++ b/ELY Browser Design/screens/browsing.jsx @@ -0,0 +1,80 @@ +// browsing.jsx — Main browsing page (real webpage open) +const BrowsingScreen = () => ( + , active:true, audio:true, closable:true }, + { id:'roadmap', label:'Roadmap 2024 — Linear', icon: }, + { id:'mkt', label:'Marketing Site', icon: }, + { id:'twt', label:'Home / X', icon: }, + { id:'gh', label:'elybrowser/core · main', icon: }, + ] }}> + +
+ {/* Mock figma-ish UI inside */} +
+
Style Guide
+ / + ELY Design System + + Share +
+
+ +
+
+
+
Pages
+ {['Cover','Tokens','Typography','Color','Spacing','Components','Patterns','Sandbox'].map((p,i)=>( +
+ {p} +
+ ))} +
Layers
+ {[['Frame · Display','◇'],[' H1 / Display','T'],[' H2 / Title','T'],[' H3 / Subtitle','T'],['Frame · Body','◇'],[' Paragraph','T'],[' Caption','T']].map(([n,g],i)=>( +
{g} {n}
+ ))} +
+
+
+
+
Display / Serif
+
The aesthetics
of calm tech.
+
+ {['H1 / 64','H2 / 40','H3 / 28','Body / 16','Caption / 12','Mono / 13'].map((s,i)=>( +
+
The quick brown fox
+
{s}
+
+ ))} +
+
+
+ 100% +
+
+
+
+ Design + Prototype + Inspect +
+
Color · Local styles
+
+ {['#1d1c1a','#3a3733','#6b6660','#c96442','#ec6a8e','#7c6cf7','#4fb59a','#f6f4ef','#efe8e1','#fff3ee','#ffd1d8','#b9c7ff'].map(c=>( +
+ ))} +
+
Type · Newsreader / Geist
+
+
Aa Bb Cc
+
Newsreader · 6..72
+
+
Spacing
+
+ {[4,8,12,16,24,32].map(s=>(
))} +
+
+
+
+ +); +window.BrowsingScreen = BrowsingScreen; diff --git a/ELY Browser Design/screens/command.jsx b/ELY Browser Design/screens/command.jsx new file mode 100644 index 0000000..8fa5dd9 --- /dev/null +++ b/ELY Browser Design/screens/command.jsx @@ -0,0 +1,91 @@ +// command.jsx — Command Bar / Quick Switcher (overlaid on home) +const CommandScreen = () => ( + + +
+ {/* dimmed home behind */} +
+
+
Where focus finds flow.
+
+
+
+ {/* command panel */} +
+
+ +
+ roa +
+ Switcher + esc +
+ +
+ {/* Section: Open tabs */} + + } title="Style Guide — Figma" hint="figma.com" right="⌘1" /> + } title="Roadmap 2024 — Linear" hint="linear.app · 6 issues open" right="⌘2 · ↵" /> + } title="ELY Roadmap doc — Notion" hint="notion.so" right="⌘3" /> + + + + } title="Roadmap.app · 2024 update" hint="roadmap.app · 1d ago" /> + } title="elybrowser/core · roadmap.md" hint="github.com · 3d ago" /> + + + + } title="Roadmap planning template" hint="Pinned · Work" /> + + + + } title="Switch workspace" hint="Work · Personal · Studio" right="⌘⇧W" /> + } title="Open in split view" hint="Beside current tab" right="⌘D" /> + } title="Open Settings" hint="Appearance, Sync, Plugins…" right="⌘," /> + + + + } title='Ask: "What did we ship in the last roadmap cycle?"' hint="Searches recent tabs, history & docs" right="⌘↵"/> + +
+ +
+ navigate + open + ⌘↵ open in split + filter + + Powered by ELY + +
+
+
+ + + +); + +const Kbd = ({ children }) => ( + {children} +); + +const CmdSection = ({ label, children }) => ( +
+
{label}
+ {children} +
+); + +const CmdRow = ({ icon, title, hint, right, active, ai }) => ( +
+ {active &&
} +
{icon}
+
+
{title}
+ {hint &&
{hint}
} +
+ {right && {right}} +
+); + +window.CommandScreen = CommandScreen; diff --git a/ELY Browser Design/screens/home.jsx b/ELY Browser Design/screens/home.jsx new file mode 100644 index 0000000..5eb7ff1 --- /dev/null +++ b/ELY Browser Design/screens/home.jsx @@ -0,0 +1,118 @@ +// home.jsx — Home / New Tab +const HomeScreen = () => ( + + +
+
+
+ + Good morning, Alex +
+

+ Where focus finds flow. +

+
+
+ + Search the web or ELY + +
+
+
+ {[['Search Tabs', ], ['Switch Workspace', ], ['Open Notion', ]].map(([t,i],idx)=>( + + {i}{t} + + ))} +
+
+ + {/* favorites */} +
+
+ Favorites +
+
+ {[ + [, 'Notion'], + [, 'YouTube'], + [, 'Dribbble'], + [, 'X'], + [, 'Vercel'], + [, 'Behance'], + ].map(([g,n], i)=>( +
+ {g} +
{n}
+
+ ))} +
+ +
+
+
+ + {/* lower row */} +
+
+
+
Continue where you left off
+
+ {[ + [, 'Design System – Figma', 'figma.com', '2 min ago'], + [
, 'Roadmap 2024', 'linear.app', '1 hour ago'], + [, 'Marketing Site', 'elybrowser.com', '3 hours ago'], + ].map(([icon, name, host, when], i)=>( +
+ {icon} +
+
{name}
+
{host} · {when}
+
+
+ ))} +
+
+
+
+
+ Reading List · 5 +
+
+
The aesthetics of
calm technology
+
Inside ELY Journal · 6 min read
+
+
+
+ +
+
Recent activity
+
+ {[ + ['viewed', 'Pricing Page', 'elybrowser.com/pricing', '2m', ], + ['opened', 'Style Guide', 'figma.com/file/12345', '1h', ], + ['commented on', 'Roadmap 2024', 'linear.app/roadmap', '3h', ], + ].map(([verb, name, url, when, ic], i)=>( +
+
{ic}
+
+
You {verb}
+
{name}
+
{url}
+
+
{when}
+
+ ))} +
+
+ View all history +
+
+
+
+ +); +window.HomeScreen = HomeScreen; diff --git a/ELY Browser Design/screens/plugins.jsx b/ELY Browser Design/screens/plugins.jsx new file mode 100644 index 0000000..43dc923 --- /dev/null +++ b/ELY Browser Design/screens/plugins.jsx @@ -0,0 +1,159 @@ +// plugins.jsx — Plugin Marketplace +const PluginsScreen = () => { + const cats = ['All', 'Productivity', 'Reading', 'Privacy', 'Developer', 'AI', 'Writing', 'Design']; + const plugins = [ + { name:'Reader+', author:'ELY Studio', desc:'Distraction-free reading mode with serif typography & ambient backgrounds.', accent:'linear-gradient(135deg,#ffd1d8,#b9c7ff)', glyph:'¶', installs:'24.5k', rating:4.9, tags:['Featured','Reading'] }, + { name:'Tab Capsules', author:'orbit.dev', desc:'Hibernate tabs into searchable capsules. Restore in one click — even months later.', accent:'linear-gradient(135deg,#c5f0db,#7c6cf7)', glyph:'◐', installs:'18.2k', rating:4.8, tags:['Productivity'] }, + { name:'Coast Privacy Shield', author:'Coast Labs', desc:'Per-site tracker counts and one-tap container tabs.', accent:'linear-gradient(135deg,#1d1c1a,#7c6cf7)', glyph:'◊', dark:true, installs:'31.0k', rating:4.7, tags:['Privacy'] }, + { name:'Quill', author:'paperhouse', desc:'Annotate any page. Notes sync to Notion, Obsidian, Bear.', accent:'linear-gradient(135deg,#fff3ee,#f4c8b8)', glyph:'✎', installs:'12.4k', rating:4.6, tags:['Writing'] }, + { name:'ELY AI · Summon', author:'ELY Studio', desc:'Ask anything about the page. Beautiful inline citations.', accent:'linear-gradient(135deg,#d6c4ff,#ffd1d8)', glyph:'✦', installs:'47.3k', rating:4.9, tags:['AI','Featured'] }, + { name:'Pomodoro Glass', author:'flux.app', desc:'Ambient timer that lives in your sidebar. Subtle, focused.', accent:'linear-gradient(135deg,#ffe5b8,#ec6a8e)', glyph:'◷', installs:'9.1k', rating:4.5, tags:['Productivity'] }, + { name:'Localhost Console', author:'devhabit', desc:'Inline preview & logs for any dev port. Toggle from omnibar.', accent:'linear-gradient(135deg,#0e0d0c,#4fb59a)', glyph:'❯_', dark:true, installs:'7.6k', rating:4.7, tags:['Developer'] }, + { name:'Lookbook', author:'studio.bento', desc:'Collect screenshots & swatches as you browse. Boards live in ELY.', accent:'linear-gradient(135deg,#ec6a8e,#7c6cf7)', glyph:'▥', installs:'14.0k', rating:4.8, tags:['Design'] }, + ]; + return ( + +
+ + +
ely://plugins/marketplace
+ + + +
+
+ {/* Hero */} +
+
+
Plugins · Native to ELY
+

Quiet tools.
Everyday magic.

+
A small, curated marketplace built for ELY — sandboxed, theme-aware, no Chrome extension framework required. Each plugin ships with its own tweakable controls in your sidebar.
+
+ + Search 184 plugins… + ⌘K +
+
+
+
Editor's pick
+
+
+
+
ELY AI · Summon
+
by ELY Studio · 47.3k installs
+
Ask anything about the current page with citations.
+
+
+
+ Install + Open detail + ⭐︎ 4.9 · 1,241 reviews +
+
+
+ + {/* Cats */} +
+ {cats.map((c,i)=>( + {c} + ))} + Sort: Most installed +
+ + {/* Plugin grid */} +
+ {plugins.map((p,i)=>( +
+
+
+ {p.glyph} + {p.tags.includes('Featured') && FEATURED} +
+
+
+
{p.name}
+
{p.author}
+
+ Get +
+
{p.desc}
+
+ ★ {p.rating} + · + {p.installs} installs + Sandboxed +
+
+ ))} +
+ + {/* Plugin detail (preview / featured below) */} +
+
+
+
+
+ Install · Free + +
+
Permissions
+
+ {[['Read content of pages you choose','✓'],['Store reading preferences locally','✓'],['Access network',' '],['Read browsing history',' ']].map(([t,c],i)=>( +
+ {c} + {t} +
+ ))} +
+
+ +
+
+
+
Reading · Featured
+

Reader+

+
by ELY Studio · v2.4 · updated 3 days ago
+
+
+
★ 4.9
+
1,894 reviews
+
+
+

Reader+ pulls the article out of the noise. A serif type system, gentle ambient backgrounds, and a focus mode that quietly hides everything you don't need. Built native to ELY's plugin runtime — themes follow your wallpaper, settings live in the sidebar.

+ +
+ {[ + ['Installs','24,521'],['Rating','4.9 / 5'],['Size','340 kB'],['Sandboxed','Yes'] + ].map(([k,v])=>( +
+
{k}
+
{v}
+
+ ))} +
+ +
+
What it adds to ELY
+
+ {[ + ['Reader sidebar', 'Outline, time-to-read, table of contents — themed to your wallpaper.'], + ['Ambient mode', 'Subtle background motion sourced from the article\'s lead image.'], + ['Pocket-style queue', 'Save for later that lives in ELY\'s reading list, not a third-party app.'], + ].map(([t,d],i)=>( +
+ +
+
{t}
+
{d}
+
+
+ ))} +
+
+
+
+
+
+ +);}; +window.PluginsScreen = PluginsScreen; diff --git a/ELY Browser Design/screens/settings.jsx b/ELY Browser Design/screens/settings.jsx new file mode 100644 index 0000000..0c2acc2 --- /dev/null +++ b/ELY Browser Design/screens/settings.jsx @@ -0,0 +1,138 @@ +// settings.jsx — Settings page +const SettingsScreen = () => { + const SectionLabel = ({ children, k='label' }) => k==='label' ? +
{children}
+ :
{children}
; + const NavItem = ({ icon, label, active }) => ( +
+ {icon}{label} +
+ ); + const Toggle = ({ on }) => ( + + + + ); + const Row = ({ title, desc, children, last }) => ( +
+
+
{title}
+ {desc &&
{desc}
} +
+ {children} +
+ ); + const Select = ({ value }) => ( + {value} + ); + return ( + +
+ + +
ely://settings/appearance
+ + + + +
+
+
+
+
Settings
+
ELY 0.42 · macOS 14.4
+
+ General + } label="Appearance" active/> + } label="Sidebar"/> + } label="Tabs & Workspaces"/> + } label="Shortcuts"/> + Account + } label="Sync"/> + } label="Privacy & Security"/> + } label="Profiles"/> + Power + } label="Plugins"/> + } label="ELY AI"/> + } label="Developer"/> + About + } label="What's new"/> + } label="Help & feedback"/> +
+ +
+
+
General
+

Appearance

+
Tune the atmosphere of your browser. ELY's theme follows the time of day by default — pick a wallpaper, then let it breathe.
+ +
+ {[ + ['Dawn','#ffd1d8','#b9c7ff', true], + ['Violet','#d6c4ff','#ffe0c0'], + ['Mint','#c5f0db','#ffe5b8'], + ['Slate','#cfd6e2','#1d1c1a'] + ].map(([n, a, b, sel])=>( +
+
+
+ {n} + {sel && } +
+
+ ))} +
+ +
+ +
+ {['System','Light','Dark'].map((t,i)=>( + {t} + ))} +
+
+ +
+ {['#c96442','#ec6a8e','#7c6cf7','#4fb59a','#1d1c1a'].map((c,i)=>( +
+ ))} +
+ + + + +
+ +
Sidebar
+

Layout

+
+ {['Single column','Compact','Hidden on hover'].map((n,i)=>( +
+
+
+
+
+
{n}
+
{['Default · workspace + tabs','Icons-only with tooltips','Slide in on cursor reach'][i]}
+
+ ))} +
+ +
+
+
+ +);}; +window.SettingsScreen = SettingsScreen; diff --git a/ELY Browser Design/screens/split.jsx b/ELY Browser Design/screens/split.jsx new file mode 100644 index 0000000..91f4b41 --- /dev/null +++ b/ELY Browser Design/screens/split.jsx @@ -0,0 +1,105 @@ +// split.jsx — Split-view browsing +const SplitScreen = () => { + const Pane = ({ title, host, children, accent }) => ( +
+
+ + + {host} + {title} + + + +
+
{children}
+
+ ); + return ( + , active:true, closable:true }, + { id:'lin', label:'Roadmap 2024 — Linear', icon:, active:true }, + { id:'gh', label:'elybrowser/core', icon: }, + ] }}> +
+ + +
+ + figma.com + /file/12345/Style-Guide + +
+
+
+ + linear.app + /elybrowser/roadmap + +
+ + +
+
+ {/* Left: Figma */} + +
+
+ + Style Guide + / Typography + + Share + +
+
+
+
+
The aesthetics of calm tech.
+
A type system tuned for long-form reading and slow, deliberate interfaces. Designed for ambient use.
+
+ {['#1d1c1a','#c96442','#ec6a8e','#7c6cf7','#4fb59a'].map(c=>(
))} +
+
+
+
+ + {/* Right: Linear */} + +
+
+
ELY
+ {['Inbox','My Issues','Triage'].map(l=>(
{l}
))} +
Cycles
+ {['24 · Now','24 · Next','25 · Q1','25 · Q2'].map((l,i)=>(
{l}
))} +
+
+
+ Roadmap 2024 + · 18 issues + + New +
+
+ {[ + ['ELY-101','In Progress', 'Universal command bar', '#f5a623'], + ['ELY-102','In Progress', 'Vertical tabs · sticky pinning', '#f5a623'], + ['ELY-103','Todo', 'Cloudflare sync · device pairing', '#5e6ad2'], + ['ELY-104','Todo', 'Plugin SDK · v0', '#5e6ad2'], + ['ELY-105','Backlog', 'Reader mode · ambient theme', '#6b6660'], + ['ELY-106','Done', 'Workspace switching', '#4fb59a'], + ].map(([id,st,t,c],i)=>( +
+ + {id} + {t} + {st} + +
+ ))} +
+
+
+
+
+ +);}; +window.SplitScreen = SplitScreen; diff --git a/ELY Browser Design/screens/sync.jsx b/ELY Browser Design/screens/sync.jsx new file mode 100644 index 0000000..8b67935 --- /dev/null +++ b/ELY Browser Design/screens/sync.jsx @@ -0,0 +1,111 @@ +// sync.jsx — Sync / Sign In page +const SyncScreen = () => { + const Device = ({ name, model, when, current, icon }) => ( +
+
{icon}
+
+
{name}{current && This device}
+
{model} · {when}
+
+ Synced +
+ ); + return ( + +
+ + +
ely://settings/sync
+ + +
+
+
+
+
+ Cloudflare Sync · end-to-end encrypted +
+

Your tabs, on every device.

+

Sign in once and ELY keeps your tabs, workspaces, pinned items and history in sync — encrypted in your hands, mirrored at the edge.

+ +
+
+ Better Auth · ELY Account +
+ +
alex@elybrowser.com
+ + +
••••••••••
+ + +
+
or continue with
+
+
+ {['Google','GitHub','Apple'].map(p=>( +
+ {p==='GitHub' && } + {p==='Google' && } + {p==='Apple' && } + {p} +
+ ))} +
+
By signing in you accept the ELY terms & privacy.
+
+
+ +
+
+
+ +
Devices
+ 3 connected +
+ }/> + }/> + }/> +
+ Pair a device + or scan QR with the ELY app +
+
+ +
+
+ +
+
Recovery key
+
The only way to restore an end-to-end encrypted account. We don't keep a copy.
+
+
+
+ river · slate · candle · lantern
orchid · meadow · whistle · ember +
+
+ Copy + Download .txt + I've saved it +
+
+ +
+
What syncs
+
+ {[['Tabs & pinned',true],['Workspaces',true],['History',true],['Bookmarks',true],['Reading list',true],['Plugins',true],['Passwords',false],['Open windows',false]].map(([n,on],i)=>( +
+ {on? : } + +
+ ))} +
+
+
+
+
+ +);}; +window.SyncScreen = SyncScreen; diff --git a/ELY Browser Design/shared.jsx b/ELY Browser Design/shared.jsx new file mode 100644 index 0000000..e91e1d2 --- /dev/null +++ b/ELY Browser Design/shared.jsx @@ -0,0 +1,272 @@ +// shared.jsx — icons + sidebar + browser shell shared across screens +// All Lucide-style 1.5px stroke SVG. + +const Icon = ({ d, size = 16, fill = 'none', stroke = 'currentColor', sw = 1.5, children, style }) => ( + + {d ? : children} + +); + +const I = { + ArrowLeft: (p)=> , + ArrowRight: (p)=> , + ArrowUpRight:(p)=> , + Plus: (p)=> , + X: (p)=> , + Search: (p)=> , + Star: (p)=> , + Sparkle: (p)=> , + Sun: (p)=> , + Moon: (p)=> , + Sunrise: (p)=> , + Settings: (p)=> , + Menu: (p)=> , + Filters: (p)=> , + Bookmark: (p)=> , + Copy: (p)=> , + Download: (p)=> , + ChevronDown:(p)=> , + ChevronRight:(p)=> , + ChevronUp: (p)=> , + Layers: (p)=> , + Home: (p)=> , + Calendar: (p)=> , + Globe: (p)=> , + Lock: (p)=> , + Sidebar: (p)=> , + Split: (p)=> , + Cmd: (p)=> , + Reload: (p)=> , + Pin: (p)=> , + Tab: (p)=> , + Inbox: (p)=> , + Trash: (p)=> , + Eye: (p)=> , + Mic: (p)=> , + Lightning: (p)=> , + Shield: (p)=> , + Cloud: (p)=> , + Plug: (p)=> , + Heart: (p)=> , + Check: (p)=> , + Dot: (p)=> , + Key: (p)=> , + Devices: (p)=> , + User: (p)=> , + Bell: (p)=> , + Palette: (p)=> , + Code: (p)=> , + Filter: (p)=> , +}; + +/* ===== little brand glyphs (faux app icons) ===== */ +const Brand = { + Notion: ({s=22}) => ( +
N
+ ), + YT: ({s=22}) => ( +
+ +
+ ), + Dribbble: ({s=22}) => ( +
·
+ ), + X: ({s=22}) => ( +
𝕏
+ ), + Vercel: ({s=22}) => ( +
+ +
+ ), + Behance: ({s=22}) => ( +
+ ), + Figma: ({s=18}) => ( +
+ + + + + + + + +
+ ), + Slack: ({s=18}) => ( + + ), + GitHub: ({s=18}) => ( + + ), + Linear: ({s=18}) => ( +
L
+ ), +}; + +/* ============== SIDEBAR ============== */ +const Sidebar = ({ activeId = 'home', mode = 'home', tabs, withTrafficTitle = true, workspace='Work', accentEmoji='🌸' }) => { + const defaultTabs = [ + { id:'figma', label:'Design System – Figma', icon:, closable:true }, + { id:'roadmap', label:'Roadmap 2024', icon:
}, + { id:'mkt', label:'Marketing Site', icon: }, + ]; + const t = tabs || defaultTabs; + return ( + + ); +}; + +/* Browser shell that hosts a sidebar + main area */ +const ElyShell = ({ wallpaper='dawn', children, sidebarProps={}, fakeTraffic=true }) => ( +
+
+
+ +
{children}
+
+
+); + +/* topbar */ +const TopBar = ({ url, host, path, secure=true, right, canBack=true, canForward=false }) => ( +
+ + +
+ {secure ? : } + {host ? ( + <> + {host} + {path && {path}} + + ) : ( + {url || 'Search ELY or type a command…'} + )} + + + + +
+ {right || ( + <> + + + + + + )} +
+); + +window.Sidebar = Sidebar; +window.ElyShell = ElyShell; +window.TopBar = TopBar; +window.Icon = Icon; +window.I = I; +window.Brand = Brand; diff --git a/ELY Browser Design/styles.css b/ELY Browser Design/styles.css new file mode 100644 index 0000000..c2de864 --- /dev/null +++ b/ELY Browser Design/styles.css @@ -0,0 +1,302 @@ +/* ELY Browser – global tokens & shared chrome */ + +@import url('https://fonts.googleapis.com/css2?family=Newsreader:ital,opsz,wght@0,6..72,300;0,6..72,400;0,6..72,500;0,6..72,600;1,6..72,400&display=swap'); +@import url('https://fonts.googleapis.com/css2?family=Geist:wght@300;400;450;500;600;700&family=Geist+Mono:wght@400;500&display=swap'); + +:root { + --ely-ink: #1d1c1a; + --ely-ink-2: #3a3733; + --ely-ink-3: #6b6660; + --ely-ink-4: #9a948c; + --ely-ink-5: #c8c2b8; + + --ely-glass: rgba(255,255,255,0.62); + --ely-glass-2: rgba(255,255,255,0.78); + --ely-glass-3: rgba(255,255,255,0.92); + --ely-tint: rgba(255,255,255,0.42); + --ely-stroke: rgba(40,30,20,0.08); + --ely-stroke-2: rgba(40,30,20,0.04); + --ely-divider: rgba(40,30,20,0.06); + + --ely-accent: #c96442; /* warm clay */ + --ely-accent-2: #d97757; + --ely-violet: #7c6cf7; + --ely-rose: #ec6a8e; + --ely-mint: #4fb59a; + + --ely-radius-card: 18px; + --ely-radius-pane: 14px; + --ely-radius-pill: 999px; + + --ely-shadow-window: 0 30px 70px -20px rgba(40,30,20,0.35), 0 0 0 1px rgba(255,255,255,0.5) inset, 0 1px 0 rgba(255,255,255,0.7) inset; + --ely-shadow-card: 0 8px 24px -10px rgba(40,30,20,0.18), 0 0 0 1px var(--ely-stroke) inset; + --ely-shadow-soft: 0 1px 0 rgba(255,255,255,0.7) inset, 0 0 0 1px var(--ely-stroke); + + --ely-font-sans: 'Geist', ui-sans-serif, sans-serif; + --ely-font-serif: 'Newsreader', 'Source Serif 4', Georgia, serif; + --ely-font-mono: 'Geist Mono', ui-monospace, monospace; +} + +/* ============== BROWSER FRAME ============== */ + +.ely-window { + width: 100%; + height: 100%; + position: relative; + font-family: var(--ely-font-sans); + color: var(--ely-ink); + font-feature-settings: 'cv11', 'ss01'; + letter-spacing: -0.01em; + overflow: hidden; + border-radius: 14px; + isolation: isolate; +} + +/* the dreamy gradient wallpaper */ +.ely-wallpaper { + position: absolute; inset: 0; + background: + radial-gradient(60% 50% at 85% 18%, #ffd1d8 0%, rgba(255,209,216,0) 70%), + radial-gradient(55% 60% at 15% 95%, #b9c7ff 0%, rgba(185,199,255,0) 75%), + radial-gradient(80% 80% at 50% 50%, #fff3ee 0%, #efe8e1 100%); + z-index: 0; +} +.ely-wallpaper.ely-wp-dawn { + background: + radial-gradient(60% 50% at 85% 18%, #ffd1d8 0%, rgba(255,209,216,0) 70%), + radial-gradient(55% 60% at 15% 95%, #b9c7ff 0%, rgba(185,199,255,0) 75%), + radial-gradient(80% 80% at 50% 50%, #fff3ee 0%, #efe8e1 100%); +} +.ely-wallpaper.ely-wp-violet { + background: + radial-gradient(50% 50% at 80% 20%, #d6c4ff 0%, rgba(214,196,255,0) 70%), + radial-gradient(50% 50% at 12% 90%, #ffe0c0 0%, rgba(255,224,192,0) 70%), + radial-gradient(80% 80% at 50% 50%, #f4eef9 0%, #e9e2ee 100%); +} +.ely-wallpaper.ely-wp-mint { + background: + radial-gradient(50% 50% at 85% 25%, #c5f0db 0%, rgba(197,240,219,0) 70%), + radial-gradient(55% 55% at 10% 95%, #ffe5b8 0%, rgba(255,229,184,0) 70%), + radial-gradient(80% 80% at 50% 50%, #f0f5ee 0%, #e6ece2 100%); +} + +.ely-shell { + position: absolute; + inset: 16px; + z-index: 1; + display: grid; + grid-template-columns: 256px 1fr; + gap: 12px; +} + +/* ============== SIDEBAR ============== */ + +.ely-sidebar { + background: var(--ely-glass); + backdrop-filter: blur(28px) saturate(140%); + -webkit-backdrop-filter: blur(28px) saturate(140%); + border-radius: var(--ely-radius-card); + box-shadow: var(--ely-shadow-window); + display: flex; + flex-direction: column; + padding: 10px 10px 12px; + gap: 10px; + position: relative; +} + +.ely-traffic { + display: flex; gap: 8px; padding: 6px 6px 4px; + align-items: center; +} +.ely-traffic .dot { width: 12px; height: 12px; border-radius: 50%; box-shadow: inset 0 0 0 0.5px rgba(0,0,0,0.18); } +.ely-traffic .dot.red { background: #ff5f57; } +.ely-traffic .dot.yellow { background: #febc2e; } +.ely-traffic .dot.green { background: #28c840; } +.ely-traffic .title { + margin-left: 8px; + font-size: 12.5px; font-weight: 500; + color: var(--ely-ink-2); + display: inline-flex; align-items: center; gap: 4px; +} +.ely-traffic .title svg { opacity: .6; } + +.ely-ws-row { + display: flex; align-items: center; gap: 6px; + padding: 4px 2px; +} +.ely-ws-tile { + width: 32px; height: 32px; + border-radius: 9px; + background: linear-gradient(135deg, #fff 0%, #ffe9e0 100%); + display: flex; align-items: center; justify-content: center; + box-shadow: var(--ely-shadow-soft); + color: var(--ely-accent); + flex: 0 0 auto; +} +.ely-ws-pick { + flex: 1; min-width: 0; + display: flex; align-items: center; gap: 8px; + padding: 6px 8px; + border-radius: 9px; + background: rgba(255,255,255,0.6); + box-shadow: var(--ely-shadow-soft); + font-size: 13px; font-weight: 500; color: var(--ely-ink); +} +.ely-ws-pick .ws-glyph { + width: 18px; height: 18px; border-radius: 5px; + background: linear-gradient(135deg, #ec6a8e, #c96442); + display: inline-flex; align-items: center; justify-content: center; + color: #fff; font-size: 10px; +} +.ely-ws-pick .grow { flex: 1; } +.ely-ws-pick svg { color: var(--ely-ink-3); } +.ely-ws-add { + width: 32px; height: 32px; + border-radius: 9px; + background: rgba(255,255,255,0.4); + box-shadow: var(--ely-shadow-soft); + display: flex; align-items: center; justify-content: center; + color: var(--ely-ink-3); + flex: 0 0 auto; +} + +.ely-nav { display: flex; flex-direction: column; gap: 1px; padding: 4px 0 0; } +.ely-nav-item { + display: flex; align-items: center; gap: 10px; + padding: 7px 10px; + border-radius: 8px; + font-size: 13px; + color: var(--ely-ink-2); + position: relative; +} +.ely-nav-item .ico { + width: 18px; height: 18px; flex: 0 0 auto; + display: inline-flex; align-items: center; justify-content: center; + color: var(--ely-ink-3); +} +.ely-nav-item .ico-tile { + width: 18px; height: 18px; flex: 0 0 auto; + border-radius: 4px; + background: #fff; box-shadow: 0 0 0 1px var(--ely-stroke); + display: inline-flex; align-items: center; justify-content: center; + font-size: 10px; font-weight: 600; +} +.ely-nav-item .label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.ely-nav-item .badge { + font-size: 10px; + background: rgba(40,30,20,0.06); + color: var(--ely-ink-3); + padding: 1px 6px; + border-radius: 999px; +} +.ely-nav-item .dot-unread { + width: 6px; height: 6px; border-radius: 50%; + background: var(--ely-accent); +} +.ely-nav-item.active { + background: rgba(255,255,255,0.85); + color: var(--ely-ink); + box-shadow: var(--ely-shadow-soft); +} +.ely-nav-item .close { + width: 16px; height: 16px; + display: inline-flex; align-items: center; justify-content: center; + border-radius: 4px; + color: var(--ely-ink-4); + opacity: 0; +} +.ely-nav-item:hover .close { opacity: 1; } + +.ely-sec-label { + font-size: 10.5px; + font-weight: 500; + color: var(--ely-ink-4); + letter-spacing: 0.08em; + text-transform: uppercase; + display: flex; align-items: center; gap: 6px; + padding: 8px 10px 4px; +} + +.ely-sidebar-foot { + margin-top: auto; + display: flex; flex-direction: column; gap: 2px; + padding-top: 8px; + border-top: 1px solid var(--ely-divider); +} +.ely-profile-row { + display: flex; align-items: center; gap: 10px; + padding: 6px 10px; + border-radius: 9px; +} +.ely-avatar { + width: 26px; height: 26px; border-radius: 50%; + background: linear-gradient(135deg, #f4c8b8, #c96442); + display: inline-flex; align-items: center; justify-content: center; + font-size: 10px; font-weight: 600; color: #fff; + flex: 0 0 auto; +} +.ely-profile-row .name { font-size: 13px; font-weight: 500; flex: 1; } + +/* ============== MAIN PANE ============== */ + +.ely-main { + background: var(--ely-glass); + backdrop-filter: blur(28px) saturate(140%); + -webkit-backdrop-filter: blur(28px) saturate(140%); + border-radius: var(--ely-radius-card); + box-shadow: var(--ely-shadow-window); + display: flex; flex-direction: column; + overflow: hidden; + position: relative; +} + +.ely-topbar { + height: 54px; + display: flex; align-items: center; gap: 8px; + padding: 0 14px; + border-bottom: 1px solid var(--ely-divider); + flex: 0 0 auto; +} +.ely-iconbtn { + width: 30px; height: 30px; + border-radius: 8px; + display: inline-flex; align-items: center; justify-content: center; + color: var(--ely-ink-3); + background: transparent; + border: 0; +} +.ely-iconbtn:hover { background: rgba(255,255,255,0.55); color: var(--ely-ink); } +.ely-iconbtn.is-on { background: rgba(255,255,255,0.85); color: var(--ely-ink); box-shadow: var(--ely-shadow-soft); } +.ely-iconbtn.disabled { color: var(--ely-ink-5); } + +.ely-omnibar { + flex: 1; + height: 36px; + display: flex; align-items: center; gap: 10px; + background: rgba(255,255,255,0.55); + border-radius: var(--ely-radius-pill); + padding: 0 14px; + font-size: 13px; + color: var(--ely-ink-3); + box-shadow: var(--ely-shadow-soft); +} +.ely-omnibar .url-host { color: var(--ely-ink); font-weight: 500; } +.ely-omnibar .url-path { color: var(--ely-ink-3); } +.ely-omnibar .right { margin-left: auto; display: flex; align-items: center; gap: 4px; color: var(--ely-ink-4); } + +/* generic glass card */ +.ely-card { + background: var(--ely-glass-2); + border-radius: 16px; + box-shadow: var(--ely-shadow-card); +} + +/* utility */ +.ely-row { display: flex; align-items: center; } +.ely-col { display: flex; flex-direction: column; } +.ely-grow { flex: 1; min-width: 0; } +.ely-mono { font-family: var(--ely-font-mono); } +.ely-serif { font-family: var(--ely-font-serif); } + +/* scrollbar invisibilize within artboards (already done by canvas) */ +.ely-scroll::-webkit-scrollbar { display: none; } diff --git a/ELY Browser Design/uploads/pasted-1778197597361-0.png b/ELY Browser Design/uploads/pasted-1778197597361-0.png new file mode 100644 index 0000000..c15251a Binary files /dev/null and b/ELY Browser Design/uploads/pasted-1778197597361-0.png differ diff --git a/crates/ely_app/src/services/mod.rs b/crates/ely_app/src/services/mod.rs index 3fa3b39..0a7da45 100644 --- a/crates/ely_app/src/services/mod.rs +++ b/crates/ely_app/src/services/mod.rs @@ -4,10 +4,9 @@ pub mod http_downloads; pub mod plugin_package_store; pub mod plugin_packages; pub mod plugin_signatures; -mod servo_profile_data; -pub mod servo_sidecar; +pub mod servo_live; +pub(crate) mod servo_profile_data; mod servo_sidecar_command; -mod servo_sidecar_request; pub(crate) use servo_profile_data::ProfileDataMode; diff --git a/crates/ely_app/src/services/servo_profile_data.rs b/crates/ely_app/src/services/servo_profile_data.rs index 3f1c50f..ba577bf 100644 --- a/crates/ely_app/src/services/servo_profile_data.rs +++ b/crates/ely_app/src/services/servo_profile_data.rs @@ -11,16 +11,16 @@ const ELY_QUALIFIER: &str = "com"; const ELY_ORGANIZATION: &str = "elydora"; const ELY_APPLICATION: &str = "ELY Browser"; -pub(super) fn default_profile_data_root() -> Option { +pub(crate) fn default_profile_data_root() -> Option { ProjectDirs::from(ELY_QUALIFIER, ELY_ORGANIZATION, ELY_APPLICATION) .map(|project_dirs| project_dirs.data_dir().join("profiles")) } -pub(super) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf { +pub(crate) fn profile_data_dir(profile_data_root: &Path, profile_id: &ProfileId) -> PathBuf { profile_data_root.join(profile_id.as_str()).join("servo") } -pub(super) fn transient_profile_data_dir( +pub(crate) fn transient_profile_data_dir( profile_id: &ProfileId, ) -> Result { let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); diff --git a/crates/ely_app/src/services/servo_sidecar.rs b/crates/ely_app/src/services/servo_sidecar.rs deleted file mode 100644 index ea55e51..0000000 --- a/crates/ely_app/src/services/servo_sidecar.rs +++ /dev/null @@ -1,472 +0,0 @@ -use std::{ - env, fs, io, - path::{Path, PathBuf}, - process::{Command, Output, Stdio}, - thread, - time::{Duration, Instant, SystemTime, SystemTimeError, UNIX_EPOCH}, -}; - -use ely_domain::ProfileId; -use serde::Deserialize; -use thiserror::Error; - -pub use super::servo_sidecar_request::{SidecarSitePermission, SidecarSnapshotRequest}; - -use super::{ - servo_profile_data::{ - ProfileDataMode, default_profile_data_root, profile_data_dir, transient_profile_data_dir, - }, - servo_sidecar_command::{SidecarCommandTarget, default_sidecar_command}, -}; - -const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20); -const SIDECAR_RETRY_INTERVAL: Duration = Duration::from_millis(250); -const SIDECAR_NAVIGATION_ATTEMPTS: usize = 3; -const SIDECAR_INTERACTION_ATTEMPTS: usize = 1; - -#[derive(Clone, Debug)] -pub struct ServoSidecarClient { - command_target: SidecarCommandTarget, - profile_data_root: PathBuf, -} - -impl ServoSidecarClient { - pub fn new() -> Result { - Ok(Self { - command_target: default_sidecar_command()?, - profile_data_root: default_profile_data_root() - .ok_or(ServoSidecarError::ProfileDataRootUnavailable)?, - }) - } - - pub fn snapshot( - &self, - request: SidecarSnapshotRequest, - ) -> Result { - if let Some(path) = self.command_target.missing_binary_path() { - return Err(ServoSidecarError::SidecarBinaryUnavailable { path: path.to_path_buf() }); - } - - let mut attempt = 0; - loop { - match self.snapshot_once(&request) { - Ok(snapshot) => return Ok(snapshot), - Err(error) => { - attempt += 1; - if attempt >= request.max_attempts() { - return Err(error); - } - thread::sleep(SIDECAR_RETRY_INTERVAL); - } - } - } - } - - fn snapshot_once( - &self, - request: &SidecarSnapshotRequest, - ) -> Result { - let rgba_path = temporary_rgba_path()?; - let profile_data_dir = request.profile_data_dir(&self.profile_data_root)?; - let output = match self.run_snapshot_command(request, &rgba_path, &profile_data_dir) { - Ok(output) => output, - Err(error) => { - return cleanup_failed_snapshot(request, &rgba_path, &profile_data_dir, error); - } - }; - - if !output.status.success() { - let error = ServoSidecarError::SidecarFailed { - status: output.status.to_string(), - stderr: String::from_utf8_lossy(&output.stderr).trim().to_string(), - }; - return cleanup_failed_snapshot(request, &rgba_path, &profile_data_dir, error); - } - - let snapshot = read_sidecar_snapshot(&output.stdout, &rgba_path, &request.profile_id); - let cleanup = remove_temporary_file(&rgba_path); - let profile_cleanup = request.cleanup_profile_data_dir(&profile_data_dir); - match (snapshot, cleanup, profile_cleanup) { - (Ok(snapshot), Ok(()), Ok(())) => Ok(snapshot), - (Err(error), Ok(()), Ok(())) => Err(error), - (Ok(_), Err(error), _) | (Err(_), Err(error), _) => Err(error), - (Ok(_), Ok(()), Err(error)) | (Err(_), Ok(()), Err(error)) => Err(error), - } - } - - fn run_snapshot_command( - &self, - request: &SidecarSnapshotRequest, - rgba_path: &Path, - profile_data_dir: &Path, - ) -> Result { - let mut command = self.command_target.command(); - append_snapshot_args(&mut command, request, rgba_path, profile_data_dir); - if let Some(click_point) = request.click_point { - command - .arg("--click-x") - .arg(click_point.x.to_string()) - .arg("--click-y") - .arg(click_point.y.to_string()); - } - if let Some(typed_text) = request.typed_text.as_deref() { - command.arg("--type-text").arg(typed_text); - } - for permission in &request.site_permissions { - command.arg("--site-permission").arg(permission.to_arg()); - } - - let mut child = command - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .map_err(ServoSidecarError::Command)?; - - let started_at = Instant::now(); - loop { - if child.try_wait().map_err(ServoSidecarError::Command)?.is_some() { - return child.wait_with_output().map_err(ServoSidecarError::Command); - } - - let timeout = self.command_target.timeout(); - if started_at.elapsed() >= timeout { - terminate_child(child)?; - return Err(ServoSidecarError::SidecarTimedOut { - url: request.url.as_str().to_string(), - seconds: timeout.as_secs(), - }); - } - - thread::sleep(SIDECAR_POLL_INTERVAL); - } - } -} - -fn append_snapshot_args( - command: &mut Command, - request: &SidecarSnapshotRequest, - rgba_path: &Path, - profile_data_dir: &Path, -) { - command - .arg("snapshot") - .arg("--url") - .arg(request.url.as_str()) - .arg("--profile-id") - .arg(request.profile_id.as_str()) - .arg("--profile-data-dir") - .arg(profile_data_dir) - .arg("--rgba-out") - .arg(rgba_path) - .arg("--width") - .arg(request.width.to_string()) - .arg("--height") - .arg(request.height.to_string()) - .arg("--scroll-x") - .arg(request.scroll_x.to_string()) - .arg("--scroll-y") - .arg(request.scroll_y.to_string()) - .arg("--page-zoom-percent") - .arg(request.page_zoom_percent.to_string()); -} - -impl SidecarSnapshotRequest { - fn profile_data_dir(&self, profile_data_root: &Path) -> Result { - match self.profile_data_mode { - ProfileDataMode::Persistent => { - Ok(profile_data_dir(profile_data_root, &self.profile_id)) - } - ProfileDataMode::Transient => { - transient_profile_data_dir(&self.profile_id).map_err(ServoSidecarError::SystemClock) - } - } - } - - fn cleanup_profile_data_dir(&self, profile_data_dir: &Path) -> Result<(), ServoSidecarError> { - if self.profile_data_mode == ProfileDataMode::Persistent { - return Ok(()); - } - - remove_temporary_directory(profile_data_dir) - } - - fn max_attempts(&self) -> usize { - if self.click_point.is_some() || self.typed_text.is_some() { - return SIDECAR_INTERACTION_ATTEMPTS; - } - - SIDECAR_NAVIGATION_ATTEMPTS - } -} - -#[derive(Clone, Debug)] -pub struct SidecarSnapshot { - loaded_url: Option, - title: Option, - render_state: String, - width: u32, - height: u32, - #[cfg(test)] - non_white_pixel_count: u64, - #[cfg(test)] - content_pixel_count: u64, - #[cfg(test)] - sample_hash: u64, - rgba_bytes: Vec, -} - -impl SidecarSnapshot { - fn from_report( - report: SidecarReport, - expected_profile_id: &ProfileId, - rgba_bytes: Vec, - ) -> Result { - let expected_byte_count = expected_rgba_byte_count(report.width, report.height)?; - if report.profile_id != expected_profile_id.as_str() { - return Err(ServoSidecarError::ProfileMismatch { - expected: expected_profile_id.as_str().to_string(), - actual: report.profile_id, - }); - } - if !is_renderable_state(&report.state) { - return Err(ServoSidecarError::IncompleteRender { state: report.state }); - } - if report.rgba_byte_count != expected_byte_count || rgba_bytes.len() != expected_byte_count - { - return Err(ServoSidecarError::RgbaByteCountMismatch { - expected: expected_byte_count, - reported: report.rgba_byte_count, - actual: rgba_bytes.len(), - }); - } - if report.non_white_pixel_count == 0 { - return Err(ServoSidecarError::BlankRenderedFrame { - requested_url: report.requested_url, - }); - } - if report.content_pixel_count == 0 { - return Err(ServoSidecarError::ContentlessRenderedFrame { - requested_url: report.requested_url, - }); - } - - Ok(Self { - loaded_url: report.loaded_url, - title: report.title, - render_state: report.state, - width: report.width, - height: report.height, - #[cfg(test)] - non_white_pixel_count: report.non_white_pixel_count, - #[cfg(test)] - content_pixel_count: report.content_pixel_count, - #[cfg(test)] - sample_hash: report.sample_hash, - rgba_bytes, - }) - } - - #[must_use] - pub fn loaded_url(&self) -> Option<&str> { - self.loaded_url.as_deref() - } - - #[must_use] - pub fn title(&self) -> Option<&str> { - self.title.as_deref() - } - - #[must_use] - pub fn render_state(&self) -> &str { - self.render_state.as_str() - } - - #[must_use] - pub fn width(&self) -> u32 { - self.width - } - - #[must_use] - pub fn height(&self) -> u32 { - self.height - } - - #[cfg(all(test, feature = "live-site-smoke"))] - #[must_use] - pub(crate) fn non_white_pixel_count(&self) -> u64 { - self.non_white_pixel_count - } - - #[cfg(all(test, feature = "live-site-smoke"))] - #[must_use] - pub(crate) fn content_pixel_count(&self) -> u64 { - self.content_pixel_count - } - - #[cfg(all(test, feature = "live-site-smoke"))] - #[must_use] - pub(crate) fn sample_hash(&self) -> u64 { - self.sample_hash - } - - #[must_use] - pub fn into_rgba_bytes(self) -> Vec { - self.rgba_bytes - } -} - -#[derive(Debug, Error)] -pub enum ServoSidecarError { - #[error("current executable path is unavailable: {0}")] - CurrentExecutable(#[source] io::Error), - - #[error("current executable directory is unavailable for {path}")] - CurrentExecutableDirectoryUnavailable { path: PathBuf }, - - #[error("servo sidecar binary is unavailable at {path}")] - SidecarBinaryUnavailable { path: PathBuf }, - - #[error("temporary frame directory is unavailable: {0}")] - TempDirectory(#[source] io::Error), - - #[error("profile data root is unavailable")] - ProfileDataRootUnavailable, - - #[error("system clock is before UNIX epoch")] - SystemClock(#[from] SystemTimeError), - - #[error("failed to run servo sidecar: {0}")] - Command(#[source] io::Error), - - #[error("servo sidecar exited with {status}: {stderr}")] - SidecarFailed { status: String, stderr: String }, - - #[error("servo sidecar timed out after {seconds}s while rendering {url}")] - SidecarTimedOut { url: String, seconds: u64 }, - - #[error("failed to parse servo sidecar report: {0}")] - Report(#[from] serde_json::Error), - - #[error("failed to read servo frame file: {0}")] - FrameRead(#[source] io::Error), - - #[error("failed to remove servo frame file: {0}")] - FrameCleanup(#[source] io::Error), - - #[error("failed to remove transient servo profile data at {path}: {source}")] - ProfileDataCleanup { path: PathBuf, source: io::Error }, - - #[error("servo sidecar returned incomplete render state: {state}")] - IncompleteRender { state: String }, - - #[error("servo sidecar returned profile {actual}, expected {expected}")] - ProfileMismatch { expected: String, actual: String }, - - #[error( - "servo frame byte count mismatch: expected {expected}, reported {reported}, actual {actual}" - )] - RgbaByteCountMismatch { expected: usize, reported: usize, actual: usize }, - - #[error("servo frame dimensions overflow byte count: {width}x{height}")] - RgbaByteCountOverflow { width: u32, height: u32 }, - - #[error("servo rendered a blank frame for {requested_url}")] - BlankRenderedFrame { requested_url: String }, - - #[error("servo rendered a frame without visible content for {requested_url}")] - ContentlessRenderedFrame { requested_url: String }, -} - -fn is_renderable_state(state: &str) -> bool { - matches!(state, "complete" | "loading") -} - -#[derive(Deserialize)] -struct SidecarReport { - requested_url: String, - profile_id: String, - loaded_url: Option, - title: Option, - state: String, - width: u32, - height: u32, - rgba_byte_count: usize, - non_white_pixel_count: u64, - content_pixel_count: u64, - #[cfg(test)] - sample_hash: u64, -} - -fn temporary_rgba_path() -> Result { - let directory = env::temp_dir().join("ely-browser-servo"); - fs::create_dir_all(&directory).map_err(ServoSidecarError::TempDirectory)?; - let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos(); - Ok(directory.join(format!("frame-{}-{timestamp}.rgba", std::process::id()))) -} - -fn remove_temporary_file(path: &Path) -> Result<(), ServoSidecarError> { - match fs::remove_file(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(ServoSidecarError::FrameCleanup(error)), - } -} - -fn remove_temporary_directory(path: &Path) -> Result<(), ServoSidecarError> { - match fs::remove_dir_all(path) { - Ok(()) => Ok(()), - Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()), - Err(error) => { - Err(ServoSidecarError::ProfileDataCleanup { path: path.to_path_buf(), source: error }) - } - } -} - -fn cleanup_failed_snapshot( - request: &SidecarSnapshotRequest, - rgba_path: &Path, - profile_data_dir: &Path, - snapshot_error: ServoSidecarError, -) -> Result { - let frame_cleanup = remove_temporary_file(rgba_path); - let profile_cleanup = request.cleanup_profile_data_dir(profile_data_dir); - match (frame_cleanup, profile_cleanup) { - (Ok(()), Ok(())) => Err(snapshot_error), - (Err(error), _) => Err(error), - (Ok(()), Err(error)) => Err(error), - } -} - -fn terminate_child(mut child: std::process::Child) -> Result<(), ServoSidecarError> { - match child.kill() { - Ok(()) => { - let _output = child.wait_with_output().map_err(ServoSidecarError::Command)?; - Ok(()) - } - Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()), - Err(error) => Err(ServoSidecarError::Command(error)), - } -} - -fn read_sidecar_snapshot( - stdout: &[u8], - rgba_path: &Path, - expected_profile_id: &ProfileId, -) -> Result { - let report: SidecarReport = serde_json::from_slice(stdout)?; - let rgba_bytes = fs::read(rgba_path).map_err(ServoSidecarError::FrameRead)?; - SidecarSnapshot::from_report(report, expected_profile_id, rgba_bytes) -} - -fn expected_rgba_byte_count(width: u32, height: u32) -> Result { - let byte_count = u64::from(width) - .checked_mul(u64::from(height)) - .and_then(|pixels| pixels.checked_mul(4)) - .ok_or(ServoSidecarError::RgbaByteCountOverflow { width, height })?; - usize::try_from(byte_count) - .map_err(|_| ServoSidecarError::RgbaByteCountOverflow { width, height }) -} - -#[cfg(test)] -#[path = "servo_sidecar_tests.rs"] -mod tests; diff --git a/crates/ely_app/src/services/servo_sidecar_command.rs b/crates/ely_app/src/services/servo_sidecar_command.rs index 1bef907..79dd687 100644 --- a/crates/ely_app/src/services/servo_sidecar_command.rs +++ b/crates/ely_app/src/services/servo_sidecar_command.rs @@ -1,14 +1,11 @@ use std::{ - env, + env, io, path::{Path, PathBuf}, process::Command, - time::Duration, }; -use super::servo_sidecar::ServoSidecarError; +use thiserror::Error; -const SIDECAR_BINARY_TIMEOUT: Duration = Duration::from_secs(45); -const SIDECAR_CARGO_TIMEOUT: Duration = Duration::from_secs(180); const SIDECAR_PATH_ENV: &str = "ELY_SERVO_SIDECAR"; #[derive(Clone, Debug)] @@ -40,13 +37,6 @@ impl SidecarCommandTarget { } } - pub(super) fn timeout(&self) -> Duration { - match self { - Self::Binary(_) => SIDECAR_BINARY_TIMEOUT, - Self::Cargo { .. } => SIDECAR_CARGO_TIMEOUT, - } - } - pub(super) fn missing_binary_path(&self) -> Option<&Path> { match self { Self::Binary(path) if !path.is_file() => Some(path.as_path()), @@ -55,14 +45,23 @@ impl SidecarCommandTarget { } } -pub(super) fn default_sidecar_command() -> Result { +#[derive(Debug, Error)] +pub(crate) enum SidecarCommandError { + #[error("current executable path is unavailable: {0}")] + CurrentExecutable(#[source] io::Error), + + #[error("current executable directory is unavailable for {path}")] + CurrentExecutableDirectoryUnavailable { path: PathBuf }, +} + +pub(super) fn default_sidecar_command() -> Result { if let Some(path) = env::var_os(SIDECAR_PATH_ENV) { return Ok(SidecarCommandTarget::Binary(PathBuf::from(path))); } - let current_exe = env::current_exe().map_err(ServoSidecarError::CurrentExecutable)?; + let current_exe = env::current_exe().map_err(SidecarCommandError::CurrentExecutable)?; let exe_dir = current_exe.parent().ok_or_else(|| { - ServoSidecarError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() } + SidecarCommandError::CurrentExecutableDirectoryUnavailable { path: current_exe.clone() } })?; let adjacent_sidecar = exe_dir.join(sidecar_binary_name()); if adjacent_sidecar.is_file() { diff --git a/crates/ely_app/src/services/servo_sidecar_request.rs b/crates/ely_app/src/services/servo_sidecar_request.rs deleted file mode 100644 index 2807711..0000000 --- a/crates/ely_app/src/services/servo_sidecar_request.rs +++ /dev/null @@ -1,145 +0,0 @@ -use ely_domain::{ - DEFAULT_ZOOM_PERCENT, ProfileId, SiteOrigin, SitePermissionDecision, SitePermissionFeature, - UrlText, -}; - -use super::ProfileDataMode; - -#[derive(Clone, Debug)] -pub struct SidecarSnapshotRequest { - pub(in crate::services) url: UrlText, - pub(in crate::services) profile_id: ProfileId, - pub(in crate::services) profile_data_mode: ProfileDataMode, - pub(in crate::services) width: u32, - pub(in crate::services) height: u32, - pub(in crate::services) scroll_x: i32, - pub(in crate::services) scroll_y: i32, - pub(in crate::services) page_zoom_percent: u16, - pub(in crate::services) click_point: Option, - pub(in crate::services) typed_text: Option, - pub(in crate::services) site_permissions: Vec, -} - -impl SidecarSnapshotRequest { - #[must_use] - pub fn new(url: UrlText, profile_id: ProfileId, width: u32, height: u32) -> Self { - Self { - url, - profile_id, - profile_data_mode: ProfileDataMode::Persistent, - width, - height, - scroll_x: 0, - scroll_y: 0, - page_zoom_percent: DEFAULT_ZOOM_PERCENT, - click_point: None, - typed_text: None, - site_permissions: Vec::new(), - } - } - - #[must_use] - pub fn with_profile_data_mode(mut self, profile_data_mode: ProfileDataMode) -> Self { - self.profile_data_mode = profile_data_mode; - self - } - - #[must_use] - pub fn with_scroll_offset(mut self, scroll_x: i32, scroll_y: i32) -> Self { - self.scroll_x = scroll_x; - self.scroll_y = scroll_y; - self - } - - #[must_use] - pub fn with_page_zoom_percent(mut self, page_zoom_percent: u16) -> Self { - self.page_zoom_percent = page_zoom_percent; - self - } - - #[must_use] - pub fn with_click_point(mut self, x: u32, y: u32) -> Self { - self.click_point = Some(SidecarClickPoint { x, y }); - self - } - - #[must_use] - pub fn with_typed_text(mut self, typed_text: String) -> Self { - self.typed_text = Some(typed_text); - self - } - - #[must_use] - pub fn with_site_permissions(mut self, site_permissions: Vec) -> Self { - self.site_permissions = site_permissions; - self - } - - #[cfg(test)] - pub(crate) fn typed_text_for_test(&self) -> Option<&str> { - self.typed_text.as_deref() - } - - #[cfg(test)] - pub(crate) fn profile_id_for_test(&self) -> &ProfileId { - &self.profile_id - } - - #[cfg(test)] - pub(crate) fn profile_data_mode_for_test(&self) -> ProfileDataMode { - self.profile_data_mode - } - - #[cfg(test)] - pub(crate) fn page_zoom_percent_for_test(&self) -> u16 { - self.page_zoom_percent - } -} - -#[derive(Clone, Copy, Debug)] -pub(in crate::services) struct SidecarClickPoint { - pub(in crate::services) x: u32, - pub(in crate::services) y: u32, -} - -#[derive(Clone, Debug, Eq, PartialEq)] -pub struct SidecarSitePermission { - origin: SiteOrigin, - feature: SitePermissionFeature, - decision: SitePermissionDecision, -} - -impl SidecarSitePermission { - #[must_use] - pub fn new( - origin: SiteOrigin, - feature: SitePermissionFeature, - decision: SitePermissionDecision, - ) -> Self { - Self { origin, feature, decision } - } - - pub(in crate::services) fn to_arg(&self) -> String { - serde_json::json!({ - "origin": self.origin.as_str(), - "feature": self.feature.as_str(), - "decision": self.decision.as_str(), - }) - .to_string() - } - - #[cfg(test)] - pub(crate) fn origin(&self) -> &SiteOrigin { - &self.origin - } - - #[cfg(test)] - pub(crate) fn feature(&self) -> SitePermissionFeature { - self.feature - } - - #[cfg(test)] - pub(crate) fn decision(&self) -> SitePermissionDecision { - self.decision - } -} diff --git a/crates/ely_app/src/services/servo_sidecar_tests.rs b/crates/ely_app/src/services/servo_sidecar_tests.rs deleted file mode 100644 index bfbe4e8..0000000 --- a/crates/ely_app/src/services/servo_sidecar_tests.rs +++ /dev/null @@ -1,260 +0,0 @@ -use std::error::Error; - -use ely_domain::UrlText; - -use super::*; - -#[cfg(feature = "live-site-smoke")] -use crate::services::prd_live_sites::{ - LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES, - assert_prd_reference_urls_are_covered, -}; - -#[cfg(feature = "live-site-smoke")] -const LIVE_SITE_RENDER_ATTEMPTS: usize = 3; -#[cfg(feature = "live-site-smoke")] -const LIVE_SITE_WIDTH: u32 = 934; -#[cfg(feature = "live-site-smoke")] -const LIVE_SITE_HEIGHT: u32 = 657; -#[cfg(feature = "live-site-smoke")] -const MINIMUM_CONTENT_PIXELS: u64 = 1_000; - -#[test] -fn accepts_loading_report_with_visible_content() -> Result<(), ServoSidecarError> { - let profile_id = ProfileId::new(); - let snapshot = SidecarSnapshot::from_report( - report_with_state("loading", &profile_id), - &profile_id, - visible_frame(), - )?; - - assert_eq!(snapshot.loaded_url(), Some("https://example.com/")); - assert_eq!(snapshot.title(), Some("Example Domain")); - assert_eq!(snapshot.render_state(), "loading"); - assert_eq!(snapshot.width(), 2); - assert_eq!(snapshot.height(), 1); - assert_eq!(snapshot.non_white_pixel_count, 1); - assert_eq!(snapshot.content_pixel_count, 1); - assert_eq!(snapshot.sample_hash, 42); - Ok(()) -} - -#[test] -fn rejects_created_report_with_visible_content() { - let profile_id = ProfileId::new(); - let result = SidecarSnapshot::from_report( - report_with_state("created", &profile_id), - &profile_id, - visible_frame(), - ); - - assert!( - matches!(result, Err(ServoSidecarError::IncompleteRender { state }) if state == "created") - ); -} - -#[test] -fn rejects_report_from_different_profile() { - let expected_profile_id = ProfileId::new(); - let actual_profile_id = ProfileId::new(); - let result = SidecarSnapshot::from_report( - report_with_state("loading", &actual_profile_id), - &expected_profile_id, - visible_frame(), - ); - - assert!(matches!(result, Err(ServoSidecarError::ProfileMismatch { expected, actual }) - if expected == expected_profile_id.as_str() && actual == actual_profile_id.as_str())); -} - -#[test] -fn retries_navigation_snapshots() -> Result<(), Box> { - let request = - SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, ProfileId::new(), 2, 1); - - assert_eq!(request.max_attempts(), SIDECAR_NAVIGATION_ATTEMPTS); - Ok(()) -} - -#[test] -fn keeps_page_interactions_single_attempt() -> Result<(), Box> { - let request = - SidecarSnapshotRequest::new(UrlText::parse("https://example.com")?, ProfileId::new(), 2, 1) - .with_click_point(1, 1) - .with_typed_text("ely".to_string()); - - assert_eq!(request.max_attempts(), SIDECAR_INTERACTION_ATTEMPTS); - Ok(()) -} - -#[test] -fn persistent_profile_data_uses_profile_root() -> Result<(), Box> { - let profile_id = ProfileId::new(); - let request = SidecarSnapshotRequest::new( - UrlText::parse("https://example.com")?, - profile_id.clone(), - 2, - 1, - ); - let root = std::env::temp_dir().join("ely-browser-profile-root-test"); - - assert_eq!(request.profile_data_mode_for_test(), ProfileDataMode::Persistent); - assert_eq!(request.profile_data_dir(&root)?, root.join(profile_id.as_str()).join("servo")); - Ok(()) -} - -#[test] -fn transient_profile_data_uses_temporary_directory_and_cleans_up() -> Result<(), Box> { - let profile_id = ProfileId::new(); - let request = SidecarSnapshotRequest::new( - UrlText::parse("https://example.com")?, - profile_id.clone(), - 2, - 1, - ) - .with_profile_data_mode(ProfileDataMode::Transient); - let root = std::env::temp_dir().join("ely-browser-profile-root-test"); - let profile_data_dir = request.profile_data_dir(&root)?; - - assert!(profile_data_dir.starts_with(std::env::temp_dir().join("ely-browser-servo-profiles"))); - assert!(profile_data_dir.to_string_lossy().contains(profile_id.as_str())); - std::fs::create_dir_all(&profile_data_dir)?; - std::fs::write(profile_data_dir.join("probe"), b"private")?; - - request.cleanup_profile_data_dir(&profile_data_dir)?; - - assert!(!profile_data_dir.exists()); - Ok(()) -} - -#[cfg(feature = "live-site-smoke")] -#[test] -fn desktop_sidecar_opens_prd_top_sites() -> Result<(), Box> { - assert_live_sites_render(PRD_TOP_SITE_CASES) -} - -#[cfg(feature = "live-site-smoke")] -#[test] -fn desktop_sidecar_opens_prd_reference_sites() -> Result<(), Box> { - assert_live_sites_render(PRD_REFERENCE_SITE_CASES) -} - -#[cfg(feature = "live-site-smoke")] -#[test] -fn prd_reference_live_site_cases_cover_prd_urls() -> Result<(), Box> { - assert_prd_reference_urls_are_covered() -} - -#[cfg(feature = "live-site-smoke")] -fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box> { - let client = ServoSidecarClient::new()?; - for case in cases { - let snapshot = render_live_site_snapshot(&client, case)?; - let rgba_bytes = snapshot.into_rgba_bytes(); - assert_eq!( - rgba_bytes.len(), - expected_rgba_byte_count(LIVE_SITE_WIDTH, LIVE_SITE_HEIGHT)?, - "{}", - case.url - ); - } - Ok(()) -} - -#[cfg(feature = "live-site-smoke")] -fn render_live_site_snapshot( - client: &ServoSidecarClient, - case: &LiveSiteCase, -) -> Result> { - let mut last_error = String::new(); - - for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS { - let request = SidecarSnapshotRequest::new( - UrlText::parse(case.url)?, - ProfileId::new(), - LIVE_SITE_WIDTH, - LIVE_SITE_HEIGHT, - ); - - match client.snapshot(request) { - Ok(snapshot) => match validate_live_site_snapshot(&snapshot, case) { - Ok(()) => return Ok(snapshot), - Err(error) => last_error = error, - }, - Err(error) => last_error = error.to_string(), - } - - if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS { - std::thread::sleep(std::time::Duration::from_millis(250)); - } - } - - Err(last_error.into()) -} - -#[cfg(feature = "live-site-smoke")] -fn validate_live_site_snapshot( - snapshot: &SidecarSnapshot, - case: &LiveSiteCase, -) -> Result<(), String> { - require( - snapshot.width() == LIVE_SITE_WIDTH, - format!("{} width: {}", case.url, snapshot.width()), - )?; - require( - snapshot.height() == LIVE_SITE_HEIGHT, - format!("{} height: {}", case.url, snapshot.height()), - )?; - require_render_state_is_open(snapshot.render_state(), case.url)?; - require_loaded_url_contains(snapshot, case.url)?; - require_title_contains(snapshot, case.title_fragment)?; - require(snapshot.non_white_pixel_count > 0, case.url.to_string())?; - require( - snapshot.content_pixel_count >= MINIMUM_CONTENT_PIXELS, - format!("{} content pixels: {}", case.url, snapshot.content_pixel_count), - )?; - require(snapshot.sample_hash > 0, case.url.to_string()) -} - -#[cfg(feature = "live-site-smoke")] -fn require_render_state_is_open(state: &str, url: &str) -> Result<(), String> { - require(matches!(state, "complete" | "loading"), format!("{url} state: {state}")) -} - -#[cfg(feature = "live-site-smoke")] -fn require_loaded_url_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> { - let loaded_url = - snapshot.loaded_url().ok_or_else(|| format!("missing loaded URL for {fragment}"))?; - require(loaded_url.contains(fragment), format!("loaded_url: {loaded_url}")) -} - -#[cfg(feature = "live-site-smoke")] -fn require_title_contains(snapshot: &SidecarSnapshot, fragment: &str) -> Result<(), String> { - let title = snapshot.title().ok_or_else(|| format!("missing title containing {fragment}"))?; - require(title.contains(fragment), format!("title: {title}")) -} - -#[cfg(feature = "live-site-smoke")] -fn require(condition: bool, message: String) -> Result<(), String> { - if condition { Ok(()) } else { Err(message) } -} - -fn report_with_state(state: &str, profile_id: &ProfileId) -> SidecarReport { - SidecarReport { - requested_url: "https://example.com".to_string(), - profile_id: profile_id.as_str().to_string(), - loaded_url: Some("https://example.com/".to_string()), - title: Some("Example Domain".to_string()), - state: state.to_string(), - width: 2, - height: 1, - rgba_byte_count: 8, - non_white_pixel_count: 1, - content_pixel_count: 1, - sample_hash: 42, - } -} - -fn visible_frame() -> Vec { - vec![0, 0, 0, 255, 255, 255, 255, 255] -} diff --git a/crates/ely_app/src/shell/mod.rs b/crates/ely_app/src/shell/mod.rs index 8082829..fff6bd5 100644 --- a/crates/ely_app/src/shell/mod.rs +++ b/crates/ely_app/src/shell/mod.rs @@ -26,15 +26,17 @@ mod web_surface; mod web_surface_controller; mod web_surface_frame; mod web_surface_geometry; -mod web_surface_image; mod web_surface_keyboard; mod web_surface_permissions; +mod web_surface_runtime; mod web_surface_state; mod web_surface_view; +use std::time::Duration; + use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ProfileId, SpaceId, TabId}; -use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Window}; +use gpui::{AppContext, Context, Entity, FocusHandle, Subscription, Timer, Window}; use gpui_component::input::{InputEvent, InputState}; use crate::shortcuts::ShortcutProfile; @@ -144,7 +146,7 @@ impl ElyShell { Err(error) => ShellState::StartupError(error.to_string()), }; - Self { + let shell = Self { state, focus_handle: cx.focus_handle(), command_input, @@ -172,7 +174,9 @@ impl ElyShell { pending_plugin_uninstall: None, web_surfaces: WebSurfaceStore::new(), _command_subscription: command_subscription, - } + }; + start_external_web_surface_timer(cx); + shell } fn focus_command_mode(&mut self, window: &mut Window, cx: &mut Context) { @@ -432,3 +436,20 @@ impl ElyShell { self.zoom_active_tab_out(cx); } } + +fn start_external_web_surface_timer(cx: &mut Context) { + cx.spawn(async move |shell, cx| { + loop { + Timer::after(Duration::from_millis(16)).await; + let result = shell.update(cx, |shell, cx| { + if shell.tick_external_web_surfaces() { + cx.notify(); + } + }); + if result.is_err() { + break; + } + } + }) + .detach(); +} diff --git a/crates/ely_app/src/shell/web_surface_frame.rs b/crates/ely_app/src/shell/web_surface_frame.rs index b661d85..533041b 100644 --- a/crates/ely_app/src/shell/web_surface_frame.rs +++ b/crates/ely_app/src/shell/web_surface_frame.rs @@ -4,14 +4,14 @@ use gpui::RenderImage; use image::{ImageBuffer, Rgba}; use thiserror::Error; -use crate::services::servo_sidecar::SidecarSnapshot; +use crate::services::servo_live::ServoLiveFrame; -use super::{ - web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset, WebSurfaceSize}, - web_surface_image::renderable_image_buffer, -}; +#[cfg(all(test, feature = "live-site-smoke"))] +use super::web_surface_geometry::WebSurfaceSize; +use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset}; #[derive(Clone)] +#[cfg_attr(not(all(test, feature = "live-site-smoke")), allow(dead_code))] pub(super) struct WebSurfaceFrame { pub(super) requested_url: String, loaded_url: Option, @@ -33,62 +33,75 @@ pub(super) struct WebSurfaceFrame { } impl WebSurfaceFrame { - pub(super) fn from_snapshot( + pub(super) fn from_live_frame( requested_url: String, scroll_offset: WebSurfaceScrollOffset, zoom_percent: u16, - click_point: Option, - typed_text: Option, - snapshot: SidecarSnapshot, + frame: ServoLiveFrame, ) -> Result { - let width = snapshot.width(); - let height = snapshot.height(); - let loaded_url = snapshot.loaded_url().map(str::to_string); - let title = snapshot.title().map(str::to_string); - let render_state = snapshot.render_state().to_string(); - #[cfg(all(test, feature = "live-site-smoke"))] - let non_white_pixel_count = snapshot.non_white_pixel_count(); - #[cfg(all(test, feature = "live-site-smoke"))] - let content_pixel_count = snapshot.content_pixel_count(); - #[cfg(all(test, feature = "live-site-smoke"))] - let sample_hash = snapshot.sample_hash(); - let rgba_bytes = snapshot.into_rgba_bytes(); - - let Some(buffer) = ImageBuffer::, _>::from_raw(width, height, rgba_bytes) else { - return Err(WebSurfaceError::InvalidFrameBuffer { width, height }); - }; - - let image_buffer = renderable_image_buffer(buffer); - - Ok(Self { + Self::from_parts(WebSurfaceFrameParts { requested_url, - loaded_url, - title, - render_state, - width, - height, + loaded_url: frame.loaded_url().map(str::to_string), + title: frame.title().map(str::to_string), + render_state: frame.render_state().to_string(), + width: frame.width(), + height: frame.height(), scroll_offset, zoom_percent, - click_point, - typed_text, + click_point: None, + typed_text: None, #[cfg(all(test, feature = "live-site-smoke"))] - non_white_pixel_count, + non_white_pixel_count: frame.non_white_pixel_count(), #[cfg(all(test, feature = "live-site-smoke"))] - content_pixel_count, + content_pixel_count: frame.content_pixel_count(), #[cfg(all(test, feature = "live-site-smoke"))] - sample_hash, + sample_hash: frame.sample_hash(), + rgba_bytes: frame.into_rgba_bytes(), + }) + } + + fn from_parts(parts: WebSurfaceFrameParts) -> Result { + let Some(image_buffer) = + ImageBuffer::, _>::from_raw(parts.width, parts.height, parts.rgba_bytes) + else { + return Err(WebSurfaceError::InvalidFrameBuffer { + width: parts.width, + height: parts.height, + }); + }; + + Ok(Self { + requested_url: parts.requested_url, + loaded_url: parts.loaded_url, + title: parts.title, + render_state: parts.render_state, + width: parts.width, + height: parts.height, + scroll_offset: parts.scroll_offset, + zoom_percent: parts.zoom_percent, + click_point: parts.click_point, + typed_text: parts.typed_text, + #[cfg(all(test, feature = "live-site-smoke"))] + non_white_pixel_count: parts.non_white_pixel_count, + #[cfg(all(test, feature = "live-site-smoke"))] + content_pixel_count: parts.content_pixel_count, + #[cfg(all(test, feature = "live-site-smoke"))] + sample_hash: parts.sample_hash, image: Arc::new(RenderImage::new([image::Frame::new(image_buffer)])), }) } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn title_label(&self) -> String { self.title.clone().unwrap_or_else(|| self.requested_url.clone()) } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn url_label(&self) -> &str { self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str()) } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn detail_label(&self) -> String { let mut detail = format!("{} {}", self.render_state(), self.scroll_offset.detail_label(self.size())); @@ -104,14 +117,17 @@ impl WebSurfaceFrame { } } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn size(&self) -> WebSurfaceSize { WebSurfaceSize { width: self.width, height: self.height } } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn render_state(&self) -> &str { self.render_state.as_str() } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn scroll_offset(&self) -> WebSurfaceScrollOffset { self.scroll_offset } @@ -120,10 +136,12 @@ impl WebSurfaceFrame { self.zoom_percent } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn click_point(&self) -> Option { self.click_point } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn typed_text(&self) -> Option<&str> { self.typed_text.as_deref() } @@ -144,6 +162,26 @@ impl WebSurfaceFrame { } } +struct WebSurfaceFrameParts { + requested_url: String, + loaded_url: Option, + title: Option, + render_state: String, + width: u32, + height: u32, + scroll_offset: WebSurfaceScrollOffset, + zoom_percent: u16, + click_point: Option, + typed_text: Option, + #[cfg(all(test, feature = "live-site-smoke"))] + non_white_pixel_count: u64, + #[cfg(all(test, feature = "live-site-smoke"))] + content_pixel_count: u64, + #[cfg(all(test, feature = "live-site-smoke"))] + sample_hash: u64, + rgba_bytes: Vec, +} + #[derive(Debug, Error)] pub(super) enum WebSurfaceError { #[error("invalid servo frame buffer for {width}x{height}")] diff --git a/crates/ely_app/src/shell/web_surface_geometry.rs b/crates/ely_app/src/shell/web_surface_geometry.rs index 8cb7842..b089b86 100644 --- a/crates/ely_app/src/shell/web_surface_geometry.rs +++ b/crates/ely_app/src/shell/web_surface_geometry.rs @@ -1,6 +1,6 @@ use gpui::{Bounds, Pixels, Point}; -#[derive(Clone, Copy, Debug, Eq, PartialEq)] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] pub(super) struct WebSurfaceSize { pub(super) width: u32, pub(super) height: u32, @@ -32,6 +32,7 @@ impl WebSurfaceClickPoint { }) } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn detail_label(self) -> String { format!("click={},{}", self.x, self.y) } @@ -59,6 +60,7 @@ impl WebSurfaceScrollOffset { } } + #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn detail_label(self, size: WebSurfaceSize) -> String { match (self.x, self.y) { (0, 0) => format!("{}x{}", size.width, size.height), @@ -67,10 +69,7 @@ impl WebSurfaceScrollOffset { } } - pub(super) fn x(self) -> i32 { - self.x - } - + #[cfg_attr(not(test), allow(dead_code))] pub(super) fn y(self) -> i32 { self.y } @@ -92,6 +91,18 @@ impl WebSurfaceScrollDelta { Some(Self { x, y }) } + + pub(super) fn x(self) -> i32 { + self.x + } + + pub(super) fn y(self) -> i32 { + self.y + } + + pub(super) fn combined_with(self, next: Self) -> Self { + Self { x: combined_scroll_delta(self.x, next.x), y: combined_scroll_delta(self.y, next.y) } + } } fn viewport_dimension(pixels: Pixels) -> Option { @@ -133,3 +144,8 @@ fn positive_scroll_component(current: i32, delta: i32) -> i32 { let clamped = value.clamp(0, i64::from(i32::MAX)); clamped as i32 } + +fn combined_scroll_delta(current: i32, next: i32) -> i32 { + let value = i64::from(current) + i64::from(next); + value.clamp(i64::from(i32::MIN), i64::from(i32::MAX)) as i32 +} diff --git a/crates/ely_app/src/shell/web_surface_image.rs b/crates/ely_app/src/shell/web_surface_image.rs deleted file mode 100644 index 22a8cb5..0000000 --- a/crates/ely_app/src/shell/web_surface_image.rs +++ /dev/null @@ -1,28 +0,0 @@ -use image::{ImageBuffer, Rgba, imageops::FilterType}; - -const WEB_SURFACE_IMAGE_MAX_EDGE: u32 = 1024; - -pub(super) fn renderable_image_buffer( - buffer: ImageBuffer, Vec>, -) -> ImageBuffer, Vec> { - let largest_edge = buffer.width().max(buffer.height()); - if largest_edge <= WEB_SURFACE_IMAGE_MAX_EDGE { - return buffer; - } - - image::imageops::resize( - &buffer, - scaled_image_dimension(buffer.width(), largest_edge), - scaled_image_dimension(buffer.height(), largest_edge), - FilterType::Triangle, - ) -} - -fn scaled_image_dimension(dimension: u32, largest_edge: u32) -> u32 { - let numerator = u64::from(dimension) * u64::from(WEB_SURFACE_IMAGE_MAX_EDGE); - let rounded = (numerator + u64::from(largest_edge / 2)) / u64::from(largest_edge); - match u32::try_from(rounded.max(1)) { - Ok(value) => value, - Err(_) => WEB_SURFACE_IMAGE_MAX_EDGE, - } -} diff --git a/crates/ely_app/src/shell/web_surface_live_site_tests.rs b/crates/ely_app/src/shell/web_surface_live_site_tests.rs index 09ddc77..b177815 100644 --- a/crates/ely_app/src/shell/web_surface_live_site_tests.rs +++ b/crates/ely_app/src/shell/web_surface_live_site_tests.rs @@ -1,13 +1,21 @@ -use std::error::Error; +use std::{ + env, + error::Error, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText}; use gpui::{Bounds, point, px, size}; use crate::{ - services::ProfileDataMode, - services::prd_live_sites::{ - LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES, - assert_prd_reference_urls_are_covered, + services::{ + ProfileDataMode, + prd_live_sites::{ + LiveSiteCase, PRD_REFERENCE_SITE_CASES, PRD_TOP_SITE_CASES, + assert_prd_reference_urls_are_covered, + }, }, shell::{ web_surface_frame::WebSurfaceFrame, @@ -22,6 +30,9 @@ const LIVE_SURFACE_WIDTH: u32 = 934; const LIVE_SURFACE_HEIGHT: u32 = 657; const MINIMUM_CONTENT_PIXELS: u64 = 1_000; const LIVE_SITE_RENDER_ATTEMPTS: usize = 3; +const LIVE_SITE_WAIT_TIMEOUT: Duration = Duration::from_secs(20); +const LIVE_SITE_WAIT_INTERVAL: Duration = Duration::from_millis(2); +const LIVE_SITE_CHILD_ENV: &str = "ELY_APP_WEB_SURFACE_LIVE_CHILD"; #[test] fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box> { @@ -30,63 +41,110 @@ fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box> { #[test] fn web_surface_opens_and_renders_prd_top_sites() -> Result<(), Box> { - assert_web_surfaces_render(PRD_TOP_SITE_CASES) + run_isolated_live_site_test("web_surface_opens_and_renders_prd_top_sites", || { + assert_web_surfaces_render(PRD_TOP_SITE_CASES) + }) } #[test] fn web_surface_opens_and_renders_prd_reference_sites() -> Result<(), Box> { - assert_web_surfaces_render(PRD_REFERENCE_SITE_CASES) + run_isolated_live_site_test("web_surface_opens_and_renders_prd_reference_sites", || { + assert_web_surfaces_render(PRD_REFERENCE_SITE_CASES) + }) +} + +fn run_isolated_live_site_test( + test_name: &str, + test: impl FnOnce() -> Result<(), Box>, +) -> Result<(), Box> { + if env::var_os(LIVE_SITE_CHILD_ENV).is_some() { + return test(); + } + + let output = Command::new(env::current_exe()?) + .arg(test_name) + .env(LIVE_SITE_CHILD_ENV, "1") + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output()?; + + if output.status.success() { + return Ok(()); + } + + Err(format!( + "isolated web surface live-site test failed\nstatus: {}\nstdout: {}\nstderr: {}", + output.status, + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .into()) } fn assert_web_surfaces_render(cases: &[LiveSiteCase]) -> Result<(), Box> { + let mut store = WebSurfaceStore::new(); + let profile_id = ProfileId::new(); + for case in cases { - let frame = render_web_surface_frame(case)?; + let frame = render_web_surface_frame(&mut store, &profile_id, case)?; log_prd_frame("web-surface", &frame, case); } Ok(()) } -fn render_web_surface_frame(case: &LiveSiteCase) -> Result> { +fn render_web_surface_frame( + store: &mut WebSurfaceStore, + profile_id: &ProfileId, + case: &LiveSiteCase, +) -> Result> { let mut last_error = String::new(); for attempt in 0..LIVE_SITE_RENDER_ATTEMPTS { - let mut store = WebSurfaceStore::new(); - let tab = web_tab(case.url)?; + let tab = web_tab(profile_id.clone(), case.url)?; assert!(store.record_viewport_size(tab.id(), live_surface_bounds()), "{}", case.url); - let request = store - .prepare_request(&tab, ProfileDataMode::Persistent) - .ok_or_else(|| format!("missing web surface request for {}", case.url))?; - let tab_id = request.tab_id.clone(); - let snapshot = request.client.snapshot(request.snapshot_request)?; - let frame = WebSurfaceFrame::from_snapshot( - request.requested_url, - request.scroll_offset, - request.zoom_percent, - request.click_point, - request.typed_text, - snapshot, - )?; + store.ensure_surface(&tab, ProfileDataMode::Transient, &[]); - match validate_prd_frame(&frame, case) { - Ok(()) => { - store.finish(tab_id, WebSurfaceState::Ready(frame.clone())); - let Some(WebSurfaceState::Ready(stored_frame)) = store.state(tab.id()) else { - return Err(format!("web surface state is not ready for {}", case.url).into()); - }; - validate_prd_frame(stored_frame, case)?; - return Ok(frame); - } + match wait_for_ready_frame(store, tab.id(), case) { + Ok(frame) => return Ok(frame), Err(error) => last_error = error, } if attempt + 1 < LIVE_SITE_RENDER_ATTEMPTS { - std::thread::sleep(std::time::Duration::from_millis(250)); + thread::sleep(Duration::from_millis(250)); } } Err(last_error.into()) } +fn wait_for_ready_frame( + store: &mut WebSurfaceStore, + tab_id: &TabId, + case: &LiveSiteCase, +) -> Result { + let started_at = Instant::now(); + + loop { + if started_at.elapsed() >= LIVE_SITE_WAIT_TIMEOUT { + return Err(format!("timed out rendering {}", case.url)); + } + + store.tick(); + match store.state(tab_id) { + Some(WebSurfaceState::Ready(frame)) => { + validate_prd_frame(frame, case)?; + return Ok(frame.clone()); + } + Some(WebSurfaceState::Failed { message, .. }) => { + return Err(format!("{} failed: {message}", case.url)); + } + Some(WebSurfaceState::Loading { .. }) | None => {} + } + + thread::sleep(LIVE_SITE_WAIT_INTERVAL); + } +} + fn validate_prd_frame(frame: &WebSurfaceFrame, case: &LiveSiteCase) -> Result<(), String> { require( frame.size() == WebSurfaceSize { width: LIVE_SURFACE_WIDTH, height: LIVE_SURFACE_HEIGHT }, @@ -147,8 +205,8 @@ fn live_surface_bounds() -> Bounds { ) } -fn web_tab(url: &str) -> Result> { - Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?)) +fn web_tab(profile_id: ProfileId, url: &str) -> Result> { + Ok(BrowserTab::new(TabId::new(), SpaceId::new(), profile_id, "Web", UrlText::parse(url)?)) } fn normalized_url(url: &str) -> &str { diff --git a/crates/ely_app/src/shell/web_surface_permissions.rs b/crates/ely_app/src/shell/web_surface_permissions.rs index ff264a0..603d141 100644 --- a/crates/ely_app/src/shell/web_surface_permissions.rs +++ b/crates/ely_app/src/shell/web_surface_permissions.rs @@ -1,12 +1,39 @@ use ely_browser_core::BrowserSnapshot; -use ely_domain::{BrowserTab, SiteOrigin}; +use ely_domain::{BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature}; -use crate::services::servo_sidecar::SidecarSitePermission; +#[derive(Clone, Debug, Eq, PartialEq)] +pub(super) struct WebSurfaceSitePermission { + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, +} -pub(super) fn sidecar_site_permissions_for_tab( +impl WebSurfaceSitePermission { + pub(super) fn new( + origin: SiteOrigin, + feature: SitePermissionFeature, + decision: SitePermissionDecision, + ) -> Self { + Self { origin, feature, decision } + } + + pub(super) fn origin(&self) -> &SiteOrigin { + &self.origin + } + + pub(super) fn feature(&self) -> SitePermissionFeature { + self.feature + } + + pub(super) fn decision(&self) -> SitePermissionDecision { + self.decision + } +} + +pub(super) fn web_surface_site_permissions_for_tab( tab: &BrowserTab, snapshot: &BrowserSnapshot, -) -> Vec { +) -> Vec { let Ok(Some(origin)) = SiteOrigin::from_url(tab.url()) else { return Vec::new(); }; @@ -17,7 +44,7 @@ pub(super) fn sidecar_site_permissions_for_tab( .filter(|entry| entry.profile_id() == tab.profile_id()) .filter(|entry| entry.origin() == &origin) .map(|entry| { - SidecarSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision()) + WebSurfaceSitePermission::new(entry.origin().clone(), entry.feature(), entry.decision()) }) .collect() } @@ -31,7 +58,7 @@ mod tests { BrowserTab, SiteOrigin, SitePermissionDecision, SitePermissionFeature, UrlText, }; - use super::sidecar_site_permissions_for_tab; + use super::web_surface_site_permissions_for_tab; #[test] fn includes_matching_profile_and_origin_permissions() -> Result<(), Box> { @@ -45,7 +72,7 @@ mod tests { let snapshot = core.snapshot()?; let tab = active_tab(&snapshot)?; - let permissions = sidecar_site_permissions_for_tab(tab, &snapshot); + let permissions = web_surface_site_permissions_for_tab(tab, &snapshot); assert_eq!(permissions.len(), 1); assert_eq!(permissions[0].origin().as_str(), "https://example.com"); @@ -66,7 +93,7 @@ mod tests { let snapshot = core.snapshot()?; let tab = active_tab(&snapshot)?; - assert!(sidecar_site_permissions_for_tab(tab, &snapshot).is_empty()); + assert!(web_surface_site_permissions_for_tab(tab, &snapshot).is_empty()); Ok(()) } diff --git a/crates/ely_app/src/shell/web_surface_tests.rs b/crates/ely_app/src/shell/web_surface_tests.rs index b893c71..c8b7d8a 100644 --- a/crates/ely_app/src/shell/web_surface_tests.rs +++ b/crates/ely_app/src/shell/web_surface_tests.rs @@ -3,68 +3,60 @@ use std::error::Error; use ely_domain::{BrowserTab, ProfileId, SpaceId, TabId, UrlText}; use gpui::{Bounds, point, px, size}; -use super::{ProfileDataMode, WebSurfaceStore}; +use super::WebSurfaceStore; #[test] -fn typed_text_enters_snapshot_request_after_clicked_viewport() -> Result<(), Box> { +fn typed_text_enters_pending_input_after_clicked_viewport() -> Result<(), Box> { let mut store = WebSurfaceStore::new(); let tab = web_tab("https://example.com/form")?; - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); - assert!(store.record_viewport_size(tab.id(), bounds)); + assert!(store.record_viewport_size(tab.id(), web_bounds())); assert!(store.record_click_point(tab.id(), tab.url().as_str(), point(px(160.0), px(120.0)))); assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "e")); assert!(store.record_typed_text(tab.id(), tab.url().as_str(), "l")); - let request = store - .prepare_request(&tab, ProfileDataMode::Persistent) - .ok_or("missing web surface request")?; + let input = store.take_pending_input(tab.id(), tab.url().as_str()); - assert_eq!(request.typed_text.as_deref(), Some("el")); - assert_eq!(request.snapshot_request.typed_text_for_test(), Some("el")); - assert_eq!(request.snapshot_request.profile_id_for_test(), tab.profile_id()); - assert_eq!(request.zoom_percent, ely_domain::DEFAULT_ZOOM_PERCENT); - assert_eq!( - request.snapshot_request.page_zoom_percent_for_test(), - ely_domain::DEFAULT_ZOOM_PERCENT - ); + assert_eq!(input.click_point.map(|point| (point.x(), point.y())), Some((160, 120))); + assert_eq!(input.typed_text.as_deref(), Some("el")); Ok(()) } #[test] -fn private_profile_enters_transient_snapshot_request() -> Result<(), Box> { +fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box> { let mut store = WebSurfaceStore::new(); - let tab = web_tab("https://example.com/private")?; + let tab = web_tab("https://example.com/list")?; - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); - assert!(store.record_viewport_size(tab.id(), bounds)); + assert!(store.record_viewport_size(tab.id(), web_bounds())); + assert!(store.record_scroll_delta(tab.id(), tab.url().as_str(), point(px(0.0), px(140.0)))); + assert!(store.record_scroll_delta(tab.id(), tab.url().as_str(), point(px(0.0), px(60.0)))); - let request = store - .prepare_request(&tab, ProfileDataMode::Transient) - .ok_or("missing web surface request")?; + let input = store.take_pending_input(tab.id(), tab.url().as_str()); - assert_eq!(request.snapshot_request.profile_data_mode_for_test(), ProfileDataMode::Transient); + assert_eq!(input.scroll_offset.y(), 200); + assert_eq!(input.scroll_delta.map(|delta| (delta.x(), delta.y())), Some((0, 200))); Ok(()) } #[test] -fn tab_zoom_enters_snapshot_request() -> Result<(), Box> { +fn viewport_size_changes_after_stable_second_measurement() -> Result<(), Box> { let mut store = WebSurfaceStore::new(); - let mut tab = web_tab("https://example.com/zoom")?; - tab.set_zoom_percent(125)?; + let tab = web_tab("https://example.com/resize")?; - let bounds = Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))); - assert!(store.record_viewport_size(tab.id(), bounds)); - - let request = store - .prepare_request(&tab, ProfileDataMode::Persistent) - .ok_or("missing web surface request")?; - - assert_eq!(request.zoom_percent, 125); - assert_eq!(request.snapshot_request.page_zoom_percent_for_test(), 125); + assert!(store.record_viewport_size(tab.id(), web_bounds())); + assert!(!store.record_viewport_size(tab.id(), resized_once_bounds())); + assert!(store.record_viewport_size(tab.id(), resized_once_bounds())); Ok(()) } +fn web_bounds() -> Bounds { + Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0))) +} + +fn resized_once_bounds() -> Bounds { + Bounds::new(point(px(0.0), px(0.0)), size(px(934.0), px(657.0))) +} + fn web_tab(url: &str) -> Result> { Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?)) } diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs index 8cba947..ad26c1b 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar.rs @@ -14,6 +14,8 @@ use thiserror::Error; #[path = "ely_servo_sidecar/args.rs"] mod args; +#[path = "ely_servo_sidecar/live.rs"] +mod live; #[path = "ely_servo_sidecar/report.rs"] mod report; @@ -28,6 +30,7 @@ const INPUT_SETTLE_TIMEOUT: Duration = Duration::from_millis(700); fn main() -> Result<(), SidecarError> { match args::parse_env_command()? { + SidecarCommand::Live(args) => live::run_live(args).map_err(SidecarError::Live), SidecarCommand::Snapshot(args) => run_snapshot(args), } } @@ -43,6 +46,9 @@ enum SidecarError { #[error(transparent)] Host(#[from] ServoHostError), + #[error(transparent)] + Live(#[from] live::LiveSidecarError), + #[error(transparent)] Io(#[from] std::io::Error), diff --git a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs index 4767914..b6b152e 100644 --- a/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs +++ b/crates/ely_servo_host/src/bin/ely_servo_sidecar/args.rs @@ -8,9 +8,14 @@ use serde::Deserialize; use thiserror::Error; pub(super) enum SidecarCommand { + Live(LiveArgs), Snapshot(SnapshotArgs), } +pub(super) struct LiveArgs { + pub(super) profile_data_dir: PathBuf, +} + pub(super) struct SnapshotArgs { pub(super) url: UrlText, pub(super) profile_id: ProfileId, @@ -109,11 +114,34 @@ fn parse_command( let command = args.next().ok_or(SidecarArgsError::MissingCommand)?; match command.as_str() { + "live" => parse_live_args(args).map(SidecarCommand::Live), "snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot), _ => Err(SidecarArgsError::UnknownCommand { value: command }), } } +fn parse_live_args(args: impl IntoIterator) -> Result { + let mut args = args.into_iter(); + let mut profile_data_dir = None; + + while let Some(name) = args.next() { + match name.as_str() { + "--profile-data-dir" => { + profile_data_dir = Some(parse_path( + "--profile-data-dir", + next_argument(&mut args, "--profile-data-dir")?, + )?) + } + _ => return Err(SidecarArgsError::UnknownArgument { value: name }), + } + } + + Ok(LiveArgs { + profile_data_dir: profile_data_dir + .ok_or(SidecarArgsError::MissingRequiredArgument { name: "--profile-data-dir" })?, + }) +} + fn parse_snapshot_args( args: impl IntoIterator, ) -> Result { @@ -344,9 +372,8 @@ mod tests { fn parses_snapshot_profile_identity() -> Result<(), SidecarArgsError> { let profile_id = ProfileId::new(); let profile_data_dir = env::temp_dir().join(profile_id.as_str()); - let command = parse_snapshot_command(&profile_id, profile_data_dir.clone())?; + let args = parse_snapshot_command(&profile_id, profile_data_dir.clone())?; - let SidecarCommand::Snapshot(args) = command; assert_eq!(args.profile_id, profile_id); assert_eq!(args.profile_data_dir, profile_data_dir); assert_eq!(args.page_zoom_percent, DEFAULT_ZOOM_PERCENT); @@ -393,7 +420,7 @@ mod tests { .to_string(), ); - let SidecarCommand::Snapshot(args) = parse_command(command)?; + let args = snapshot_args(parse_command(command)?); assert_eq!(args.site_permissions.len(), 1); let permission = &args.site_permissions[0]; assert_eq!(permission.origin.as_str(), "https://example.com"); @@ -410,7 +437,7 @@ mod tests { command.push("--page-zoom-percent".to_string()); command.push("125".to_string()); - let SidecarCommand::Snapshot(args) = parse_command(command)?; + let args = snapshot_args(parse_command(command)?); assert_eq!(args.page_zoom_percent, 125); Ok(()) } @@ -432,8 +459,15 @@ mod tests { fn parse_snapshot_command( profile_id: &ProfileId, profile_data_dir: PathBuf, - ) -> Result { - parse_command(snapshot_command_args(profile_id, profile_data_dir)) + ) -> Result { + Ok(snapshot_args(parse_command(snapshot_command_args(profile_id, profile_data_dir))?)) + } + + fn snapshot_args(command: SidecarCommand) -> super::SnapshotArgs { + match command { + SidecarCommand::Snapshot(args) => args, + SidecarCommand::Live(_) => unreachable!("expected snapshot command"), + } } fn snapshot_command_args(profile_id: &ProfileId, profile_data_dir: PathBuf) -> Vec { diff --git a/crates/ely_servo_host/src/runtime_webview.rs b/crates/ely_servo_host/src/runtime_webview.rs index d232d45..c886ac5 100644 --- a/crates/ely_servo_host/src/runtime_webview.rs +++ b/crates/ely_servo_host/src/runtime_webview.rs @@ -1,7 +1,7 @@ use std::{cell::Cell, cell::RefCell, rc::Rc}; use ely_domain::{ProfileId, TabId, WebViewId}; -use servo::{LoadStatus, WebView, WebViewDelegate}; +use servo::{LoadStatus, RenderingContext, WebView, WebViewDelegate}; use url::Url; use crate::{ @@ -12,6 +12,7 @@ use crate::{ pub(super) struct HostWebView { pub(super) tab_id: TabId, pub(super) profile_id: ProfileId, + pub(super) rendering_context: Rc, pub(super) webview: WebView, pub(super) delegate: Rc, pub(super) requested_url: Option, diff --git a/实用, b/实用, new file mode 100644 index 0000000..e69de29