This commit is contained in:
2026-05-09 16:27:40 -04:00
parent 86f558c65f
commit 3caf207129
31 changed files with 2727 additions and 1597 deletions
+1
View File
@@ -6,6 +6,7 @@ members = [
"crates/ely_domain",
"crates/ely_servo_host",
]
default-members = ["crates/ely_app"]
resolver = "2"
[workspace.package]
-537
View File
@@ -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 | 6401024px | Hero h1 56px; IDE mockup compresses; feature grid 2-up. |
| Desktop | 10241280px | 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.
+68
View File
@@ -0,0 +1,68 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8"/>
<title>ELY Browser — Design System</title>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<link rel="stylesheet" href="styles.css"/>
<style>
html, body { margin:0; padding:0; background:#f0eee9; font-family: 'Geist', system-ui, sans-serif; }
</style>
<script src="https://unpkg.com/react@18.3.1/umd/react.development.js" integrity="sha384-hD6/rw4ppMLGNu3tX5cjIb+uRZ7UkRJ6BPkLpg4hAu/6onKUg4lLsHAs9EBPT82L" crossorigin="anonymous"></script>
<script src="https://unpkg.com/react-dom@18.3.1/umd/react-dom.development.js" integrity="sha384-u6aeetuaXnQ38mYT8rp6sbXaQe3NL9t+IBXmnYxwkUI2Hw4bsp2Wvmx4yRQF1uAm" crossorigin="anonymous"></script>
<script src="https://unpkg.com/@babel/standalone@7.29.0/babel.min.js" integrity="sha384-m08KidiNqLdpJqLq95G/LEi8Qvjl/xUYll3QILypMoQ65QorJ9Lvtp2RXYGBFj1y" crossorigin="anonymous"></script>
</head>
<body>
<div id="root"></div>
<script type="text/babel" src="design-canvas.jsx"></script>
<script type="text/babel" src="shared.jsx"></script>
<script type="text/babel" src="screens/home.jsx"></script>
<script type="text/babel" src="screens/browsing.jsx"></script>
<script type="text/babel" src="screens/split.jsx"></script>
<script type="text/babel" src="screens/command.jsx"></script>
<script type="text/babel" src="screens/settings.jsx"></script>
<script type="text/babel" src="screens/sync.jsx"></script>
<script type="text/babel" src="screens/plugins.jsx"></script>
<script type="text/babel">
const W = 1500, H = 940;
function App() {
return (
<DesignCanvas title="ELY Browser" subtitle="A calm, glassy desktop browser — full design across 7 surfaces.">
<DCSection id="primary" title="Primary surfaces" subtitle="Home, browsing, split view">
<DCArtboard id="home" label="01 · Home / New Tab" width={W} height={H}>
<HomeScreen/>
</DCArtboard>
<DCArtboard id="browse" label="02 · Main Browsing Page" width={W} height={H}>
<BrowsingScreen/>
</DCArtboard>
<DCArtboard id="split" label="03 · Split View" width={W} height={H}>
<SplitScreen/>
</DCArtboard>
</DCSection>
<DCSection id="overlays" title="Overlays & system" subtitle="Quick switcher, settings">
<DCArtboard id="cmd" label="04 · Command Bar / Quick Switcher" width={W} height={H}>
<CommandScreen/>
</DCArtboard>
<DCArtboard id="set" label="05 · Settings" width={W} height={H}>
<SettingsScreen/>
</DCArtboard>
</DCSection>
<DCSection id="account" title="Account & extensibility" subtitle="Sync sign-in, plugin marketplace">
<DCArtboard id="sync" label="06 · Sync / Sign In" width={W} height={H}>
<SyncScreen/>
</DCArtboard>
<DCArtboard id="plugins" label="07 · Plugin Marketplace + Detail" width={W} height={H}>
<PluginsScreen/>
</DCArtboard>
</DCSection>
</DesignCanvas>
);
}
ReactDOM.createRoot(document.getElementById('root')).render(<App/>);
</script>
</body>
</html>
+936
View File
@@ -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:
// <DesignCanvas>
// <DCSection id="onboarding" title="Onboarding" subtitle="First-run variants">
// <DCArtboard id="a" label="A · Dusk" width={260} height={480}>…</DCArtboard>
// <DCArtboard id="b" label="B · Minimal" width={260} height={480}>…</DCArtboard>
// </DCSection>
// </DesignCanvas>
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 (
<DCCtx.Provider value={api}>
<DCViewport minScale={minScale} maxScale={maxScale} style={style}>{ready && children}</DCViewport>
{state.focus && registry[state.focus] && (
<DCFocusOverlay entry={registry[state.focus]} sectionMeta={sectionMeta} sectionOrder={sectionOrder} />
)}
</DCCtx.Provider>
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
<div
ref={vpRef}
className="design-canvas"
style={{
height: '100vh', width: '100vw',
background: DC.bg,
overflow: 'hidden',
overscrollBehavior: 'none',
touchAction: 'none',
position: 'relative',
fontFamily: DC.font,
boxSizing: 'border-box',
...style,
}}
>
<div
ref={worldRef}
style={{
position: 'absolute', top: 0, left: 0,
transformOrigin: '0 0',
willChange: 'transform',
width: 'max-content', minWidth: '100%',
minHeight: '100%',
padding: '60px 0 80px',
}}
>
<div style={{ position: 'absolute', inset: -6000, backgroundImage: gridSvg, backgroundSize: '120px 120px', pointerEvents: 'none', zIndex: -1 }} />
{children}
</div>
</div>
);
}
// ─────────────────────────────────────────────────────────────
// 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 (
<div data-dc-section={sid}
style={{ marginBottom: 'calc(80px * var(--dc-inv-zoom, 1))', position: 'relative' }}>
<div style={{ padding: '0 60px' }}>
<div className="dc-sectionhead" style={{ paddingBottom: 36 }}>
<DCEditable tag="div" value={sec.title ?? title}
onChange={(v) => ctx && sid && ctx.patchSection(sid, { title: v })}
style={{ fontSize: 28, fontWeight: 600, color: DC.title, letterSpacing: -0.4, marginBottom: 6, display: 'inline-block' }} />
{subtitle && <div style={{ fontSize: 16, color: DC.subtitle }}>{subtitle}</div>}
</div>
</div>
<div style={{ display: 'flex', gap, padding: '0 60px', alignItems: 'flex-start', width: 'max-content' }}>
{order.map((k) => (
<DCArtboardFrame key={k} sectionId={sid} artboard={byId[k]} order={order}
label={(sec.labels || {})[k] ?? byId[k].props.label}
onRename={(v) => 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}`)} />
))}
</div>
{rest}
</div>
);
}
// 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 / <img> /
// 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 = '<!doctype html><html><head><meta charset="utf-8"><title>' + name + '</title>' +
(fontCss ? '<style>' + fontCss + '</style>' : '') +
'</head><body style="margin:0">' + xml + '</body></html>';
return save(new Blob([html], { type: 'text/html' }), 'html');
}
// PNG: the SVG's own width/height must be the output resolution — an
// <img>-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 = '<svg xmlns="http://www.w3.org/2000/svg" width="' + w * px + '" height="' + h * px +
'" viewBox="0 0 ' + w + ' ' + h + '"><foreignObject width="' + w + '" height="' + h + '">' +
(fontCss ? '<style><![CDATA[' + fontCss + ']]></style>' : '') + xml + '</foreignObject></svg>';
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 (
<div ref={ref} data-dc-slot={id} style={{ position: 'relative', flexShrink: 0 }}>
<div className="dc-header" style={{ color: DC.label }} onPointerDown={(e) => e.stopPropagation()}>
<div className="dc-labelrow">
<div className="dc-grip" onPointerDown={onGripDown} title="Drag to reorder">
<svg width="9" height="13" viewBox="0 0 9 13" fill="currentColor"><circle cx="2" cy="2" r="1.1"/><circle cx="7" cy="2" r="1.1"/><circle cx="2" cy="6.5" r="1.1"/><circle cx="7" cy="6.5" r="1.1"/><circle cx="2" cy="11" r="1.1"/><circle cx="7" cy="11" r="1.1"/></svg>
</div>
<div className="dc-labeltext" onClick={onFocus} title="Click to focus">
<DCEditable value={label} onChange={onRename} onClick={(e) => e.stopPropagation()}
style={{ fontSize: 15, fontWeight: 500, color: DC.label, lineHeight: 1 }} />
</div>
</div>
<div className="dc-btns">
<div ref={menuRef} style={{ position: 'relative' }}>
<button className="dc-kebab" title="More" onClick={() => setMenuOpen((o) => !o)}>
<svg width="12" height="12" viewBox="0 0 12 12" fill="currentColor"><circle cx="2.5" cy="6" r="1.1"/><circle cx="6" cy="6" r="1.1"/><circle cx="9.5" cy="6" r="1.1"/></svg>
</button>
{menuOpen && (
<div className="dc-menu" onPointerDown={(e) => e.stopPropagation()}>
<button onClick={() => doExport('png')}>Download PNG</button>
<button onClick={() => doExport('html')}>Download HTML</button>
<hr />
<button className="dc-danger"
onClick={() => { if (confirming) { setMenuOpen(false); onDelete(); } else setConfirming(true); }}>
{confirming ? 'Click again to delete' : 'Delete'}
</button>
</div>
)}
</div>
<button className="dc-expand" onClick={onFocus} title="Focus">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round"><path d="M7 1h4v4M5 11H1V7M11 1L7.5 4.5M1 11l3.5-3.5"/></svg>
</button>
</div>
</div>
<div ref={cardRef} className="dc-card"
style={{ borderRadius: 2, boxShadow: '0 1px 3px rgba(0,0,0,.08),0 4px 16px rgba(0,0,0,.06)', overflow: 'hidden', width, height, background: '#fff', ...style }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb', fontSize: 13, fontFamily: DC.font }}>{id}</div>}
</div>
</div>
);
}
// Inline rename — commits on blur or Enter.
function DCEditable({ value, onChange, style, tag = 'span', onClick }) {
const T = tag;
return (
<T className="dc-editable" contentEditable suppressContentEditableWarning
onClick={onClick}
onPointerDown={(e) => e.stopPropagation()}
onBlur={(e) => onChange && onChange(e.currentTarget.textContent)}
onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); e.currentTarget.blur(); } }}
style={style}>{value}</T>
);
}
// ─────────────────────────────────────────────────────────────
// 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 }) => (
<button onClick={(e) => { e.stopPropagation(); onClick(); }}
style={{ position: 'absolute', top: '50%', [dir]: 28, transform: 'translateY(-50%)',
border: 'none', background: 'rgba(255,255,255,.08)', color: 'rgba(255,255,255,.9)',
width: 44, height: 44, borderRadius: 22, fontSize: 18, cursor: 'pointer',
display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'background .15s' }}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.18)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.08)')}>
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
<path d={dir === 'left' ? 'M11 3L5 9l6 6' : 'M7 3l6 6-6 6'} /></svg>
</button>
);
// 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(
<div onClick={() => 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) */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', top: 0, left: 0, right: 0, height: 72, display: 'flex', alignItems: 'flex-start', padding: '16px 20px 0', gap: 16 }}>
<div style={{ position: 'relative' }}>
<button onClick={() => setDd((o) => !o)}
style={{ border: 'none', background: 'transparent', color: '#fff', cursor: 'pointer', padding: '6px 8px',
borderRadius: 6, textAlign: 'left', fontFamily: 'inherit' }}>
<span style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ fontSize: 18, fontWeight: 600, letterSpacing: -0.3 }}>{meta.title}</span>
<svg width="11" height="11" viewBox="0 0 11 11" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" style={{ opacity: .7 }}><path d="M2 4l3.5 3.5L9 4"/></svg>
</span>
{meta.subtitle && <span style={{ display: 'block', fontSize: 13, opacity: .6, fontWeight: 400, marginTop: 2 }}>{meta.subtitle}</span>}
</button>
{ddOpen && (
<div style={{ position: 'absolute', top: '100%', left: 0, marginTop: 4, background: '#2a251f', borderRadius: 8,
boxShadow: '0 8px 32px rgba(0,0,0,.4)', padding: 4, minWidth: 200, zIndex: 10 }}>
{sectionOrder.filter((sid) => sectionMeta[sid].slotIds.length).map((sid) => (
<button key={sid} onClick={() => { setDd(false); const f = sectionMeta[sid].slotIds[0]; if (f) ctx.setFocus(`${sid}/${f}`); }}
style={{ display: 'block', width: '100%', textAlign: 'left', border: 'none', cursor: 'pointer',
background: sid === sectionId ? 'rgba(255,255,255,.1)' : 'transparent', color: '#fff',
padding: '8px 12px', borderRadius: 5, fontSize: 14, fontWeight: sid === sectionId ? 600 : 400, fontFamily: 'inherit' }}>
{sectionMeta[sid].title}
</button>
))}
</div>
)}
</div>
<div style={{ flex: 1 }} />
<button onClick={() => ctx.setFocus(null)}
onMouseEnter={(e) => (e.currentTarget.style.background = 'rgba(255,255,255,.12)')}
onMouseLeave={(e) => (e.currentTarget.style.background = 'transparent')}
style={{ border: 'none', background: 'transparent', color: 'rgba(255,255,255,.7)', width: 32, height: 32,
borderRadius: 16, fontSize: 20, cursor: 'pointer', lineHeight: 1, transition: 'background .12s' }}>×</button>
</div>
{/* card centered, label + index below — only the card itself stops
propagation so any backdrop click (including the margins around
the card) exits focus */}
<div
style={{ position: 'absolute', top: 64, bottom: 56, left: 100, right: 100, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', gap: 16 }}>
<div onClick={(e) => e.stopPropagation()} style={{ width: width * scale, height: height * scale, position: 'relative' }}>
<div style={{ width, height, transform: `scale(${scale})`, transformOrigin: 'top left', background: '#fff', borderRadius: 2, overflow: 'hidden',
boxShadow: '0 20px 80px rgba(0,0,0,.4)' }}>
{children || <div style={{ height: '100%', display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#bbb' }}>{aid}</div>}
</div>
</div>
<div onClick={(e) => e.stopPropagation()} style={{ fontSize: 14, fontWeight: 500, opacity: .85, textAlign: 'center' }}>
{(sec.labels || {})[aid] ?? artboard.props.label}
<span style={{ opacity: .5, marginLeft: 10, fontVariantNumeric: 'tabular-nums' }}>{idx + 1} / {peers.length}</span>
</div>
</div>
<Arrow dir="left" onClick={() => go(-1)} />
<Arrow dir="right" onClick={() => go(1)} />
{/* dots */}
<div onClick={(e) => e.stopPropagation()}
style={{ position: 'absolute', bottom: 20, left: '50%', transform: 'translateX(-50%)', display: 'flex', gap: 8 }}>
{peers.map((p, i) => (
<button key={p} onClick={() => ctx.setFocus(`${sectionId}/${p}`)}
style={{ border: 'none', padding: 0, cursor: 'pointer', width: 6, height: 6, borderRadius: 3,
background: i === idx ? '#fff' : 'rgba(255,255,255,.3)' }} />
))}
</div>
</div>,
document.body,
);
}
// ─────────────────────────────────────────────────────────────
// Post-it — absolute-positioned sticky note
// ─────────────────────────────────────────────────────────────
function DCPostIt({ children, top, left, right, bottom, rotate = -2, width = 180 }) {
return (
<div style={{
position: 'absolute', top, left, right, bottom, width,
background: DC.postitBg, padding: '14px 16px',
fontFamily: '"Comic Sans MS", "Marker Felt", "Segoe Print", cursive',
fontSize: 14, lineHeight: 1.4, color: DC.postitText,
boxShadow: '0 2px 8px rgba(0,0,0,0.12), 0 1px 2px rgba(0,0,0,0.08)',
transform: `rotate(${rotate}deg)`,
zIndex: 5,
}}>{children}</div>
);
}
Object.assign(window, { DesignCanvas, DCSection, DCArtboard, DCPostIt });
+80
View File
@@ -0,0 +1,80 @@
// browsing.jsx — Main browsing page (real webpage open)
const BrowsingScreen = () => (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'figma', mode:'browse', tabs:[
{ id:'figma', label:'Style Guide · Figma', icon:<Brand.Figma s={16}/>, active:true, audio:true, closable:true },
{ id:'roadmap', label:'Roadmap 2024 — Linear', icon:<Brand.Linear s={16}/> },
{ id:'mkt', label:'Marketing Site', icon:<Icon size={16} stroke="#6b6660"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon> },
{ id:'twt', label:'Home / X', icon:<Brand.X s={16}/> },
{ id:'gh', label:'elybrowser/core · main', icon:<Brand.GitHub s={16}/> },
] }}>
<TopBar host="figma.com" path="/file/12345/Style-Guide" canBack canForward/>
<div className="ely-scroll" style={{flex:1, overflowY:'auto', background:'#fff'}}>
{/* Mock figma-ish UI inside */}
<div style={{height:46, background:'#1d1c1a', color:'#cfcbc4', display:'flex', alignItems:'center', padding:'0 14px', fontSize:12, gap:10}}>
<div style={{display:'flex',alignItems:'center',gap:6}}><Brand.Figma s={14}/><span style={{color:'#fff', fontWeight:500}}>Style Guide</span></div>
<span style={{opacity:.55}}>/</span>
<span>ELY Design System</span>
<span style={{marginLeft:'auto', display:'flex', gap:8, alignItems:'center'}}>
<span style={{padding:'2px 8px', background:'#fff', color:'#1d1c1a', borderRadius:4, fontSize:11, fontWeight:500}}>Share</span>
<div style={{width:22,height:22,borderRadius:'50%',background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/>
<div style={{width:22,height:22,borderRadius:'50%',background:'linear-gradient(135deg,#4fb59a,#7c6cf7)'}}/>
</span>
</div>
<div style={{display:'grid', gridTemplateColumns:'220px 1fr 260px', height:'calc(100% - 46px)', minHeight:560, background:'#f5f4f1'}}>
<div style={{background:'#fff', borderRight:'1px solid #ebe7e0', padding:'10px 8px', fontSize:11.5, color:'#3a3733'}}>
<div style={{padding:'4px 6px', fontWeight:600, color:'#1d1c1a'}}>Pages</div>
{['Cover','Tokens','Typography','Color','Spacing','Components','Patterns','Sandbox'].map((p,i)=>(
<div key={p} style={{padding:'5px 8px', borderRadius:5, background: i===2?'#f0e9e2':'transparent', color: i===2?'#1d1c1a':'#3a3733', display:'flex',alignItems:'center',gap:6}}>
<I.ChevronRight size={10} style={{opacity:.6}}/> {p}
</div>
))}
<div style={{padding:'10px 6px 4px', fontWeight:600, color:'#1d1c1a', marginTop:8}}>Layers</div>
{[['Frame · Display','◇'],[' H1 / Display','T'],[' H2 / Title','T'],[' H3 / Subtitle','T'],['Frame · Body','◇'],[' Paragraph','T'],[' Caption','T']].map(([n,g],i)=>(
<div key={i} style={{padding:'4px 6px', fontFamily:'monospace', fontSize:10.5, color:'#6b6660', whiteSpace:'pre'}}>{g} {n}</div>
))}
</div>
<div style={{padding:32, position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', inset:0, backgroundImage:'radial-gradient(circle at 1px 1px, rgba(0,0,0,0.06) 1px, transparent 0)', backgroundSize:'18px 18px'}}/>
<div style={{position:'relative', background:'#fff', borderRadius:6, padding:'32px 40px', boxShadow:'0 1px 0 rgba(0,0,0,0.06), 0 12px 40px -10px rgba(0,0,0,0.12)', maxWidth:520}}>
<div style={{fontSize:10.5, color:'#9a948c', letterSpacing:'0.12em', textTransform:'uppercase'}}>Display / Serif</div>
<div className="ely-serif" style={{fontSize:48, lineHeight:1.05, marginTop:8, color:'#1d1c1a'}}>The aesthetics<br/>of calm tech.</div>
<div style={{marginTop:24, display:'grid', gridTemplateColumns:'1fr 1fr', gap:14}}>
{['H1 / 64','H2 / 40','H3 / 28','Body / 16','Caption / 12','Mono / 13'].map((s,i)=>(
<div key={i} style={{borderTop:'1px solid #ebe7e0', paddingTop:8}}>
<div className={i<3?'ely-serif':''} style={{fontSize: i<3? 18: 13, color:'#1d1c1a', fontFamily: i===5?'monospace':undefined}}>The quick brown fox</div>
<div style={{fontSize:10.5, color:'#9a948c', marginTop:2}}>{s}</div>
</div>
))}
</div>
</div>
<div style={{position:'absolute', bottom:14, left:14, display:'flex', gap:6, padding:'6px 10px', background:'#fff', borderRadius:6, boxShadow:'0 1px 4px rgba(0,0,0,0.08)', fontSize:11, color:'#3a3733'}}>
100% <I.ChevronDown size={10}/>
</div>
</div>
<div style={{background:'#fff', borderLeft:'1px solid #ebe7e0', padding:'12px 12px', fontSize:11.5, color:'#3a3733'}}>
<div style={{display:'flex', gap:14, fontWeight:500, color:'#1d1c1a', borderBottom:'1px solid #ebe7e0', paddingBottom:8}}>
<span style={{borderBottom:'1.5px solid #1d1c1a', paddingBottom:6}}>Design</span>
<span style={{color:'#9a948c'}}>Prototype</span>
<span style={{color:'#9a948c'}}>Inspect</span>
</div>
<div style={{paddingTop:10, color:'#9a948c'}}>Color · Local styles</div>
<div style={{display:'grid', gridTemplateColumns:'repeat(6, 1fr)', gap:6, marginTop:8}}>
{['#1d1c1a','#3a3733','#6b6660','#c96442','#ec6a8e','#7c6cf7','#4fb59a','#f6f4ef','#efe8e1','#fff3ee','#ffd1d8','#b9c7ff'].map(c=>(
<div key={c} style={{aspectRatio:'1', borderRadius:4, background:c, boxShadow:'inset 0 0 0 1px rgba(0,0,0,0.05)'}}/>
))}
</div>
<div style={{paddingTop:14, color:'#9a948c'}}>Type · Newsreader / Geist</div>
<div style={{padding:8, background:'#f6f4ef', borderRadius:6, marginTop:8, fontSize:11}}>
<div className="ely-serif" style={{fontSize:18}}>Aa Bb Cc</div>
<div style={{color:'#6b6660', marginTop:2}}>Newsreader · 6..72</div>
</div>
<div style={{paddingTop:14, color:'#9a948c'}}>Spacing</div>
<div style={{display:'flex', gap:4, marginTop:6}}>
{[4,8,12,16,24,32].map(s=>(<div key={s} style={{width:s, height:18, background:'#1d1c1a', opacity:.85}}/>))}
</div>
</div>
</div>
</div>
</ElyShell>
);
window.BrowsingScreen = BrowsingScreen;
+91
View File
@@ -0,0 +1,91 @@
// command.jsx — Command Bar / Quick Switcher (overlaid on home)
const CommandScreen = () => (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'home', mode:'home' }}>
<TopBar url="Search ELY or type a command…"/>
<div style={{flex:1, position:'relative', overflow:'hidden'}}>
{/* dimmed home behind */}
<div style={{position:'absolute', inset:0, padding:'48px 64px', opacity:0.35, filter:'blur(4px) saturate(0.9)', pointerEvents:'none'}}>
<div style={{textAlign:'center'}}>
<div className="ely-serif" style={{fontSize:64, lineHeight:1.05, fontWeight:400, color:'var(--ely-ink)'}}>Where focus finds flow.</div>
</div>
</div>
<div style={{position:'absolute', inset:0, background:'rgba(20,15,10,0.18)', backdropFilter:'blur(6px)'}}/>
{/* command panel */}
<div style={{position:'absolute', top:80, left:'50%', transform:'translateX(-50%)', width:640, background:'rgba(255,255,255,0.86)', backdropFilter:'blur(40px) saturate(160%)', borderRadius:16, boxShadow:'0 30px 80px -20px rgba(40,30,20,0.45), 0 0 0 1px rgba(255,255,255,0.6) inset, 0 1px 0 rgba(255,255,255,0.8) inset', overflow:'hidden'}}>
<div style={{display:'flex', alignItems:'center', gap:12, padding:'16px 18px', borderBottom:'1px solid var(--ely-divider)'}}>
<I.Search size={18}/>
<div style={{fontSize:16, color:'var(--ely-ink)', flex:1}}>
roa<span style={{display:'inline-block', width:1, height:18, background:'var(--ely-accent)', verticalAlign:'middle', marginLeft:1, animation:'blink 1s infinite'}}/>
</div>
<span style={{padding:'2px 8px', borderRadius:6, background:'rgba(40,30,20,0.06)', fontSize:10.5, color:'var(--ely-ink-3)'}}>Switcher</span>
<span className="ely-mono" style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>esc</span>
</div>
<div style={{maxHeight:440, overflowY:'auto'}}>
{/* Section: Open tabs */}
<CmdSection label="Open tabs">
<CmdRow icon={<Brand.Figma s={16}/>} title="Style Guide — Figma" hint="figma.com" right="⌘1" />
<CmdRow active icon={<Brand.Linear s={16}/>} title="Roadmap 2024 — Linear" hint="linear.app · 6 issues open" right="⌘2 · ↵" />
<CmdRow icon={<div style={{width:16,height:16, borderRadius:4, background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/>} title="ELY Roadmap doc — Notion" hint="notion.so" right="⌘3" />
</CmdSection>
<CmdSection label="History">
<CmdRow icon={<Icon size={14}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon>} title="Roadmap.app · 2024 update" hint="roadmap.app · 1d ago" />
<CmdRow icon={<Brand.GitHub s={14}/>} title="elybrowser/core · roadmap.md" hint="github.com · 3d ago" />
</CmdSection>
<CmdSection label="Bookmarks">
<CmdRow icon={<I.Bookmark size={14}/>} title="Roadmap planning template" hint="Pinned · Work" />
</CmdSection>
<CmdSection label="Actions">
<CmdRow icon={<I.Layers size={14}/>} title="Switch workspace" hint="Work · Personal · Studio" right="⌘⇧W" />
<CmdRow icon={<I.Split size={14}/>} title="Open in split view" hint="Beside current tab" right="⌘D" />
<CmdRow icon={<I.Settings size={14}/>} title="Open Settings" hint="Appearance, Sync, Plugins…" right="⌘," />
</CmdSection>
<CmdSection label="Ask ELY">
<CmdRow ai icon={<I.Sparkle size={14}/>} title='Ask: "What did we ship in the last roadmap cycle?"' hint="Searches recent tabs, history & docs" right="⌘↵"/>
</CmdSection>
</div>
<div style={{display:'flex', alignItems:'center', gap:14, padding:'10px 16px', borderTop:'1px solid var(--ely-divider)', fontSize:10.5, color:'var(--ely-ink-3)', background:'rgba(255,255,255,0.55)'}}>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd><Kbd></Kbd> navigate</span>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd> open</span>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd> open in split</span>
<span style={{display:'inline-flex', alignItems:'center', gap:4}}><Kbd></Kbd> filter</span>
<span style={{marginLeft:'auto', display:'inline-flex', alignItems:'center', gap:6}}>
<I.Sparkle size={11}/> Powered by ELY
</span>
</div>
</div>
</div>
<style>{`@keyframes blink {0%,49%{opacity:1}50%,100%{opacity:0}}`}</style>
</ElyShell>
);
const Kbd = ({ children }) => (
<span className="ely-mono" style={{padding:'1px 5px', minWidth:14, textAlign:'center', borderRadius:4, background:'rgba(255,255,255,0.85)', boxShadow:'0 0 0 1px var(--ely-stroke), 0 1px 0 rgba(40,30,20,0.05)', fontSize:10}}>{children}</span>
);
const CmdSection = ({ label, children }) => (
<div style={{padding:'8px 0 4px'}}>
<div style={{padding:'6px 16px 4px', fontSize:10, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em', fontWeight:500}}>{label}</div>
{children}
</div>
);
const CmdRow = ({ icon, title, hint, right, active, ai }) => (
<div style={{display:'flex', alignItems:'center', gap:12, padding:'8px 16px', background: active?'rgba(201,100,66,0.08)': 'transparent', position:'relative'}}>
{active && <div style={{position:'absolute', left:0, top:6, bottom:6, width:2, borderRadius:2, background:'var(--ely-accent)'}}/>}
<div style={{width:24, height:24, borderRadius:6, background: ai?'linear-gradient(135deg,#ffd1d8,#b9c7ff)':'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-soft)', display:'flex',alignItems:'center',justifyContent:'center', color: ai?'#c96442':'var(--ely-ink-2)'}}>{icon}</div>
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:13, fontWeight: active?500:450, color:'var(--ely-ink)'}}>{title}</div>
{hint && <div style={{fontSize:11, color:'var(--ely-ink-4)', marginTop:1}}>{hint}</div>}
</div>
{right && <span className="ely-mono" style={{fontSize:10.5, color:'var(--ely-ink-3)'}}>{right}</span>}
</div>
);
window.CommandScreen = CommandScreen;
+118
View File
@@ -0,0 +1,118 @@
// home.jsx — Home / New Tab
const HomeScreen = () => (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'home', mode:'home' }}>
<TopBar url="Search ELY or type a command…"/>
<div className="ely-scroll" style={{flex:1, overflowY:'auto', padding:'48px 64px 28px'}}>
<div style={{textAlign:'center', maxWidth: 880, margin:'0 auto'}}>
<div style={{display:'inline-flex', alignItems:'center', gap:8, color:'var(--ely-ink-3)', fontSize:13, marginBottom:14}}>
<span style={{color:'#e89a6e'}}><I.Sunrise size={16}/></span>
Good morning, Alex
</div>
<h1 className="ely-serif" style={{fontSize: 64, lineHeight: 1.05, margin:'0 0 28px', fontWeight:400, letterSpacing:'-0.02em', color:'var(--ely-ink)'}}>
Where focus finds flow.
</h1>
<div style={{position:'relative', maxWidth: 640, margin:'0 auto'}}>
<div style={{height:54, background:'rgba(255,255,255,0.85)', borderRadius:14, boxShadow:'var(--ely-shadow-card)', display:'flex', alignItems:'center', padding:'0 16px', gap:12}}>
<I.Search size={16}/>
<span style={{color:'var(--ely-ink-4)', fontSize:14, flex:1, textAlign:'left'}}>Search the web or ELY</span>
<button className="ely-iconbtn" style={{background:'rgba(40,30,20,0.04)'}}><I.ArrowRight size={14}/></button>
</div>
</div>
<div style={{display:'flex', justifyContent:'center', gap:8, marginTop:14}}>
{[['Search Tabs', <I.Search size={12}/>], ['Switch Workspace', <I.Layers size={12}/>], ['Open Notion', <Brand.Notion s={12}/>]].map(([t,i],idx)=>(
<span key={idx} style={{display:'inline-flex',alignItems:'center',gap:6, padding:'6px 12px', borderRadius:999, background:'rgba(255,255,255,0.55)', boxShadow:'var(--ely-shadow-soft)', fontSize:12, color:'var(--ely-ink-2)'}}>
{i}{t}
</span>
))}
</div>
</div>
{/* favorites */}
<div style={{marginTop:48}}>
<div style={{display:'flex', alignItems:'center', gap:6, color:'var(--ely-ink-3)', fontSize:12.5, marginBottom:12, fontWeight:500}}>
Favorites <I.ChevronDown size={12}/>
</div>
<div style={{display:'grid', gridTemplateColumns:'repeat(7, 1fr)', gap:10}}>
{[
[<Brand.Notion s={28}/>, 'Notion'],
[<Brand.YT s={28}/>, 'YouTube'],
[<Brand.Dribbble s={28}/>, 'Dribbble'],
[<Brand.X s={28}/>, 'X'],
[<Brand.Vercel s={28}/>, 'Vercel'],
[<Brand.Behance s={28}/>, 'Behance'],
].map(([g,n], i)=>(
<div key={i} className="ely-card" style={{height:96, display:'flex', flexDirection:'column', alignItems:'center', justifyContent:'center', gap:8}}>
{g}
<div style={{fontSize:11.5, color:'var(--ely-ink-3)'}}>{n}</div>
</div>
))}
<div className="ely-card" style={{height:96, display:'flex', alignItems:'center', justifyContent:'center', color:'var(--ely-ink-4)', background:'rgba(255,255,255,0.5)'}}>
<I.Plus size={18}/>
</div>
</div>
</div>
{/* lower row */}
<div style={{display:'grid', gridTemplateColumns:'1.6fr 1fr', gap:12, marginTop:16}}>
<div className="ely-card" style={{padding:16, display:'grid', gridTemplateColumns:'1fr 240px', gap:16}}>
<div>
<div style={{fontSize:13, fontWeight:500, marginBottom:14}}>Continue where you left off</div>
<div style={{display:'flex', flexDirection:'column', gap:10}}>
{[
[<Brand.Figma s={26}/>, 'Design System Figma', 'figma.com', '2 min ago'],
[<div style={{width:26,height:26,borderRadius:7,background:'linear-gradient(135deg,#7c6cf7,#1d1c1a)'}}/>, 'Roadmap 2024', 'linear.app', '1 hour ago'],
[<Icon size={26}><circle cx="12" cy="12" r="9" stroke="#6b6660" strokeWidth="1.4" fill="none"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18" stroke="#6b6660" strokeWidth="1.4" fill="none"/></Icon>, 'Marketing Site', 'elybrowser.com', '3 hours ago'],
].map(([icon, name, host, when], i)=>(
<div key={i} style={{display:'flex', alignItems:'center', gap:12, padding:'4px 0'}}>
{icon}
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:13, fontWeight:500, color:'var(--ely-ink)'}}>{name}</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-4)'}}>{host} · {when}</div>
</div>
</div>
))}
</div>
</div>
<div style={{position:'relative', borderRadius:12, overflow:'hidden', background:'linear-gradient(135deg,#dcc4b6,#9aa5c4)', minHeight:200}}>
<div style={{position:'absolute', inset:0, background:`
radial-gradient(60% 40% at 50% 30%, rgba(255,200,210,0.7), transparent),
radial-gradient(80% 60% at 30% 80%, rgba(120,140,180,0.6), transparent)
`}}/>
<div style={{position:'absolute', top:8, right:8, background:'rgba(0,0,0,0.35)', backdropFilter:'blur(8px)', color:'#fff', padding:'4px 10px', borderRadius:999, fontSize:10.5, display:'flex', alignItems:'center', gap:6}}>
Reading List <span style={{opacity:.6}}>·</span> 5
</div>
<div style={{position:'absolute', bottom:0, left:0, right:0, padding:14, color:'#fff', textShadow:'0 1px 8px rgba(0,0,0,0.4)'}}>
<div className="ely-serif" style={{fontSize:18, lineHeight:1.15, fontWeight:500}}>The aesthetics of<br/>calm technology</div>
<div style={{fontSize:11, opacity:.85, marginTop:6}}>Inside ELY Journal · 6 min read</div>
</div>
</div>
</div>
<div className="ely-card" style={{padding:16}}>
<div style={{fontSize:13, fontWeight:500, marginBottom:12}}>Recent activity</div>
<div style={{display:'flex', flexDirection:'column', gap:12}}>
{[
['viewed', 'Pricing Page', 'elybrowser.com/pricing', '2m', <Icon size={14}><circle cx="12" cy="12" r="9"/><path d="M3 12h18"/></Icon>],
['opened', 'Style Guide', 'figma.com/file/12345', '1h', <Brand.Figma s={14}/>],
['commented on', 'Roadmap 2024', 'linear.app/roadmap', '3h', <Brand.Linear s={14}/>],
].map(([verb, name, url, when, ic], i)=>(
<div key={i} style={{display:'flex', alignItems:'flex-start', gap:10}}>
<div style={{width:24, height:24, borderRadius:6, background:'rgba(255,255,255,0.8)', boxShadow:'var(--ely-shadow-soft)', display:'flex',alignItems:'center',justifyContent:'center'}}>{ic}</div>
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:11.5, color:'var(--ely-ink-3)'}}>You {verb}</div>
<div style={{fontSize:13, fontWeight:500}}>{name}</div>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)', marginTop:1}}>{url}</div>
</div>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>{when}</div>
</div>
))}
</div>
<div style={{marginTop:14, paddingTop:10, borderTop:'1px solid var(--ely-divider)', fontSize:11.5, color:'var(--ely-ink-3)', display:'inline-flex', alignItems:'center', gap:4}}>
View all history <I.ArrowRight size={11}/>
</div>
</div>
</div>
</div>
</ElyShell>
);
window.HomeScreen = HomeScreen;
+159
View File
@@ -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 (
<ElyShell wallpaper="mint" sidebarProps={{ activeId:'plugins' }}>
<div className="ely-topbar">
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar"><I.Plug size={14}/><span className="url-host">ely://plugins</span><span className="url-path">/marketplace</span><span className="right"><I.Filters size={14}/></span></div>
<button className="ely-iconbtn is-on"><I.Plug size={15}/></button>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div className="ely-scroll" style={{flex:1, overflowY:'auto'}}>
{/* Hero */}
<div style={{padding:'40px 56px 24px', display:'grid', gridTemplateColumns:'1.4fr 1fr', gap:28, alignItems:'end'}}>
<div>
<div style={{fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Plugins · Native to ELY</div>
<h1 className="ely-serif" style={{fontSize:48, lineHeight:1.05, margin:'6px 0 10px', fontWeight:400, letterSpacing:'-0.02em'}}>Quiet tools.<br/>Everyday magic.</h1>
<div style={{fontSize:13.5, color:'var(--ely-ink-2)', maxWidth:520, lineHeight:1.55}}>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.</div>
<div style={{marginTop:18, height:42, padding:'0 16px', borderRadius:12, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-card)', display:'flex', alignItems:'center', gap:10, maxWidth:480}}>
<I.Search size={14}/>
<span style={{fontSize:13, color:'var(--ely-ink-4)', flex:1}}>Search 184 plugins</span>
<span className="ely-mono" style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>K</span>
</div>
</div>
<div className="ely-card" style={{padding:18, background:'linear-gradient(135deg, rgba(255,255,255,0.9), rgba(214,196,255,0.55))'}}>
<div style={{fontSize:11, color:'var(--ely-ink-3)', textTransform:'uppercase', letterSpacing:'0.1em', display:'flex',alignItems:'center', gap:6}}><I.Sparkle size={11}/> Editor's pick</div>
<div style={{display:'flex', alignItems:'flex-start', gap:14, marginTop:12}}>
<div style={{width:56, height:56, borderRadius:14, background:'linear-gradient(135deg,#d6c4ff,#ffd1d8)', display:'flex',alignItems:'center',justifyContent:'center', fontSize:28, color:'#fff'}}>✦</div>
<div style={{flex:1}}>
<div style={{fontSize:15, fontWeight:600}}>ELY AI · Summon</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-4)'}}>by ELY Studio · 47.3k installs</div>
<div style={{fontSize:12.5, color:'var(--ely-ink-2)', marginTop:6, lineHeight:1.4}}>Ask anything about the current page with citations.</div>
</div>
</div>
<div style={{display:'flex', alignItems:'center', gap:8, marginTop:12}}>
<span style={{padding:'7px 14px', borderRadius:8, background:'var(--ely-ink)', color:'#fff', fontSize:12, fontWeight:500}}>Install</span>
<span style={{padding:'7px 14px', borderRadius:8, background:'rgba(255,255,255,0.7)', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500}}>Open detail</span>
<span style={{marginLeft:'auto', fontSize:11.5, color:'var(--ely-ink-3)'}}>⭐︎ 4.9 · 1,241 reviews</span>
</div>
</div>
</div>
{/* Cats */}
<div style={{padding:'4px 56px 18px', display:'flex', gap:6, flexWrap:'wrap'}}>
{cats.map((c,i)=>(
<span key={c} style={{padding:'6px 12px', borderRadius:999, fontSize:12, background: i===0?'var(--ely-ink)':'rgba(255,255,255,0.7)', color: i===0?'#fff':'var(--ely-ink-2)', boxShadow:i===0?'none':'var(--ely-shadow-soft)', fontWeight:500}}>{c}</span>
))}
<span style={{marginLeft:'auto', fontSize:11.5, color:'var(--ely-ink-3)', display:'inline-flex', alignItems:'center', gap:6}}>Sort: Most installed <I.ChevronDown size={11}/></span>
</div>
{/* Plugin grid */}
<div style={{padding:'0 56px 28px', display:'grid', gridTemplateColumns:'repeat(4, 1fr)', gap:14}}>
{plugins.map((p,i)=>(
<div key={i} className="ely-card" style={{padding:14, display:'flex', flexDirection:'column', gap:10}}>
<div style={{aspectRatio:'5/3', borderRadius:10, background:p.accent, display:'flex',alignItems:'center',justifyContent:'center', fontSize:36, color: p.dark?'rgba(255,255,255,0.9)':'rgba(255,255,255,0.95)', position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', inset:0, background:'radial-gradient(60% 50% at 30% 20%, rgba(255,255,255,0.4), transparent 70%)'}}/>
<span style={{position:'relative', textShadow:'0 2px 14px rgba(0,0,0,0.2)'}}>{p.glyph}</span>
{p.tags.includes('Featured') && <span style={{position:'absolute', top:8, left:8, padding:'2px 8px', borderRadius:999, background:'rgba(255,255,255,0.95)', color:'var(--ely-ink)', fontSize:9.5, fontWeight:600, letterSpacing:'0.04em'}}>FEATURED</span>}
</div>
<div style={{display:'flex',alignItems:'flex-start',gap:8}}>
<div style={{flex:1, minWidth:0}}>
<div style={{fontSize:13.5, fontWeight:600}}>{p.name}</div>
<div style={{fontSize:11, color:'var(--ely-ink-4)'}}>{p.author}</div>
</div>
<span style={{padding:'3px 9px', borderRadius:6, background:'rgba(40,30,20,0.05)', fontSize:11, fontWeight:500, color:'var(--ely-ink-2)'}}>Get</span>
</div>
<div style={{fontSize:12, color:'var(--ely-ink-3)', lineHeight:1.45, height: 50, overflow:'hidden'}}>{p.desc}</div>
<div style={{display:'flex', alignItems:'center', gap:8, fontSize:10.5, color:'var(--ely-ink-4)', borderTop:'1px solid var(--ely-divider)', paddingTop:8, marginTop:'auto'}}>
<span>★ {p.rating}</span>
<span>·</span>
<span>{p.installs} installs</span>
<span style={{marginLeft:'auto', display:'inline-flex',alignItems:'center', gap:3, color:'#2f7d68'}}><I.Shield size={10}/> Sandboxed</span>
</div>
</div>
))}
</div>
{/* Plugin detail (preview / featured below) */}
<div style={{padding:'10px 56px 40px'}}>
<div className="ely-card" style={{padding:24, display:'grid', gridTemplateColumns:'320px 1fr', gap:24}}>
<div>
<div style={{aspectRatio:'4/3', borderRadius:14, background:'linear-gradient(135deg,#ffd1d8,#b9c7ff)', display:'flex',alignItems:'center',justifyContent:'center', fontSize:80, color:'#fff', textShadow:'0 4px 20px rgba(0,0,0,0.15)'}}>¶</div>
<div style={{display:'flex', alignItems:'center', gap:10, marginTop:14}}>
<span style={{flex:1, height:40, background:'var(--ely-ink)', color:'#fff', borderRadius:10, display:'inline-flex',alignItems:'center',justifyContent:'center', fontSize:13, fontWeight:500, gap:6}}><I.Download size={13}/> Install · Free</span>
<span style={{width:40, height:40, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-soft)', borderRadius:10, display:'inline-flex',alignItems:'center',justifyContent:'center'}}><I.Heart size={14}/></span>
</div>
<div style={{marginTop:16, fontSize:11, color:'var(--ely-ink-3)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Permissions</div>
<div style={{marginTop:8, display:'flex', flexDirection:'column', gap:6, fontSize:12}}>
{[['Read content of pages you choose',''],['Store reading preferences locally',''],['Access network',' '],['Read browsing history',' ']].map(([t,c],i)=>(
<div key={i} style={{display:'flex',alignItems:'center', gap:8, color: c===''?'var(--ely-ink-2)':'var(--ely-ink-4)'}}>
<span style={{width:14, height:14, borderRadius:4, background: c===''?'#e8f3ee':'rgba(40,30,20,0.05)', color:'#2f7d68', display:'inline-flex',alignItems:'center',justifyContent:'center', fontSize:10}}>{c}</span>
{t}
</div>
))}
</div>
</div>
<div>
<div style={{display:'flex', alignItems:'flex-start', gap:14}}>
<div style={{flex:1}}>
<div style={{fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Reading · Featured</div>
<h2 className="ely-serif" style={{fontSize:32, fontWeight:400, margin:'4px 0 4px', letterSpacing:'-0.02em'}}>Reader+</h2>
<div style={{fontSize:12.5, color:'var(--ely-ink-3)'}}>by ELY Studio · v2.4 · updated 3 days ago</div>
</div>
<div style={{textAlign:'right'}}>
<div style={{fontSize:18, fontWeight:600}}>★ 4.9</div>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>1,894 reviews</div>
</div>
</div>
<p style={{fontSize:13.5, color:'var(--ely-ink-2)', lineHeight:1.6, marginTop:14}}>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.</p>
<div style={{display:'grid', gridTemplateColumns:'repeat(4, 1fr)', gap:8, marginTop:18}}>
{[
['Installs','24,521'],['Rating','4.9 / 5'],['Size','340 kB'],['Sandboxed','Yes']
].map(([k,v])=>(
<div key={k} style={{padding:'10px 12px', borderRadius:10, background:'rgba(255,255,255,0.7)', boxShadow:'var(--ely-shadow-soft)'}}>
<div style={{fontSize:10.5, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>{k}</div>
<div style={{fontSize:13, fontWeight:500, marginTop:2}}>{v}</div>
</div>
))}
</div>
<div style={{marginTop:22}}>
<div style={{fontSize:13, fontWeight:600, marginBottom:10}}>What it adds to ELY</div>
<div style={{display:'flex', flexDirection:'column', gap:8}}>
{[
['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)=>(
<div key={i} style={{display:'flex', alignItems:'flex-start', gap:10, padding:'10px 12px', borderRadius:10, background:'rgba(255,255,255,0.55)'}}>
<span style={{width:18, height:18, borderRadius:5, background:'rgba(201,100,66,0.12)', color:'var(--ely-accent)', display:'inline-flex',alignItems:'center',justifyContent:'center', fontSize:11, marginTop:1}}><I.Check size={11}/></span>
<div style={{flex:1}}>
<div style={{fontSize:12.5, fontWeight:500}}>{t}</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-3)', marginTop:1, lineHeight:1.4}}>{d}</div>
</div>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</div>
</ElyShell>
);};
window.PluginsScreen = PluginsScreen;
+138
View File
@@ -0,0 +1,138 @@
// settings.jsx — Settings page
const SettingsScreen = () => {
const SectionLabel = ({ children, k='label' }) => k==='label' ?
<div style={{padding:'8px 12px 4px', fontSize:10, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em', fontWeight:500}}>{children}</div>
: <div>{children}</div>;
const NavItem = ({ icon, label, active }) => (
<div style={{display:'flex',alignItems:'center',gap:10, padding:'7px 12px', borderRadius:8, background: active?'rgba(255,255,255,0.85)':'transparent', boxShadow: active?'var(--ely-shadow-soft)':'none', fontSize:13, color: active?'var(--ely-ink)':'var(--ely-ink-2)'}}>
<span style={{color:'var(--ely-ink-3)'}}>{icon}</span>{label}
</div>
);
const Toggle = ({ on }) => (
<span style={{width:32, height:18, borderRadius:999, background: on?'var(--ely-accent)':'rgba(40,30,20,0.15)', position:'relative', display:'inline-block', boxShadow:'inset 0 0 0 1px rgba(0,0,0,0.05)'}}>
<span style={{position:'absolute', top:2, left: on?16:2, width:14, height:14, borderRadius:'50%', background:'#fff', boxShadow:'0 1px 2px rgba(0,0,0,0.2)', transition:'left .2s'}}/>
</span>
);
const Row = ({ title, desc, children, last }) => (
<div style={{display:'flex', alignItems:'center', gap:16, padding:'14px 0', borderBottom: last?'none':'1px solid var(--ely-divider)'}}>
<div style={{flex:1}}>
<div style={{fontSize:13, fontWeight:500}}>{title}</div>
{desc && <div style={{fontSize:11.5, color:'var(--ely-ink-3)', marginTop:2}}>{desc}</div>}
</div>
{children}
</div>
);
const Select = ({ value }) => (
<span style={{display:'inline-flex',alignItems:'center', gap:6, padding:'5px 10px', background:'rgba(255,255,255,0.85)', borderRadius:6, boxShadow:'var(--ely-shadow-soft)', fontSize:12}}>{value} <I.ChevronDown size={11}/></span>
);
return (
<ElyShell wallpaper="dawn" sidebarProps={{ activeId:'settings' }}>
<div className="ely-topbar">
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn disabled"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar"><I.Settings size={14}/><span className="url-host">ely://settings</span><span className="url-path">/appearance</span><span className="right"><I.Filters size={14}/></span></div>
<button className="ely-iconbtn"><I.Copy size={16}/></button>
<button className="ely-iconbtn"><I.Download size={16}/></button>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div style={{flex:1, display:'grid', gridTemplateColumns:'232px 1fr', minHeight:0}}>
<div style={{padding:'16px 10px', borderRight:'1px solid var(--ely-divider)', overflowY:'auto'}}>
<div style={{padding:'4px 12px 12px'}}>
<div style={{fontSize:18, fontWeight:500}} className="ely-serif">Settings</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-4)', marginTop:2}}>ELY 0.42 · macOS 14.4</div>
</div>
<SectionLabel>General</SectionLabel>
<NavItem icon={<I.Palette size={14}/>} label="Appearance" active/>
<NavItem icon={<I.Sidebar size={14}/>} label="Sidebar"/>
<NavItem icon={<I.Tab size={14}/>} label="Tabs &amp; Workspaces"/>
<NavItem icon={<I.Cmd size={14}/>} label="Shortcuts"/>
<SectionLabel>Account</SectionLabel>
<NavItem icon={<I.Cloud size={14}/>} label="Sync"/>
<NavItem icon={<I.Shield size={14}/>} label="Privacy &amp; Security"/>
<NavItem icon={<I.User size={14}/>} label="Profiles"/>
<SectionLabel>Power</SectionLabel>
<NavItem icon={<I.Plug size={14}/>} label="Plugins"/>
<NavItem icon={<I.Sparkle size={14}/>} label="ELY AI"/>
<NavItem icon={<I.Code size={14}/>} label="Developer"/>
<SectionLabel>About</SectionLabel>
<NavItem icon={<I.Heart size={14}/>} label="What's new"/>
<NavItem icon={<I.Bell size={14}/>} label="Help &amp; feedback"/>
</div>
<div className="ely-scroll" style={{padding:'28px 40px 32px', overflowY:'auto'}}>
<div style={{maxWidth:780, margin:'0 auto'}}>
<div style={{fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>General</div>
<h1 className="ely-serif" style={{fontSize:34, fontWeight:400, margin:'4px 0 6px', letterSpacing:'-0.02em'}}>Appearance</h1>
<div style={{fontSize:13, color:'var(--ely-ink-3)', maxWidth:520}}>Tune the atmosphere of your browser. ELY's theme follows the time of day by default — pick a wallpaper, then let it breathe.</div>
<div style={{marginTop:24, display:'grid', gridTemplateColumns:'repeat(4, 1fr)', gap:10}}>
{[
['Dawn','#ffd1d8','#b9c7ff', true],
['Violet','#d6c4ff','#ffe0c0'],
['Mint','#c5f0db','#ffe5b8'],
['Slate','#cfd6e2','#1d1c1a']
].map(([n, a, b, sel])=>(
<div key={n} style={{position:'relative'}}>
<div style={{aspectRatio:'4/3', borderRadius:10, background:`radial-gradient(70% 60% at 80% 20%, ${a}, transparent 70%), radial-gradient(60% 60% at 15% 90%, ${b}, transparent 70%), #f6f4ef`, boxShadow:'var(--ely-shadow-card)', outline: sel?'2px solid var(--ely-accent)':'none', outlineOffset:2}}/>
<div style={{display:'flex', alignItems:'center', gap:6, padding:'8px 2px 0', fontSize:12}}>
<span style={{flex:1, fontWeight:500}}>{n}</span>
{sel && <span style={{color:'var(--ely-accent)'}}><I.Check size={13}/></span>}
</div>
</div>
))}
</div>
<div style={{marginTop:32}}>
<Row title="Theme mode" desc="Match system appearance, or pick one to lock.">
<div style={{display:'flex', gap:4, padding:3, background:'rgba(40,30,20,0.05)', borderRadius:8}}>
{['System','Light','Dark'].map((t,i)=>(
<span key={t} style={{padding:'4px 12px', borderRadius:6, fontSize:12, background: i===0?'#fff':'transparent', boxShadow: i===0?'var(--ely-shadow-soft)':'none', fontWeight:500}}>{t}</span>
))}
</div>
</Row>
<Row title="Accent color" desc="Used across selection, focus rings & links.">
<div style={{display:'flex', gap:8}}>
{['#c96442','#ec6a8e','#7c6cf7','#4fb59a','#1d1c1a'].map((c,i)=>(
<div key={c} style={{width:22,height:22, borderRadius:'50%', background:c, boxShadow: i===0?'0 0 0 2px #fff, 0 0 0 4px var(--ely-accent)':'inset 0 0 0 1px rgba(0,0,0,0.05)'}}/>
))}
</div>
</Row>
<Row title="Font" desc="Display serif used for headlines & New Tab.">
<Select value="Newsreader"/>
</Row>
<Row title="Translucency" desc="Glass density of sidebar & panels.">
<div style={{width:200, height:4, borderRadius:2, background:'rgba(40,30,20,0.1)', position:'relative'}}>
<div style={{position:'absolute', left:0, top:0, bottom:0, width:'62%', background:'var(--ely-accent)', borderRadius:2}}/>
<div style={{position:'absolute', left:'62%', top:'50%', width:14, height:14, borderRadius:'50%', background:'#fff', boxShadow:'0 1px 4px rgba(0,0,0,0.2)', transform:'translate(-50%,-50%)'}}/>
</div>
</Row>
<Row title="Reduce motion" desc="Mute transitions & ambient effects.">
<Toggle/>
</Row>
<Row title="Reading-mode background" last>
<Select value="Sepia"/>
</Row>
</div>
<div style={{marginTop:36, fontSize:11, color:'var(--ely-ink-4)', textTransform:'uppercase', letterSpacing:'0.1em'}}>Sidebar</div>
<h2 className="ely-serif" style={{fontSize:22, fontWeight:400, margin:'2px 0 18px'}}>Layout</h2>
<div style={{display:'grid', gridTemplateColumns:'repeat(3, 1fr)', gap:10}}>
{['Single column','Compact','Hidden on hover'].map((n,i)=>(
<div key={n} className="ely-card" style={{padding:14, outline: i===0?'2px solid var(--ely-accent)':'none'}}>
<div style={{aspectRatio:'5/3', background:'#fff', borderRadius:8, boxShadow:'var(--ely-shadow-soft)', position:'relative', overflow:'hidden'}}>
<div style={{position:'absolute', left:6, top:6, bottom:6, width: i===2?6:i===1?32:54, background:'rgba(40,30,20,0.08)', borderRadius:5}}/>
<div style={{position:'absolute', left: i===2?20:i===1?46:68, top:6, right:6, bottom:6, background:'rgba(40,30,20,0.04)', borderRadius:5}}/>
</div>
<div style={{fontSize:12, fontWeight:500, marginTop:10}}>{n}</div>
<div style={{fontSize:11, color:'var(--ely-ink-4)', marginTop:2}}>{['Default · workspace + tabs','Icons-only with tooltips','Slide in on cursor reach'][i]}</div>
</div>
))}
</div>
</div>
</div>
</div>
</ElyShell>
);};
window.SettingsScreen = SettingsScreen;
+105
View File
@@ -0,0 +1,105 @@
// split.jsx — Split-view browsing
const SplitScreen = () => {
const Pane = ({ title, host, children, accent }) => (
<div style={{display:'flex', flexDirection:'column', flex:1, minWidth:0, background:'#fff', borderRadius:10, overflow:'hidden', boxShadow:'0 1px 0 rgba(0,0,0,0.05), 0 12px 30px -16px rgba(0,0,0,0.18)'}}>
<div style={{height:30, background:'#f6f4ef', borderBottom:'1px solid #ebe7e0', display:'flex', alignItems:'center', padding:'0 10px', gap:8, fontSize:11, color:'#3a3733'}}>
<span style={{width:8, height:8, borderRadius:'50%', background:accent}}/>
<I.Lock size={11}/>
<span style={{color:'#1d1c1a', fontWeight:500}}>{host}</span>
<span style={{color:'#9a948c'}}>{title}</span>
<span style={{marginLeft:'auto', display:'flex', gap:6, color:'#9a948c'}}>
<I.Reload size={11}/><I.X size={11}/>
</span>
</div>
<div style={{flex:1, minHeight:0, overflow:'hidden'}}>{children}</div>
</div>
);
return (
<ElyShell wallpaper="violet" sidebarProps={{ activeId:'figma', mode:'browse', tabs:[
{ id:'figma', label:'Style Guide · Figma', icon:<Brand.Figma s={16}/>, active:true, closable:true },
{ id:'lin', label:'Roadmap 2024 — Linear', icon:<Brand.Linear s={16}/>, active:true },
{ id:'gh', label:'elybrowser/core', icon:<Brand.GitHub s={16}/> },
] }}>
<div className="ely-topbar" style={{gap:6}}>
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn disabled"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar" style={{flex:1}}>
<I.Lock size={14}/>
<span className="url-host">figma.com</span>
<span className="url-path">/file/12345/Style-Guide</span>
<span className="right"><I.Filters size={14}/><I.Star size={14}/></span>
</div>
<div style={{width:1, height:22, background:'var(--ely-divider)', margin:'0 4px'}}/>
<div className="ely-omnibar" style={{flex:1}}>
<I.Lock size={14}/>
<span className="url-host">linear.app</span>
<span className="url-path">/elybrowser/roadmap</span>
<span className="right"><I.Filters size={14}/><I.Star size={14}/></span>
</div>
<button className="ely-iconbtn is-on" title="Split"><I.Split size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div style={{flex:1, padding:14, display:'flex', gap:10, minHeight:0}}>
{/* Left: Figma */}
<Pane title="· Style Guide" host="figma.com" accent="#7c6cf7">
<div style={{height:'100%', display:'grid', gridTemplateRows:'auto 1fr', background:'#1d1c1a', color:'#cfcbc4'}}>
<div style={{padding:'8px 12px', fontSize:11, display:'flex', gap:8, alignItems:'center'}}>
<Brand.Figma s={14}/>
<span style={{color:'#fff', fontWeight:500}}>Style Guide</span>
<span style={{opacity:.6}}>/ Typography</span>
<span style={{marginLeft:'auto', display:'flex', gap:6}}>
<span style={{padding:'2px 8px', background:'#fff', color:'#1d1c1a', borderRadius:4, fontSize:10.5}}>Share</span>
</span>
</div>
<div style={{position:'relative', overflow:'hidden', background:'#f5f4f1'}}>
<div style={{position:'absolute', inset:0, backgroundImage:'radial-gradient(circle at 1px 1px, rgba(0,0,0,0.05) 1px, transparent 0)', backgroundSize:'16px 16px'}}/>
<div style={{position:'relative', margin:'24px auto', maxWidth:380, background:'#fff', padding:'24px 28px', borderRadius:6, boxShadow:'0 1px 0 rgba(0,0,0,0.06)'}}>
<div className="ely-serif" style={{fontSize:36, lineHeight:1.05}}>The aesthetics of calm tech.</div>
<div style={{marginTop:14, fontSize:11.5, color:'#3a3733', lineHeight:1.5}}>A type system tuned for long-form reading and slow, deliberate interfaces. Designed for ambient use.</div>
<div style={{marginTop:14, display:'flex', gap:6}}>
{['#1d1c1a','#c96442','#ec6a8e','#7c6cf7','#4fb59a'].map(c=>(<div key={c} style={{width:18,height:18,borderRadius:4, background:c}}/>))}
</div>
</div>
</div>
</div>
</Pane>
{/* Right: Linear */}
<Pane title="· Roadmap 2024" host="linear.app" accent="#5e6ad2">
<div style={{height:'100%', background:'#0e0d0c', color:'#dcd9d2', display:'flex'}}>
<div style={{width:140, borderRight:'1px solid #1d1c1a', padding:'10px 8px', fontSize:11}}>
<div style={{display:'flex',alignItems:'center',gap:6, padding:'4px 6px'}}><Brand.Linear s={14}/><span style={{fontWeight:600}}>ELY</span></div>
{['Inbox','My Issues','Triage'].map(l=>(<div key={l} style={{padding:'4px 6px', color:'#9a948c'}}>{l}</div>))}
<div style={{padding:'8px 6px 2px', color:'#6b6660', fontSize:10, textTransform:'uppercase', letterSpacing:'.08em'}}>Cycles</div>
{['24 · Now','24 · Next','25 · Q1','25 · Q2'].map((l,i)=>(<div key={l} style={{padding:'4px 6px', borderRadius:4, background:i===0?'#1d1c1a':'transparent', color:i===0?'#fff':'#9a948c'}}>{l}</div>))}
</div>
<div style={{flex:1, padding:'10px 14px', fontSize:11.5}}>
<div style={{display:'flex', alignItems:'center', gap:8}}>
<span style={{fontSize:13, color:'#fff', fontWeight:500}}>Roadmap 2024</span>
<span style={{color:'#6b6660'}}>· 18 issues</span>
<span style={{marginLeft:'auto', padding:'2px 8px', background:'#5e6ad2', color:'#fff', borderRadius:4, fontSize:10.5}}>+ New</span>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr', gap:1, marginTop:10, background:'#1d1c1a', borderRadius:4, overflow:'hidden'}}>
{[
['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)=>(
<div key={i} style={{display:'flex',alignItems:'center', gap:10, padding:'8px 10px', background:'#0e0d0c'}}>
<span style={{width:8,height:8,borderRadius:'50%', background:c}}/>
<span style={{color:'#6b6660', width:54}}>{id}</span>
<span style={{flex:1, color:'#fff'}}>{t}</span>
<span style={{color:'#9a948c'}}>{st}</span>
<span style={{width:18,height:18,borderRadius:'50%', background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/>
</div>
))}
</div>
</div>
</div>
</Pane>
</div>
</ElyShell>
);};
window.SplitScreen = SplitScreen;
+111
View File
@@ -0,0 +1,111 @@
// sync.jsx — Sync / Sign In page
const SyncScreen = () => {
const Device = ({ name, model, when, current, icon }) => (
<div style={{display:'flex',alignItems:'center', gap:12, padding:'14px 4px', borderBottom:'1px solid var(--ely-divider)'}}>
<div style={{width:36, height:36, borderRadius:9, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-soft)', display:'flex',alignItems:'center',justifyContent:'center', color:'var(--ely-ink-2)'}}>{icon}</div>
<div style={{flex:1}}>
<div style={{fontSize:13, fontWeight:500, display:'flex', alignItems:'center', gap:8}}>{name}{current && <span style={{padding:'1px 7px', background:'rgba(79,181,154,0.18)', color:'#2f7d68', borderRadius:999, fontSize:10}}>This device</span>}</div>
<div style={{fontSize:11, color:'var(--ely-ink-4)', marginTop:1}}>{model} · {when}</div>
</div>
<span style={{fontSize:11, color:'var(--ely-ink-3)', display:'inline-flex',alignItems:'center', gap:4}}><span style={{width:6, height:6, borderRadius:'50%', background:'#4fb59a'}}/> Synced</span>
</div>
);
return (
<ElyShell wallpaper="violet" sidebarProps={{ activeId:'settings' }}>
<div className="ely-topbar">
<button className="ely-iconbtn"><I.ArrowLeft size={16}/></button>
<button className="ely-iconbtn disabled"><I.ArrowRight size={16}/></button>
<div className="ely-omnibar"><I.Cloud size={14}/><span className="url-host">ely://settings</span><span className="url-path">/sync</span></div>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</div>
<div className="ely-scroll" style={{flex:1, overflowY:'auto', padding:'40px 56px 32px'}}>
<div style={{maxWidth:880, margin:'0 auto', display:'grid', gridTemplateColumns:'1.1fr 1fr', gap:32, alignItems:'start'}}>
<div>
<div style={{display:'inline-flex', alignItems:'center', gap:8, padding:'4px 10px', borderRadius:999, background:'rgba(255,255,255,0.7)', boxShadow:'var(--ely-shadow-soft)', fontSize:11, color:'var(--ely-ink-3)'}}>
<I.Cloud size={12}/> Cloudflare Sync · end-to-end encrypted
</div>
<h1 className="ely-serif" style={{fontSize:46, lineHeight:1.05, margin:'16px 0 10px', fontWeight:400, letterSpacing:'-0.02em'}}>Your tabs, on every device.</h1>
<p style={{fontSize:14, color:'var(--ely-ink-2)', lineHeight:1.55, maxWidth:440, margin:0}}>Sign in once and ELY keeps your tabs, workspaces, pinned items and history in sync encrypted in your hands, mirrored at the edge.</p>
<div style={{marginTop:28, padding:24, borderRadius:16, background:'rgba(255,255,255,0.85)', boxShadow:'var(--ely-shadow-card)', maxWidth:380}}>
<div style={{fontSize:12, color:'var(--ely-ink-3)', marginBottom:14, display:'flex', alignItems:'center', gap:6}}>
<I.Lock size={12}/> Better Auth · ELY Account
</div>
<label style={{display:'block', fontSize:11, color:'var(--ely-ink-3)', marginBottom:6}}>Email</label>
<div style={{height:40, padding:'0 12px', borderRadius:9, background:'#fff', boxShadow:'var(--ely-shadow-soft)', display:'flex', alignItems:'center', fontSize:13, color:'var(--ely-ink)'}}>alex@elybrowser.com</div>
<label style={{display:'block', fontSize:11, color:'var(--ely-ink-3)', marginBottom:6, marginTop:14}}>Password</label>
<div style={{height:40, padding:'0 12px', borderRadius:9, background:'#fff', boxShadow:'var(--ely-shadow-soft)', display:'flex', alignItems:'center', fontSize:13, color:'var(--ely-ink)', letterSpacing:'0.2em'}}></div>
<button style={{width:'100%', marginTop:18, height:42, borderRadius:10, background:'var(--ely-ink)', color:'#fff', fontWeight:500, fontSize:13, border:0, display:'flex', alignItems:'center', justifyContent:'center', gap:8}}>
Sign in <I.ArrowRight size={14}/>
</button>
<div style={{display:'flex', alignItems:'center', gap:10, margin:'14px 0 12px'}}>
<div style={{flex:1, height:1, background:'var(--ely-divider)'}}/><span style={{fontSize:10.5, color:'var(--ely-ink-4)'}}>or continue with</span><div style={{flex:1, height:1, background:'var(--ely-divider)'}}/>
</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr 1fr', gap:8}}>
{['Google','GitHub','Apple'].map(p=>(
<div key={p} style={{height:36, borderRadius:9, background:'#fff', boxShadow:'var(--ely-shadow-soft)', display:'flex', alignItems:'center', justifyContent:'center', gap:6, fontSize:12, fontWeight:500, color:'var(--ely-ink)'}}>
{p==='GitHub' && <Brand.GitHub s={13}/>}
{p==='Google' && <span style={{width:13, height:13, borderRadius:'50%', background:'conic-gradient(from 90deg, #ea4335, #fbbc05, #34a853, #4285f4)'}}/>}
{p==='Apple' && <span style={{fontSize:13}}></span>}
{p}
</div>
))}
</div>
<div style={{marginTop:14, fontSize:11, color:'var(--ely-ink-4)', textAlign:'center'}}>By signing in you accept the ELY <span style={{color:'var(--ely-ink-2)', textDecoration:'underline', textDecorationColor:'var(--ely-ink-5)'}}>terms</span> &amp; <span style={{color:'var(--ely-ink-2)', textDecoration:'underline', textDecorationColor:'var(--ely-ink-5)'}}>privacy</span>.</div>
</div>
</div>
<div style={{display:'flex', flexDirection:'column', gap:14}}>
<div className="ely-card" style={{padding:18}}>
<div style={{display:'flex', alignItems:'center', gap:10, marginBottom:6}}>
<span style={{width:28, height:28, borderRadius:8, background:'linear-gradient(135deg,#fff,#f0e9e2)', display:'flex',alignItems:'center',justifyContent:'center', boxShadow:'var(--ely-shadow-soft)', color:'var(--ely-accent)'}}><I.Devices size={14}/></span>
<div style={{fontSize:13, fontWeight:500, flex:1}}>Devices</div>
<span style={{fontSize:11, color:'var(--ely-ink-3)'}}>3 connected</span>
</div>
<Device current name="Alex's MacBook Pro" model="macOS 14.4 · Apple Silicon" when="Active now" icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="3" y="4" width="18" height="12" rx="1.5"/><path d="M2 20h20"/></svg>}/>
<Device name="iPhone 15 Pro" model="iOS 17.4 · Cellular" when="2 minutes ago" icon={<svg width="14" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="6" y="2" width="12" height="20" rx="2"/><path d="M11 18h2"/></svg>}/>
<Device name="Studio iMac" model="macOS 14.2 · Office" when="6 hours ago" icon={<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5"><rect x="2" y="3" width="20" height="14" rx="1.5"/><path d="M8 21h8M12 17v4"/></svg>}/>
<div style={{padding:'12px 4px 0', display:'flex', alignItems:'center', gap:10}}>
<span style={{padding:'7px 14px', borderRadius:8, background:'#fff', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500, display:'inline-flex', alignItems:'center', gap:6}}><I.Plus size={12}/> Pair a device</span>
<span style={{fontSize:11, color:'var(--ely-ink-4)'}}>or scan QR with the ELY app</span>
</div>
</div>
<div className="ely-card" style={{padding:18, background:'linear-gradient(135deg, rgba(255,235,225,0.95), rgba(232,224,255,0.85))'}}>
<div style={{display:'flex', alignItems:'flex-start', gap:12}}>
<span style={{width:30, height:30, borderRadius:8, background:'#fff', display:'flex',alignItems:'center',justifyContent:'center', boxShadow:'var(--ely-shadow-soft)', color:'var(--ely-accent)'}}><I.Key size={15}/></span>
<div style={{flex:1}}>
<div style={{fontSize:13, fontWeight:500}}>Recovery key</div>
<div style={{fontSize:11.5, color:'var(--ely-ink-3)', marginTop:2}}>The only way to restore an end-to-end encrypted account. We don't keep a copy.</div>
</div>
</div>
<div className="ely-mono" style={{marginTop:14, padding:14, background:'rgba(255,255,255,0.85)', borderRadius:10, fontSize:13, letterSpacing:'0.05em', lineHeight:1.7, color:'var(--ely-ink)', textAlign:'center', fontWeight:500, boxShadow:'var(--ely-shadow-soft)'}}>
river · slate · candle · lantern<br/>orchid · meadow · whistle · ember
</div>
<div style={{display:'flex', gap:8, marginTop:12}}>
<span style={{flex:1, padding:'8px 10px', borderRadius:8, background:'#fff', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500, textAlign:'center'}}>Copy</span>
<span style={{flex:1, padding:'8px 10px', borderRadius:8, background:'#fff', boxShadow:'var(--ely-shadow-soft)', fontSize:12, fontWeight:500, textAlign:'center'}}>Download .txt</span>
<span style={{flex:1, padding:'8px 10px', borderRadius:8, background:'var(--ely-ink)', color:'#fff', fontSize:12, fontWeight:500, textAlign:'center'}}>I've saved it</span>
</div>
</div>
<div className="ely-card" style={{padding:16}}>
<div style={{fontSize:13, fontWeight:500, marginBottom:8}}>What syncs</div>
<div style={{display:'grid', gridTemplateColumns:'1fr 1fr', gap:6, fontSize:12, color:'var(--ely-ink-2)'}}>
{[['Tabs &amp; pinned',true],['Workspaces',true],['History',true],['Bookmarks',true],['Reading list',true],['Plugins',true],['Passwords',false],['Open windows',false]].map(([n,on],i)=>(
<div key={i} style={{display:'flex',alignItems:'center',gap:8, padding:'4px 0'}}>
<span style={{color: on?'#2f7d68':'var(--ely-ink-4)'}}>{on? <I.Check size={13}/> : <I.X size={13}/>}</span>
<span dangerouslySetInnerHTML={{__html:n}}/>
</div>
))}
</div>
</div>
</div>
</div>
</div>
</ElyShell>
);};
window.SyncScreen = SyncScreen;
+272
View File
@@ -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 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill={fill} stroke={stroke}
strokeWidth={sw} strokeLinecap="round" strokeLinejoin="round" style={style}>
{d ? <path d={d} /> : children}
</svg>
);
const I = {
ArrowLeft: (p)=> <Icon size={p?.size||16} d="M19 12H5M12 19l-7-7 7-7"/>,
ArrowRight: (p)=> <Icon size={p?.size||16} d="M5 12h14M12 5l7 7-7 7"/>,
ArrowUpRight:(p)=> <Icon size={p?.size||14} d="M7 17 17 7M7 7h10v10"/>,
Plus: (p)=> <Icon size={p?.size||14} d="M12 5v14M5 12h14"/>,
X: (p)=> <Icon size={p?.size||14} d="M18 6 6 18M6 6l12 12"/>,
Search: (p)=> <Icon size={p?.size||14}><circle cx="11" cy="11" r="7"/><path d="m20 20-3.5-3.5"/></Icon>,
Star: (p)=> <Icon size={p?.size||14} d="M12 17.3l-5.4 3 1-6-4.3-4.2 6-.9L12 3.5l2.7 5.7 6 .9-4.3 4.2 1 6z"/>,
Sparkle: (p)=> <Icon size={p?.size||14} d="M12 3v3M12 18v3M3 12h3M18 12h3M5.6 5.6l2.1 2.1M16.3 16.3l2.1 2.1M5.6 18.4l2.1-2.1M16.3 7.7l2.1-2.1"/>,
Sun: (p)=> <Icon size={p?.size||16}><circle cx="12" cy="12" r="4"/><path d="M12 2v2M12 20v2M4.9 4.9l1.4 1.4M17.7 17.7l1.4 1.4M2 12h2M20 12h2M4.9 19.1l1.4-1.4M17.7 6.3l1.4-1.4"/></Icon>,
Moon: (p)=> <Icon size={p?.size||16} d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z"/>,
Sunrise: (p)=> <Icon size={p?.size||14} d="M17 18a5 5 0 0 0-10 0M12 9V2M4.2 10.2l1.4 1.4M19.8 10.2l-1.4 1.4M2 18h2M20 18h2M22 22H2M8 6l4-4 4 4"/>,
Settings: (p)=> <Icon size={p?.size||16}><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.8l.1.1a2 2 0 1 1-2.8 2.8l-.1-.1a1.7 1.7 0 0 0-1.8-.3 1.7 1.7 0 0 0-1 1.5V21a2 2 0 1 1-4 0v-.1a1.7 1.7 0 0 0-1.1-1.5 1.7 1.7 0 0 0-1.8.3l-.1.1a2 2 0 1 1-2.8-2.8l.1-.1a1.7 1.7 0 0 0 .3-1.8 1.7 1.7 0 0 0-1.5-1H3a2 2 0 1 1 0-4h.1A1.7 1.7 0 0 0 4.6 9a1.7 1.7 0 0 0-.3-1.8l-.1-.1a2 2 0 1 1 2.8-2.8l.1.1a1.7 1.7 0 0 0 1.8.3H9a1.7 1.7 0 0 0 1-1.5V3a2 2 0 1 1 4 0v.1a1.7 1.7 0 0 0 1 1.5 1.7 1.7 0 0 0 1.8-.3l.1-.1a2 2 0 1 1 2.8 2.8l-.1.1a1.7 1.7 0 0 0-.3 1.8V9a1.7 1.7 0 0 0 1.5 1H21a2 2 0 1 1 0 4h-.1a1.7 1.7 0 0 0-1.5 1z"/></Icon>,
Menu: (p)=> <Icon size={p?.size||16} d="M4 6h16M4 12h16M4 18h16"/>,
Filters: (p)=> <Icon size={p?.size||14} d="M3 6h13M3 12h9M3 18h6M19 4v6M22 7h-6M16 14v6M19 17h-6"/>,
Bookmark: (p)=> <Icon size={p?.size||14} d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>,
Copy: (p)=> <Icon size={p?.size||16}><rect x="9" y="9" width="11" height="11" rx="2"/><path d="M5 15V5a2 2 0 0 1 2-2h10"/></Icon>,
Download: (p)=> <Icon size={p?.size||16} d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/>,
ChevronDown:(p)=> <Icon size={p?.size||14} d="m6 9 6 6 6-6"/>,
ChevronRight:(p)=> <Icon size={p?.size||14} d="m9 6 6 6-6 6"/>,
ChevronUp: (p)=> <Icon size={p?.size||14} d="m6 15 6-6 6 6"/>,
Layers: (p)=> <Icon size={p?.size||16} d="M12 2 2 7l10 5 10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>,
Home: (p)=> <Icon size={p?.size||16} d="M3 9.5 12 3l9 6.5V21a1 1 0 0 1-1 1h-5v-7H9v7H4a1 1 0 0 1-1-1z"/>,
Calendar: (p)=> <Icon size={p?.size||16}><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></Icon>,
Globe: (p)=> <Icon size={p?.size||14}><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18M12 3a14 14 0 0 0 0 18"/></Icon>,
Lock: (p)=> <Icon size={p?.size||14}><rect x="4" y="11" width="16" height="10" rx="2"/><path d="M8 11V7a4 4 0 1 1 8 0v4"/></Icon>,
Sidebar: (p)=> <Icon size={p?.size||16}><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M9 4v16"/></Icon>,
Split: (p)=> <Icon size={p?.size||16}><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M12 4v16"/></Icon>,
Cmd: (p)=> <Icon size={p?.size||14} d="M9 9V6a3 3 0 1 0-3 3h12a3 3 0 1 0-3-3v12a3 3 0 1 0 3-3H6a3 3 0 1 0 3 3z"/>,
Reload: (p)=> <Icon size={p?.size||14} d="M3 12a9 9 0 1 0 3-6.7L3 8M3 3v5h5"/>,
Pin: (p)=> <Icon size={p?.size||14} d="M12 17v5M9 3h6l-1 4 3 4H7l3-4z"/>,
Tab: (p)=> <Icon size={p?.size||14} d="M3 12h13l-3-3M3 12l3 3M21 5v14"/>,
Inbox: (p)=> <Icon size={p?.size||14} d="M22 12h-6l-2 3h-4l-2-3H2M5.5 5h13l3 7v6a2 2 0 0 1-2 2H4.5a2 2 0 0 1-2-2v-6z"/>,
Trash: (p)=> <Icon size={p?.size||14} d="M3 6h18M19 6l-1.5 14a2 2 0 0 1-2 2H8.5a2 2 0 0 1-2-2L5 6M10 11v6M14 11v6M9 6V4a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2"/>,
Eye: (p)=> <Icon size={p?.size||14}><path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z"/><circle cx="12" cy="12" r="3"/></Icon>,
Mic: (p)=> <Icon size={p?.size||14} d="M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3zM5 11a7 7 0 0 0 14 0M12 18v4"/>,
Lightning: (p)=> <Icon size={p?.size||14} d="M13 2 4 14h7l-1 8 9-12h-7z"/>,
Shield: (p)=> <Icon size={p?.size||14} d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/>,
Cloud: (p)=> <Icon size={p?.size||14} d="M17 18a5 5 0 0 0-1-9.9A7 7 0 1 0 5 16h12z"/>,
Plug: (p)=> <Icon size={p?.size||14} d="M9 2v4M15 2v4M7 6h10v4a5 5 0 0 1-10 0zM12 15v7"/>,
Heart: (p)=> <Icon size={p?.size||14} d="M12 21s-7-4.5-9-9a5 5 0 0 1 9-3 5 5 0 0 1 9 3c-2 4.5-9 9-9 9z"/>,
Check: (p)=> <Icon size={p?.size||14} d="m5 12 5 5 9-11"/>,
Dot: (p)=> <Icon size={p?.size||4} fill="currentColor"><circle cx="12" cy="12" r="6"/></Icon>,
Key: (p)=> <Icon size={p?.size||14}><circle cx="8" cy="15" r="4"/><path d="m11 12 9-9 1 4-3 1 1 3-4 1z"/></Icon>,
Devices: (p)=> <Icon size={p?.size||14}><rect x="3" y="5" width="13" height="11" rx="1.5"/><rect x="14" y="11" width="7" height="9" rx="1.5"/></Icon>,
User: (p)=> <Icon size={p?.size||14}><circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/></Icon>,
Bell: (p)=> <Icon size={p?.size||14} d="M6 8a6 6 0 1 1 12 0c0 7 3 9 3 9H3s3-2 3-9M10 21a2 2 0 0 0 4 0"/>,
Palette: (p)=> <Icon size={p?.size||14} d="M12 22a10 10 0 1 1 10-10c0 2-2 3-4 3h-2a2 2 0 0 0-2 2v2c0 2-1 3-2 3z"/>,
Code: (p)=> <Icon size={p?.size||14} d="m8 6-6 6 6 6M16 6l6 6-6 6M14 4l-4 16"/>,
Filter: (p)=> <Icon size={p?.size||14} d="M22 3H2l8 9.5V21l4-2v-6.5z"/>,
};
/* ===== little brand glyphs (faux app icons) ===== */
const Brand = {
Notion: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#fff', display:'inline-flex',alignItems:'center',justifyContent:'center',
boxShadow:'0 0 0 1px rgba(0,0,0,0.08)', fontFamily:'serif', fontWeight:700, fontSize:s*0.65, color:'#111'}}>N</div>
),
YT: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#FF3B2D', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff'}}>
<svg width={s*0.55} height={s*0.55} viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>
</div>
),
Dribbble: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:'50%', background:'#ea4c89', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontWeight:800, fontSize: s*0.55, fontStyle:'italic'}}>·</div>
),
X: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#000', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontWeight:700, fontSize: s*0.55}}>𝕏</div>
),
Vercel: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#fff', display:'inline-flex',alignItems:'center',justifyContent:'center', boxShadow:'0 0 0 1px rgba(0,0,0,0.08)'}}>
<svg width={s*0.65} height={s*0.65} viewBox="0 0 24 24" fill="#000"><path d="M12 4 22 20H2z"/></svg>
</div>
),
Behance: ({s=22}) => (
<div style={{width:s, height:s, borderRadius:5, background:'#1769ff', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontWeight:800, fontSize: s*0.55, fontFamily:'serif'}}></div>
),
Figma: ({s=18}) => (
<div style={{width:s, height:s, position:'relative'}}>
<svg width={s} height={s} viewBox="0 0 24 24">
<path fill="#F24E1E" d="M8 2h4v6H8a3 3 0 0 1 0-6z"/>
<path fill="#A259FF" d="M12 2h4a3 3 0 0 1 0 6h-4z"/>
<path fill="#F24E1E" d="M8 8h4v6H8a3 3 0 0 1 0-6z" opacity="0"/>
<path fill="#1ABCFE" d="M12 8h.5a3 3 0 1 1-.5 6V8z"/>
<path fill="#0ACF83" d="M8 14h4v3a3 3 0 0 1-6 0 3 3 0 0 1 2-3z"/>
<path fill="#FF7262" d="M8 8h4v6H8a3 3 0 0 1 0-6z"/>
</svg>
</div>
),
Slack: ({s=18}) => (
<svg width={s} height={s} viewBox="0 0 24 24"><path fill="#36c5f0" d="M5 14a2 2 0 1 0-2-2h2zm1 0a2 2 0 1 1 4 0v5a2 2 0 1 1-4 0z"/><path fill="#2eb67d" d="M10 5a2 2 0 1 0 2-2v2zm0 1a2 2 0 1 1 0 4H5a2 2 0 1 1 0-4z"/><path fill="#ecb22e" d="M19 10a2 2 0 1 0 2 2h-2zm-1 0a2 2 0 1 1-4 0V5a2 2 0 1 1 4 0z"/><path fill="#e01e5a" d="M14 19a2 2 0 1 0-2 2v-2zm0-1a2 2 0 1 1 0-4h5a2 2 0 1 1 0 4z"/></svg>
),
GitHub: ({s=18}) => (
<svg width={s} height={s} viewBox="0 0 24 24" fill="#1d1c1a"><path d="M12 2a10 10 0 0 0-3.16 19.49c.5.09.66-.22.66-.48v-1.7c-2.78.6-3.37-1.34-3.37-1.34-.46-1.16-1.11-1.46-1.11-1.46-.91-.62.07-.6.07-.6 1 .07 1.53 1.03 1.53 1.03.89 1.52 2.34 1.08 2.91.83.09-.65.35-1.08.63-1.33-2.22-.25-4.55-1.11-4.55-4.94 0-1.09.39-1.98 1.03-2.68-.1-.25-.45-1.27.1-2.65 0 0 .84-.27 2.75 1.02a9.6 9.6 0 0 1 5 0c1.91-1.29 2.75-1.02 2.75-1.02.55 1.38.2 2.4.1 2.65.64.7 1.03 1.59 1.03 2.68 0 3.84-2.34 4.69-4.57 4.94.36.31.68.92.68 1.85v2.74c0 .27.16.58.67.48A10 10 0 0 0 12 2z"/></svg>
),
Linear: ({s=18}) => (
<div style={{width:s, height:s, borderRadius:'50%', background:'linear-gradient(135deg,#5e6ad2,#1d1c1a)', display:'inline-flex',alignItems:'center',justifyContent:'center', color:'#fff', fontSize:s*0.5, fontWeight:600, letterSpacing:'-0.05em'}}>L</div>
),
};
/* ============== SIDEBAR ============== */
const Sidebar = ({ activeId = 'home', mode = 'home', tabs, withTrafficTitle = true, workspace='Work', accentEmoji='🌸' }) => {
const defaultTabs = [
{ id:'figma', label:'Design System Figma', icon:<Brand.Figma s={16}/>, closable:true },
{ id:'roadmap', label:'Roadmap 2024', icon:<div style={{width:16,height:16,borderRadius:4, background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/> },
{ id:'mkt', label:'Marketing Site', icon:<Icon size={16} stroke="#6b6660"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon> },
];
const t = tabs || defaultTabs;
return (
<aside className="ely-sidebar">
{withTrafficTitle && (
<div className="ely-traffic">
<span className="dot red"/><span className="dot yellow"/><span className="dot green"/>
<span className="title">ELY Browser <I.ChevronDown size={12}/></span>
</div>
)}
<div className="ely-ws-row">
<div className="ely-ws-tile">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.6">
<rect x="3" y="4" width="8" height="16" rx="1.5"/>
<rect x="13" y="4" width="8" height="7" rx="1.5"/>
<rect x="13" y="13" width="8" height="7" rx="1.5"/>
</svg>
</div>
<div className="ely-ws-pick">
<span className="ws-glyph">{accentEmoji}</span>
<span className="grow">{workspace}</span>
<I.ChevronDown size={12}/>
</div>
<div className="ely-ws-add"><I.Plus size={14}/></div>
</div>
<div className="ely-nav">
<div className={'ely-nav-item' + (activeId==='home'?' active':'')}>
<span className="ico"><I.Home size={16}/></span>
<span className="label">Home</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile" style={{color:'#f24e1e'}}>31</span>
<span className="label">Calendar</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile"><Brand.Slack s={12}/></span>
<span className="label">Slack</span>
<span className="badge">12</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile"><Brand.GitHub s={12}/></span>
<span className="label">GitHub</span>
</div>
<div className="ely-nav-item">
<span className="ico-tile"><Brand.Linear s={12}/></span>
<span className="label">Linear</span>
<span className="badge">3</span>
</div>
</div>
<div className="ely-sec-label"><I.Pin size={11}/> Pinned</div>
<div className="ely-nav">
<div className="ely-nav-item">
<span className="ico"><Brand.Figma s={16}/></span>
<span className="label">Design System Figma</span>
</div>
<div className={'ely-nav-item' + (activeId==='ely'?' active':'')}>
<span className="ico"><div style={{width:16,height:16,borderRadius:'50%', background:'linear-gradient(135deg,#ec6a8e,#c96442)'}}/></span>
<span className="label">ELY Browser Home</span>
<span className="dot-unread"/>
</div>
<div className="ely-nav-item">
<span className="ico"><Icon size={16} stroke="#6b6660" d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M9 13h6M9 17h6M9 9h2"/></span>
<span className="label">Release Notes</span>
</div>
<div className="ely-nav-item">
<span className="ico"><div style={{width:16,height:16,borderRadius:4, background:'linear-gradient(135deg,#7c6cf7,#ec6a8e)'}}/></span>
<span className="label">Roadmap 2024</span>
<span className="dot-unread"/>
</div>
<div className="ely-nav-item">
<span className="ico"><Icon size={16} stroke="#6b6660"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a14 14 0 0 1 0 18"/></Icon></span>
<span className="label">Marketing Site</span>
</div>
</div>
<div className="ely-sec-label"><I.Tab size={11}/> Tabs · {t.length}</div>
<div className="ely-nav">
{t.map(tab => (
<div key={tab.id} className={'ely-nav-item' + (mode==='browse' && tab.id==='figma'?' active':'') + (tab.active?' active':'')}>
<span className="ico">{tab.icon}</span>
<span className="label">{tab.label}</span>
{tab.audio && <span className="ico" style={{color:'var(--ely-accent)'}}><I.Lightning size={12}/></span>}
<span className="close"><I.X size={12}/></span>
</div>
))}
<div className="ely-nav-item" style={{color:'var(--ely-ink-3)'}}>
<span className="ico"><I.Plus size={14}/></span>
<span className="label">New Tab</span>
</div>
</div>
<div className="ely-sidebar-foot">
<div className="ely-nav-item">
<span className="ico"><I.Settings size={15}/></span>
<span className="label">Settings</span>
</div>
<div className="ely-profile-row">
<div className="ely-avatar">A</div>
<span className="name">Alex</span>
<I.ChevronDown size={12}/>
</div>
</div>
</aside>
);
};
/* Browser shell that hosts a sidebar + main area */
const ElyShell = ({ wallpaper='dawn', children, sidebarProps={}, fakeTraffic=true }) => (
<div className="ely-window">
<div className={'ely-wallpaper ely-wp-' + wallpaper}/>
<div className="ely-shell">
<Sidebar {...sidebarProps} withTrafficTitle={fakeTraffic}/>
<main className="ely-main">{children}</main>
</div>
</div>
);
/* topbar */
const TopBar = ({ url, host, path, secure=true, right, canBack=true, canForward=false }) => (
<div className="ely-topbar">
<button className={'ely-iconbtn'+(canBack?'':' disabled')}><I.ArrowLeft size={16}/></button>
<button className={'ely-iconbtn'+(canForward?'':' disabled')}><I.ArrowRight size={16}/></button>
<div className="ely-omnibar">
{secure ? <I.Lock size={14}/> : <I.Globe size={14}/>}
{host ? (
<>
<span className="url-host">{host}</span>
{path && <span className="url-path">{path}</span>}
</>
) : (
<span>{url || 'Search ELY or type a command…'}</span>
)}
<span className="right">
<I.Filters size={14}/>
<I.Star size={14}/>
</span>
</div>
{right || (
<>
<button className="ely-iconbtn"><I.Copy size={16}/></button>
<button className="ely-iconbtn"><I.Download size={16}/></button>
<button className="ely-iconbtn"><I.Moon size={16}/></button>
<button className="ely-iconbtn"><I.Menu size={16}/></button>
</>
)}
</div>
);
window.Sidebar = Sidebar;
window.ElyShell = ElyShell;
window.TopBar = TopBar;
window.Icon = Icon;
window.I = I;
window.Brand = Brand;
+302
View File
@@ -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; }
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+2 -3
View File
@@ -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;
@@ -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<PathBuf> {
pub(crate) fn default_profile_data_root() -> Option<PathBuf> {
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<PathBuf, SystemTimeError> {
let timestamp = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos();
@@ -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<Self, ServoSidecarError> {
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<SidecarSnapshot, ServoSidecarError> {
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<SidecarSnapshot, ServoSidecarError> {
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<Output, ServoSidecarError> {
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<PathBuf, ServoSidecarError> {
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<String>,
title: Option<String>,
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<u8>,
}
impl SidecarSnapshot {
fn from_report(
report: SidecarReport,
expected_profile_id: &ProfileId,
rgba_bytes: Vec<u8>,
) -> Result<Self, ServoSidecarError> {
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<u8> {
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<String>,
title: Option<String>,
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<PathBuf, ServoSidecarError> {
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<SidecarSnapshot, ServoSidecarError> {
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<SidecarSnapshot, ServoSidecarError> {
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<usize, ServoSidecarError> {
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;
@@ -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<SidecarCommandTarget, ServoSidecarError> {
#[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<SidecarCommandTarget, SidecarCommandError> {
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() {
@@ -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<SidecarClickPoint>,
pub(in crate::services) typed_text: Option<String>,
pub(in crate::services) site_permissions: Vec<SidecarSitePermission>,
}
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<SidecarSitePermission>) -> 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
}
}
@@ -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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
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<dyn Error>> {
assert_live_sites_render(PRD_TOP_SITE_CASES)
}
#[cfg(feature = "live-site-smoke")]
#[test]
fn desktop_sidecar_opens_prd_reference_sites() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
assert_prd_reference_urls_are_covered()
}
#[cfg(feature = "live-site-smoke")]
fn assert_live_sites_render(cases: &[LiveSiteCase]) -> Result<(), Box<dyn Error>> {
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<SidecarSnapshot, Box<dyn Error>> {
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<u8> {
vec![0, 0, 0, 255, 255, 255, 255, 255]
}
+25 -4
View File
@@ -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<Self>) {
@@ -432,3 +436,20 @@ impl ElyShell {
self.zoom_active_tab_out(cx);
}
}
fn start_external_web_surface_timer(cx: &mut Context<ElyShell>) {
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();
}
+77 -39
View File
@@ -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<String>,
@@ -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<WebSurfaceClickPoint>,
typed_text: Option<String>,
snapshot: SidecarSnapshot,
frame: ServoLiveFrame,
) -> Result<Self, WebSurfaceError> {
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::<Rgba<u8>, _>::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<Self, WebSurfaceError> {
let Some(image_buffer) =
ImageBuffer::<Rgba<u8>, _>::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<WebSurfaceClickPoint> {
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<String>,
title: Option<String>,
render_state: String,
width: u32,
height: u32,
scroll_offset: WebSurfaceScrollOffset,
zoom_percent: u16,
click_point: Option<WebSurfaceClickPoint>,
typed_text: Option<String>,
#[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<u8>,
}
#[derive(Debug, Error)]
pub(super) enum WebSurfaceError {
#[error("invalid servo frame buffer for {width}x{height}")]
@@ -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<u32> {
@@ -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
}
@@ -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<Rgba<u8>, Vec<u8>>,
) -> ImageBuffer<Rgba<u8>, Vec<u8>> {
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,
}
}
@@ -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<dyn Error>> {
@@ -30,63 +41,110 @@ fn web_surface_cases_cover_prd_reference_urls() -> Result<(), Box<dyn Error>> {
#[test]
fn web_surface_opens_and_renders_prd_top_sites() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<dyn Error>>,
) -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
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<WebSurfaceFrame, Box<dyn Error>> {
fn render_web_surface_frame(
store: &mut WebSurfaceStore,
profile_id: &ProfileId,
case: &LiveSiteCase,
) -> Result<WebSurfaceFrame, Box<dyn Error>> {
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<WebSurfaceFrame, String> {
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<gpui::Pixels> {
)
}
fn web_tab(url: &str) -> Result<BrowserTab, Box<dyn Error>> {
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?))
fn web_tab(profile_id: ProfileId, url: &str) -> Result<BrowserTab, Box<dyn Error>> {
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), profile_id, "Web", UrlText::parse(url)?))
}
fn normalized_url(url: &str) -> &str {
@@ -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<SidecarSitePermission> {
) -> Vec<WebSurfaceSitePermission> {
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<dyn Error>> {
@@ -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(())
}
+27 -35
View File
@@ -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<dyn Error>> {
fn typed_text_enters_pending_input_after_clicked_viewport() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
fn scroll_delta_enters_pending_input_after_wheel() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
fn viewport_size_changes_after_stable_second_measurement() -> Result<(), Box<dyn Error>> {
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<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(640.0), px(480.0)))
}
fn resized_once_bounds() -> Bounds<gpui::Pixels> {
Bounds::new(point(px(0.0), px(0.0)), size(px(934.0), px(657.0)))
}
fn web_tab(url: &str) -> Result<BrowserTab, Box<dyn Error>> {
Ok(BrowserTab::new(TabId::new(), SpaceId::new(), ProfileId::new(), "Web", UrlText::parse(url)?))
}
@@ -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),
@@ -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<Item = String>) -> Result<LiveArgs, SidecarArgsError> {
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<Item = String>,
) -> Result<SnapshotArgs, SidecarArgsError> {
@@ -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<SidecarCommand, SidecarArgsError> {
parse_command(snapshot_command_args(profile_id, profile_data_dir))
) -> Result<super::SnapshotArgs, SidecarArgsError> {
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<String> {
+2 -1
View File
@@ -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<dyn RenderingContext>,
pub(super) webview: WebView,
pub(super) delegate: Rc<HostWebViewDelegate>,
pub(super) requested_url: Option<String>,
View File