Initialize Rust GPUI browser shell
This commit is contained in:
@@ -0,0 +1,36 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
pull_request:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
rust:
|
||||||
|
name: Rust workspace
|
||||||
|
runs-on: macos-15
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Use pinned Rust toolchain
|
||||||
|
run: rustup show
|
||||||
|
|
||||||
|
- name: Check formatting
|
||||||
|
run: cargo fmt --all --check
|
||||||
|
|
||||||
|
- name: Check workspace
|
||||||
|
run: cargo check --workspace --all-targets
|
||||||
|
|
||||||
|
- name: Lint workspace
|
||||||
|
run: cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
|
||||||
|
- name: Test workspace
|
||||||
|
run: cargo test --workspace --all-targets
|
||||||
|
|
||||||
|
- name: Audit source file size
|
||||||
|
run: scripts/audit_source_lines.sh
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
/target/
|
||||||
|
/references/
|
||||||
|
.agents/
|
||||||
|
.claude/
|
||||||
|
.DS_Store
|
||||||
|
*.log
|
||||||
|
*.tmp
|
||||||
Generated
+7824
File diff suppressed because it is too large
Load Diff
+33
@@ -0,0 +1,33 @@
|
|||||||
|
[workspace]
|
||||||
|
members = [
|
||||||
|
"crates/ely_app",
|
||||||
|
"crates/ely_browser_core",
|
||||||
|
"crates/ely_design_system",
|
||||||
|
"crates/ely_domain",
|
||||||
|
"crates/ely_servo_host",
|
||||||
|
]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
|
edition = "2024"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
rust-version = "1.95"
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
gpui = "0.2.2"
|
||||||
|
gpui-component = "0.5.1"
|
||||||
|
thiserror = "2.0.12"
|
||||||
|
url = "2.5.4"
|
||||||
|
uuid = { version = "1.12.1", features = ["v7"] }
|
||||||
|
|
||||||
|
[workspace.lints.rust]
|
||||||
|
unsafe_code = "deny"
|
||||||
|
|
||||||
|
[workspace.lints.clippy]
|
||||||
|
dbg_macro = "deny"
|
||||||
|
todo = "deny"
|
||||||
|
unwrap_used = "deny"
|
||||||
|
expect_used = "deny"
|
||||||
|
panic = "deny"
|
||||||
|
large_futures = "warn"
|
||||||
|
large_stack_frames = "warn"
|
||||||
@@ -0,0 +1,537 @@
|
|||||||
|
---
|
||||||
|
version: alpha
|
||||||
|
name: Cursor
|
||||||
|
description: An AI-first code editor whose marketing site reads like a quietly-confident developer-tools brand with a warm-cream editorial canvas (`#f7f7f4`) instead of the typical dark IDE atmosphere. Near-black warm ink (`#26251e`) carries body and display alike — display sits at weight 400 with negative letter-spacing for a magazine feel rather than a bold tech voice. The single brand voltage is **Cursor Orange** (`#f54e00`) reserved for primary CTAs and the wordmark. A signature pastel timeline palette (peach, mint, blue, lavender, gold) marks AI-action stages (Thinking / Reading / Editing / Grepping / Done) — only inside in-product timeline visualizations. Cards use minimal hairlines, no shadows, generous 80px section rhythm. CursorGothic for display/body, JetBrains Mono on every code surface (which is roughly half the page).
|
||||||
|
|
||||||
|
colors:
|
||||||
|
primary: "#f54e00"
|
||||||
|
primary-active: "#d04200"
|
||||||
|
ink: "#26251e"
|
||||||
|
body: "#5a5852"
|
||||||
|
body-strong: "#26251e"
|
||||||
|
muted: "#807d72"
|
||||||
|
muted-soft: "#a09c92"
|
||||||
|
hairline: "#e6e5e0"
|
||||||
|
hairline-soft: "#efeee8"
|
||||||
|
hairline-strong: "#cfcdc4"
|
||||||
|
canvas: "#f7f7f4"
|
||||||
|
canvas-soft: "#fafaf7"
|
||||||
|
surface-card: "#ffffff"
|
||||||
|
surface-strong: "#e6e5e0"
|
||||||
|
on-primary: "#ffffff"
|
||||||
|
timeline-thinking: "#dfa88f"
|
||||||
|
timeline-grep: "#9fc9a2"
|
||||||
|
timeline-read: "#9fbbe0"
|
||||||
|
timeline-edit: "#c0a8dd"
|
||||||
|
timeline-done: "#c08532"
|
||||||
|
semantic-error: "#cf2d56"
|
||||||
|
semantic-success: "#1f8a65"
|
||||||
|
|
||||||
|
typography:
|
||||||
|
display-mega:
|
||||||
|
fontFamily: "'CursorGothic', system-ui, 'Helvetica Neue', Helvetica, Arial, sans-serif"
|
||||||
|
fontSize: 72px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.1
|
||||||
|
letterSpacing: -2.16px
|
||||||
|
display-lg:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 36px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.2
|
||||||
|
letterSpacing: -0.72px
|
||||||
|
display-md:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 26px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.25
|
||||||
|
letterSpacing: -0.325px
|
||||||
|
display-sm:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 22px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.3
|
||||||
|
letterSpacing: -0.11px
|
||||||
|
title-md:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 18px
|
||||||
|
fontWeight: 600
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
title-sm:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 16px
|
||||||
|
fontWeight: 600
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
body-md:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 16px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: 0
|
||||||
|
body-tracked:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 16px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: 0.08px
|
||||||
|
body-sm:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 14px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: 0
|
||||||
|
caption:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 13px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
caption-uppercase:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 11px
|
||||||
|
fontWeight: 600
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0.88px
|
||||||
|
textTransform: uppercase
|
||||||
|
code:
|
||||||
|
fontFamily: "'JetBrains Mono', 'Fira Code', monospace"
|
||||||
|
fontSize: 13px
|
||||||
|
fontWeight: 400
|
||||||
|
lineHeight: 1.5
|
||||||
|
letterSpacing: 0
|
||||||
|
button:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 14px
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.0
|
||||||
|
letterSpacing: 0
|
||||||
|
nav-link:
|
||||||
|
fontFamily: "'CursorGothic', sans-serif"
|
||||||
|
fontSize: 14px
|
||||||
|
fontWeight: 500
|
||||||
|
lineHeight: 1.4
|
||||||
|
letterSpacing: 0
|
||||||
|
|
||||||
|
rounded:
|
||||||
|
none: 0px
|
||||||
|
xs: 4px
|
||||||
|
sm: 6px
|
||||||
|
md: 8px
|
||||||
|
lg: 12px
|
||||||
|
xl: 16px
|
||||||
|
pill: 9999px
|
||||||
|
full: 9999px
|
||||||
|
|
||||||
|
spacing:
|
||||||
|
xxs: 4px
|
||||||
|
xs: 8px
|
||||||
|
sm: 12px
|
||||||
|
base: 16px
|
||||||
|
md: 20px
|
||||||
|
lg: 24px
|
||||||
|
xl: 32px
|
||||||
|
xxl: 48px
|
||||||
|
section: 80px
|
||||||
|
|
||||||
|
components:
|
||||||
|
top-nav:
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.nav-link}"
|
||||||
|
height: 64px
|
||||||
|
button-primary:
|
||||||
|
backgroundColor: "{colors.primary}"
|
||||||
|
textColor: "{colors.on-primary}"
|
||||||
|
typography: "{typography.button}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: 10px 18px
|
||||||
|
height: 40px
|
||||||
|
button-primary-active:
|
||||||
|
backgroundColor: "{colors.primary-active}"
|
||||||
|
textColor: "{colors.on-primary}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
button-secondary:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.button}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: 9px 17px
|
||||||
|
height: 40px
|
||||||
|
button-tertiary-text:
|
||||||
|
backgroundColor: transparent
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.button}"
|
||||||
|
button-download:
|
||||||
|
backgroundColor: "{colors.ink}"
|
||||||
|
textColor: "{colors.canvas}"
|
||||||
|
typography: "{typography.button}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: 12px 20px
|
||||||
|
height: 44px
|
||||||
|
hero-band:
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.display-mega}"
|
||||||
|
padding: 80px
|
||||||
|
ide-mockup-card:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 0
|
||||||
|
ide-pane:
|
||||||
|
backgroundColor: "{colors.canvas-soft}"
|
||||||
|
textColor: "{colors.body}"
|
||||||
|
typography: "{typography.code}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: 16px
|
||||||
|
feature-card:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.title-md}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 24px
|
||||||
|
comparison-card:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.body-md}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 24px
|
||||||
|
timeline-pill-thinking:
|
||||||
|
backgroundColor: "{colors.timeline-thinking}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.caption-uppercase}"
|
||||||
|
rounded: "{rounded.pill}"
|
||||||
|
padding: 4px 10px
|
||||||
|
timeline-pill-grep:
|
||||||
|
backgroundColor: "{colors.timeline-grep}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.caption-uppercase}"
|
||||||
|
rounded: "{rounded.pill}"
|
||||||
|
padding: 4px 10px
|
||||||
|
timeline-pill-read:
|
||||||
|
backgroundColor: "{colors.timeline-read}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.caption-uppercase}"
|
||||||
|
rounded: "{rounded.pill}"
|
||||||
|
padding: 4px 10px
|
||||||
|
timeline-pill-edit:
|
||||||
|
backgroundColor: "{colors.timeline-edit}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.caption-uppercase}"
|
||||||
|
rounded: "{rounded.pill}"
|
||||||
|
padding: 4px 10px
|
||||||
|
timeline-pill-done:
|
||||||
|
backgroundColor: "{colors.timeline-done}"
|
||||||
|
textColor: "{colors.on-primary}"
|
||||||
|
typography: "{typography.caption-uppercase}"
|
||||||
|
rounded: "{rounded.pill}"
|
||||||
|
padding: 4px 10px
|
||||||
|
code-block:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.code}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 20px
|
||||||
|
pricing-tier-card:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.body-md}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 32px
|
||||||
|
pricing-tier-featured:
|
||||||
|
backgroundColor: "{colors.ink}"
|
||||||
|
textColor: "{colors.canvas}"
|
||||||
|
typography: "{typography.body-md}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 32px
|
||||||
|
text-input:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.body-md}"
|
||||||
|
rounded: "{rounded.md}"
|
||||||
|
padding: 12px 16px
|
||||||
|
height: 44px
|
||||||
|
badge-pill:
|
||||||
|
backgroundColor: "{colors.surface-strong}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.caption-uppercase}"
|
||||||
|
rounded: "{rounded.pill}"
|
||||||
|
padding: 4px 10px
|
||||||
|
cta-band:
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.ink}"
|
||||||
|
typography: "{typography.display-lg}"
|
||||||
|
padding: 96px
|
||||||
|
testimonial-card:
|
||||||
|
backgroundColor: "{colors.surface-card}"
|
||||||
|
textColor: "{colors.body}"
|
||||||
|
typography: "{typography.body-md}"
|
||||||
|
rounded: "{rounded.lg}"
|
||||||
|
padding: 24px
|
||||||
|
footer:
|
||||||
|
backgroundColor: "{colors.canvas}"
|
||||||
|
textColor: "{colors.body}"
|
||||||
|
typography: "{typography.body-sm}"
|
||||||
|
padding: 64px 48px
|
||||||
|
footer-link:
|
||||||
|
backgroundColor: transparent
|
||||||
|
textColor: "{colors.body}"
|
||||||
|
typography: "{typography.body-sm}"
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
Cursor's marketing site reads as a quietly-confident developer brand that believes in editorial calm over IDE-darkness. The base canvas is **warm cream** (`{colors.canvas}` — #f7f7f4) holding warm near-black ink (`{colors.ink}` — #26251e) for body and display alike. The single brand voltage is **Cursor Orange** (`{colors.primary}` — #f54e00) reserved for primary CTAs and the wordmark — used scarcely.
|
||||||
|
|
||||||
|
Type runs **CursorGothic** as the single sans family. Display sits at weight 400 with negative letter-spacing — a magazine-editorial voice rather than tech-bombastic. JetBrains Mono carries every code surface (and code surfaces are roughly half the page).
|
||||||
|
|
||||||
|
The brand's strongest visual signature is the **AI-timeline pill palette**: five pastel pills (peach `{colors.timeline-thinking}`, mint `{colors.timeline-grep}`, blue `{colors.timeline-read}`, lavender `{colors.timeline-edit}`, gold `{colors.timeline-done}`) marking AI-action stages inside in-product timeline visualizations. Used only in product UI — never as system action colors.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Warm cream canvas, not white. Ink is warm (#26251e), not pure black.
|
||||||
|
- Single CTA color: `{colors.primary}` (Cursor Orange #f54e00). Used scarcely.
|
||||||
|
- Display weight stays at 400 — never bold. Magazine voice.
|
||||||
|
- AI timeline pastels: 5 dedicated tokens for in-product agent action stages.
|
||||||
|
- Compact 8px CTA radius — developer dialect.
|
||||||
|
- Hairline-only depth; no drop shadows.
|
||||||
|
- 80px section rhythm.
|
||||||
|
|
||||||
|
## Colors
|
||||||
|
|
||||||
|
### Brand & Accent
|
||||||
|
- **Cursor Orange** (`{colors.primary}` — #f54e00): Primary CTA pills, wordmark, hero accent. Used scarcely.
|
||||||
|
- **Cursor Orange Active** (`{colors.primary-active}` — #d04200): Press state.
|
||||||
|
|
||||||
|
### Surface
|
||||||
|
- **Canvas** (`{colors.canvas}` — #f7f7f4): Warm cream page floor.
|
||||||
|
- **Canvas Soft** (`{colors.canvas-soft}` — #fafaf7): IDE-pane background inside mockups.
|
||||||
|
- **Surface Card** (`{colors.surface-card}` — #ffffff): Pure white card surface — slight contrast against the cream canvas.
|
||||||
|
- **Surface Strong** (`{colors.surface-strong}` — #e6e5e0): Badges, tag pills.
|
||||||
|
|
||||||
|
### Hairlines
|
||||||
|
- **Hairline** (`{colors.hairline}` — #e6e5e0): 1px divider.
|
||||||
|
- **Hairline Soft** (`{colors.hairline-soft}` — #efeee8): Lighter divider.
|
||||||
|
- **Hairline Strong** (`{colors.hairline-strong}` — #cfcdc4): Stronger panel outline.
|
||||||
|
|
||||||
|
### Text
|
||||||
|
- **Ink** (`{colors.ink}` — #26251e): Display, body emphasis. Warm near-black.
|
||||||
|
- **Body** (`{colors.body}` — #5a5852): Default running-text.
|
||||||
|
- **Body Strong** (`{colors.body-strong}` — #26251e): Same as ink.
|
||||||
|
- **Muted** (`{colors.muted}` — #807d72): Sub-titles.
|
||||||
|
- **Muted Soft** (`{colors.muted-soft}` — #a09c92): Disabled text.
|
||||||
|
- **On Primary** (`{colors.on-primary}` — #ffffff): White text on Cursor Orange.
|
||||||
|
|
||||||
|
### Timeline (AI-action signature)
|
||||||
|
- **Thinking** (`{colors.timeline-thinking}` — #dfa88f): Peach. Used inside in-product agent timeline only.
|
||||||
|
- **Grep** (`{colors.timeline-grep}` — #9fc9a2): Mint.
|
||||||
|
- **Read** (`{colors.timeline-read}` — #9fbbe0): Pastel blue.
|
||||||
|
- **Edit** (`{colors.timeline-edit}` — #c0a8dd): Lavender.
|
||||||
|
- **Done** (`{colors.timeline-done}` — #c08532): Warm gold.
|
||||||
|
|
||||||
|
### Semantic
|
||||||
|
- **Success** (`{colors.semantic-success}` — #1f8a65): Confirmation indicators.
|
||||||
|
- **Error** (`{colors.semantic-error}` — #cf2d56): Validation errors.
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
### Font Family
|
||||||
|
**CursorGothic** is the licensed display + body family. Fallback: `system-ui, "Helvetica Neue", Helvetica, Arial, sans-serif`. Code surfaces switch to **JetBrains Mono**.
|
||||||
|
|
||||||
|
### Hierarchy
|
||||||
|
|
||||||
|
| Token | Size | Weight | Line Height | Letter Spacing | Use |
|
||||||
|
|---|---|---|---|---|---|
|
||||||
|
| `{typography.display-mega}` | 72px | 400 | 1.1 | -2.16px | Homepage hero h1 |
|
||||||
|
| `{typography.display-lg}` | 36px | 400 | 1.2 | -0.72px | Section heads |
|
||||||
|
| `{typography.display-md}` | 26px | 400 | 1.25 | -0.325px | Sub-section heads |
|
||||||
|
| `{typography.display-sm}` | 22px | 400 | 1.3 | -0.11px | Card group titles |
|
||||||
|
| `{typography.title-md}` | 18px | 600 | 1.4 | 0 | Component titles |
|
||||||
|
| `{typography.title-sm}` | 16px | 600 | 1.4 | 0 | List labels |
|
||||||
|
| `{typography.body-md}` | 16px | 400 | 1.5 | 0 | Default body |
|
||||||
|
| `{typography.body-tracked}` | 16px | 400 | 1.5 | 0.08px | Tracked editorial body |
|
||||||
|
| `{typography.body-sm}` | 14px | 400 | 1.5 | 0 | Footer body |
|
||||||
|
| `{typography.caption}` | 13px | 400 | 1.4 | 0 | Photo captions |
|
||||||
|
| `{typography.caption-uppercase}` | 11px | 600 | 1.4 | 0.88px | Section labels, timeline pill labels |
|
||||||
|
| `{typography.code}` | 13px | 400 | 1.5 | 0 | Code blocks — JetBrains Mono |
|
||||||
|
| `{typography.button}` | 14px | 500 | 1.0 | 0 | CTA pill labels |
|
||||||
|
| `{typography.nav-link}` | 14px | 500 | 1.4 | 0 | Top-nav menu |
|
||||||
|
|
||||||
|
### Principles
|
||||||
|
- **Display weight stays at 400.** Magazine voice, never bold.
|
||||||
|
- **Negative letter-spacing on display only.** -0.11px to -2.16px tracking.
|
||||||
|
- **JetBrains Mono on every code surface.**
|
||||||
|
|
||||||
|
### Note on Font Substitutes
|
||||||
|
CursorGothic is licensed. Open-source substitute: **Inter** at weight 400 with letter-spacing -1.5%. Or **GT Sectra** for a more editorial feel.
|
||||||
|
|
||||||
|
## Layout
|
||||||
|
|
||||||
|
### Spacing System
|
||||||
|
- **Base unit:** 4px.
|
||||||
|
- **Tokens:** `{spacing.xxs}` 4px · `{spacing.xs}` 8px · `{spacing.sm}` 12px · `{spacing.base}` 16px · `{spacing.md}` 20px · `{spacing.lg}` 24px · `{spacing.xl}` 32px · `{spacing.xxl}` 48px · `{spacing.section}` 80px.
|
||||||
|
- **Section padding:** 80px.
|
||||||
|
|
||||||
|
### Grid & Container
|
||||||
|
- Max content width: ~1200px.
|
||||||
|
- Editorial body: 12-column grid.
|
||||||
|
- Feature card grids: 2-up at desktop for splits, 3-up for benefits.
|
||||||
|
- Footer: 5-column at desktop.
|
||||||
|
|
||||||
|
### Whitespace Philosophy
|
||||||
|
Generous editorial pacing — closer to a print magazine than a tech site. The cream canvas has plenty of breathing room; cards within bands sit close (16-24px gap).
|
||||||
|
|
||||||
|
## Elevation & Depth
|
||||||
|
|
||||||
|
The system uses **hairline-only depth**. No drop shadows, no elevation tiers. Cards float above the canvas via 1px hairlines and the slight white-on-cream contrast.
|
||||||
|
|
||||||
|
| Level | Treatment | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| Flat (canvas) | `{colors.canvas}` (#f7f7f4) | Body bands, footer |
|
||||||
|
| Card | `{colors.surface-card}` (#ffffff) | Content cards |
|
||||||
|
| Hairline border | 1px `{colors.hairline}` | Card outlines, dividers |
|
||||||
|
| IDE pane | `{colors.canvas-soft}` (#fafaf7) | Inside IDE mockup cards |
|
||||||
|
|
||||||
|
### Decorative Depth
|
||||||
|
- **IDE-mockup cards** are the only "elevated" element. White card on cream canvas with internal pane structure mimicking the actual Cursor editor.
|
||||||
|
- **Timeline pastel pills** add chromatic depth without surface elevation.
|
||||||
|
|
||||||
|
## Shapes
|
||||||
|
|
||||||
|
### Border Radius Scale
|
||||||
|
|
||||||
|
| Token | Value | Use |
|
||||||
|
|---|---|---|
|
||||||
|
| `{rounded.none}` | 0px | Reserved |
|
||||||
|
| `{rounded.xs}` | 4px | Inline tags |
|
||||||
|
| `{rounded.sm}` | 6px | Compact rows |
|
||||||
|
| `{rounded.md}` | 8px | CTA buttons, form inputs |
|
||||||
|
| `{rounded.lg}` | 12px | Cards, IDE panes |
|
||||||
|
| `{rounded.xl}` | 16px | Larger feature cards (rare) |
|
||||||
|
| `{rounded.pill}` | 9999px | Timeline pills, badges |
|
||||||
|
| `{rounded.full}` | 9999px | Avatars (rare) |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
### Top Navigation
|
||||||
|
|
||||||
|
**`top-nav`** — Background `{colors.canvas}`, text `{colors.ink}`, height 64px. Layout: Cursor wordmark left, primary horizontal menu (Pricing / Features / Enterprise / Blog / Forum / Careers), Sign In + Download primary CTA right.
|
||||||
|
|
||||||
|
### Buttons
|
||||||
|
|
||||||
|
**`button-primary`** — The signature Cursor Orange CTA. Background `{colors.primary}`, text `{colors.on-primary}`, type `{typography.button}` (14px / 500), padding 10px × 18px, height 40px, rounded `{rounded.md}` (8px).
|
||||||
|
|
||||||
|
**`button-primary-active`** — Press state. Background `{colors.primary-active}`.
|
||||||
|
|
||||||
|
**`button-secondary`** — White card pill on cream canvas. Background `{colors.surface-card}`, text `{colors.ink}`, 1px `{colors.hairline-strong}` border.
|
||||||
|
|
||||||
|
**`button-tertiary-text`** — Inline ink text link.
|
||||||
|
|
||||||
|
**`button-download`** — Larger ink-canvas CTA. Background `{colors.ink}`, text `{colors.canvas}`, padding 12px × 20px, height 44px. Used for "Download for macOS" type CTAs.
|
||||||
|
|
||||||
|
### Hero & IDE Mockups
|
||||||
|
|
||||||
|
**`hero-band`** — Background `{colors.canvas}`, full-width display headline in `{typography.display-mega}` (72px / 400 / -2.16px), subhead in `{typography.body-md}`, two CTAs (`button-download` + `button-tertiary-text`), and a centered IDE-mockup card below the hero copy.
|
||||||
|
|
||||||
|
**`ide-mockup-card`** — A white card containing a multi-pane IDE mockup (sidebar + main editor + chat panel + terminal). Background `{colors.surface-card}`, rounded `{rounded.lg}` (12px), 1px `{colors.hairline}` border, no padding (panes fill the card edge-to-edge).
|
||||||
|
|
||||||
|
**`ide-pane`** — Individual IDE pane inside the mockup. Background `{colors.canvas-soft}`, text `{colors.body}` in `{typography.code}` (JetBrains Mono 13px), rounded `{rounded.md}` (8px), padding 16px.
|
||||||
|
|
||||||
|
### Cards
|
||||||
|
|
||||||
|
**`feature-card`** — Background `{colors.surface-card}`, text `{colors.ink}`, type `{typography.title-md}`, rounded `{rounded.lg}`, padding 24px. 1px `{colors.hairline}` border.
|
||||||
|
|
||||||
|
**`comparison-card`** — Side-by-side "Cursor vs other tools" card. Same surface and rounding; internally split into 2 columns.
|
||||||
|
|
||||||
|
**`testimonial-card`** — Quote card. Background `{colors.surface-card}`, text `{colors.body}`, rounded `{rounded.lg}`, padding 24px.
|
||||||
|
|
||||||
|
### AI Timeline (signature)
|
||||||
|
|
||||||
|
**`timeline-pill-thinking`** — Peach pill. Background `{colors.timeline-thinking}`, text `{colors.ink}`, type `{typography.caption-uppercase}` (11px / 600 / 0.88px tracking, uppercase), rounded `{rounded.pill}`, padding 4px × 10px. Marks "Thinking" stage in product timeline.
|
||||||
|
|
||||||
|
**`timeline-pill-grep`** — Mint pill. Same shape, background `{colors.timeline-grep}`. Marks "Grepping" stage.
|
||||||
|
|
||||||
|
**`timeline-pill-read`** — Pastel-blue pill. Background `{colors.timeline-read}`. Marks "Reading" stage.
|
||||||
|
|
||||||
|
**`timeline-pill-edit`** — Lavender pill. Background `{colors.timeline-edit}`. Marks "Editing" stage.
|
||||||
|
|
||||||
|
**`timeline-pill-done`** — Gold pill. Background `{colors.timeline-done}`, text `{colors.on-primary}` white. Marks "Done" stage.
|
||||||
|
|
||||||
|
### Code
|
||||||
|
|
||||||
|
**`code-block`** — Inline code block. Background `{colors.surface-card}`, text `{colors.ink}` in `{typography.code}`, rounded `{rounded.lg}`, padding 20px, 1px `{colors.hairline}` border.
|
||||||
|
|
||||||
|
### Pricing
|
||||||
|
|
||||||
|
**`pricing-tier-card`** — Background `{colors.surface-card}`, rounded `{rounded.lg}`, padding 32px, 1px `{colors.hairline}` border.
|
||||||
|
|
||||||
|
**`pricing-tier-featured`** — Featured tier inverts to ink. Background `{colors.ink}`, text `{colors.canvas}`. Same shape, dark inversion signals "highlighted" without colored ribbon.
|
||||||
|
|
||||||
|
### Forms & Tags
|
||||||
|
|
||||||
|
**`text-input`** — Background `{colors.surface-card}`, text `{colors.ink}`, rounded `{rounded.md}` (8px), padding 12px × 16px, height 44px.
|
||||||
|
|
||||||
|
**`badge-pill`** — Small uppercase pill. Background `{colors.surface-strong}`, text `{colors.ink}`, type `{typography.caption-uppercase}`, rounded `{rounded.pill}`, padding 4px × 10px.
|
||||||
|
|
||||||
|
### CTA / Footer
|
||||||
|
|
||||||
|
**`cta-band`** — Pre-footer "Try Cursor now" band. Background `{colors.canvas}`, centered display headline in `{typography.display-lg}`, single Cursor Orange CTA. 96px vertical padding.
|
||||||
|
|
||||||
|
**`footer`** — Closing footer. Background `{colors.canvas}`, text `{colors.body}`. 5-column link list. 64×48px padding.
|
||||||
|
|
||||||
|
**`footer-link`** — Background transparent, text `{colors.body}`, type `{typography.body-sm}`.
|
||||||
|
|
||||||
|
## Do's and Don'ts
|
||||||
|
|
||||||
|
### Do
|
||||||
|
- Reserve `{colors.primary}` (Cursor Orange) for primary CTAs and brand wordmark.
|
||||||
|
- Keep display weight at 400. The editorial voice depends on this.
|
||||||
|
- Use the cream `{colors.canvas}` page floor — never pure white.
|
||||||
|
- Render every code surface (inline, blocks, IDE panes) in JetBrains Mono.
|
||||||
|
- Use timeline pastels only inside in-product agent visualizations — never as system action colors.
|
||||||
|
|
||||||
|
### Don't
|
||||||
|
- Don't introduce a secondary brand action color. Cursor Orange is the only one.
|
||||||
|
- Don't drop display to bold weights (700+). Magazine voice depends on 400.
|
||||||
|
- Don't add drop shadows. Hairlines + ink-on-cream contrast carry the depth.
|
||||||
|
- Don't use timeline pastels on non-timeline UI. They're scoped to the agent timeline only.
|
||||||
|
- Don't extract a CTA color from a third-party widget (cookie consent, OneTrust). The brand's CTA is what appears on actual product CTAs.
|
||||||
|
|
||||||
|
## Responsive Behavior
|
||||||
|
|
||||||
|
### Breakpoints
|
||||||
|
|
||||||
|
| Name | Width | Key Changes |
|
||||||
|
|---|---|---|
|
||||||
|
| Mobile | < 640px | Hero h1 72→32px; IDE mockup collapses to single pane preview; feature grid 1-up; nav hamburger. |
|
||||||
|
| Tablet | 640–1024px | Hero h1 56px; IDE mockup compresses; feature grid 2-up. |
|
||||||
|
| Desktop | 1024–1280px | Full hero h1 72px; full multi-pane IDE mockup; feature grid 3-up. |
|
||||||
|
| Wide | > 1280px | Content caps at 1200px. |
|
||||||
|
|
||||||
|
### Touch Targets
|
||||||
|
- Primary CTA at 40px height — at WCAG AA, padded for AAA.
|
||||||
|
- Download CTA at 44px — at AAA.
|
||||||
|
|
||||||
|
### Collapsing Strategy
|
||||||
|
- Top nav switches to hamburger below 768px.
|
||||||
|
- IDE mockup multi-pane collapses to a single primary pane preview on mobile.
|
||||||
|
- Feature grid: 3-up → 2-up → 1-up.
|
||||||
|
|
||||||
|
## Iteration Guide
|
||||||
|
|
||||||
|
1. Focus on a single component at a time.
|
||||||
|
2. CTAs default to `{rounded.md}` (8px). Cards use `{rounded.lg}` (12px).
|
||||||
|
3. Variants live as separate entries inside `components:`.
|
||||||
|
4. Use `{token.refs}` everywhere — never inline hex.
|
||||||
|
5. Hover state never documented.
|
||||||
|
6. CursorGothic 400 for display, 400/500/600 for body. JetBrains Mono on every code surface.
|
||||||
|
7. Cursor Orange stays scarce.
|
||||||
|
8. Timeline pastels stay scoped to in-product agent visualizations.
|
||||||
|
|
||||||
|
## Known Gaps
|
||||||
|
|
||||||
|
- CursorGothic is a licensed typeface; Inter is the substitute.
|
||||||
|
- Animation timings (timeline pill entrance, IDE pane reveal) out of scope.
|
||||||
|
- In-app surfaces (code editor, chat panel, agent timeline) only partially captured via marketing IDE mockups.
|
||||||
|
- Form validation states beyond focus not visible on captured surfaces.
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
# ELY Browser
|
||||||
|
|
||||||
|
Native Rust + GPUI browser workspace for ELY Browser by Elydora.
|
||||||
|
|
||||||
|
The repository is organized around explicit product boundaries from `PRD.md`:
|
||||||
|
domain state, browser orchestration, design tokens, GPUI shell, and Servo host contracts.
|
||||||
|
Reference GPUI ecosystem repositories live under `references/` for local review and are excluded
|
||||||
|
from version control.
|
||||||
|
|
||||||
|
## Local Commands
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo fmt --all --check
|
||||||
|
cargo check --workspace --all-targets
|
||||||
|
cargo clippy --workspace --all-targets -- -D warnings
|
||||||
|
cargo test --workspace --all-targets
|
||||||
|
cargo run -p ely_app
|
||||||
|
```
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[package]
|
||||||
|
name = "ely_app"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ely_browser_core = { path = "../ely_browser_core" }
|
||||||
|
ely_design_system = { path = "../ely_design_system" }
|
||||||
|
ely_domain = { path = "../ely_domain" }
|
||||||
|
gpui.workspace = true
|
||||||
|
gpui-component.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
mod shell;
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SystemMenuType, WindowBounds,
|
||||||
|
WindowOptions, actions, px, size,
|
||||||
|
};
|
||||||
|
use shell::ElyShell;
|
||||||
|
|
||||||
|
actions!(ely_app, [Quit]);
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
Application::new().run(|cx: &mut App| {
|
||||||
|
gpui_component::init(cx);
|
||||||
|
cx.on_action(quit);
|
||||||
|
cx.bind_keys([KeyBinding::new("cmd-q", Quit, None)]);
|
||||||
|
cx.set_menus(vec![Menu {
|
||||||
|
name: "ELY Browser".into(),
|
||||||
|
items: vec![
|
||||||
|
MenuItem::os_submenu("Services", SystemMenuType::Services),
|
||||||
|
MenuItem::separator(),
|
||||||
|
MenuItem::action("Quit ELY Browser", Quit),
|
||||||
|
],
|
||||||
|
}]);
|
||||||
|
|
||||||
|
let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx);
|
||||||
|
let opened = cx.open_window(
|
||||||
|
WindowOptions {
|
||||||
|
titlebar: None,
|
||||||
|
window_bounds: Some(WindowBounds::Windowed(bounds)),
|
||||||
|
..WindowOptions::default()
|
||||||
|
},
|
||||||
|
|window, cx| {
|
||||||
|
let shell = cx.new(|cx| ElyShell::new(window, cx));
|
||||||
|
cx.new(|cx| gpui_component::Root::new(shell, window, cx))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
if opened.is_ok() {
|
||||||
|
cx.activate(true);
|
||||||
|
} else {
|
||||||
|
cx.quit();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn quit(_: &Quit, cx: &mut App) {
|
||||||
|
cx.quit();
|
||||||
|
}
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
use ely_browser_core::{BrowserCore, BrowserSnapshot, InitialBrowserConfig};
|
||||||
|
use ely_design_system::{ELY_THEME, colors, spacing};
|
||||||
|
use ely_domain::{BrowserTab, CommandIntent, TabId, UrlText};
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement,
|
||||||
|
Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, div, px, rgb,
|
||||||
|
};
|
||||||
|
use gpui_component::{
|
||||||
|
Sizable, StyledExt,
|
||||||
|
button::{Button, ButtonVariants},
|
||||||
|
input::{Input, InputEvent, InputState},
|
||||||
|
};
|
||||||
|
|
||||||
|
enum ShellState {
|
||||||
|
Ready(BrowserCore),
|
||||||
|
StartupError(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ElyShell {
|
||||||
|
state: ShellState,
|
||||||
|
command_input: Entity<InputState>,
|
||||||
|
last_intent: Option<CommandIntent>,
|
||||||
|
_command_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ElyShell {
|
||||||
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
|
let command_input =
|
||||||
|
cx.new(|cx| InputState::new(window, cx).placeholder("Search or enter address"));
|
||||||
|
|
||||||
|
let command_subscription =
|
||||||
|
cx.subscribe(&command_input, |shell: &mut Self, input, event: &InputEvent, cx| {
|
||||||
|
let ShellState::Ready(core) = &mut shell.state else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let value = input.read(cx).value().to_string();
|
||||||
|
core.set_command_query(value);
|
||||||
|
|
||||||
|
if matches!(event, InputEvent::PressEnter { .. }) {
|
||||||
|
shell.last_intent = core.submit_command().ok().flatten();
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let state = match InitialBrowserConfig::ely_defaults().and_then(|config| {
|
||||||
|
BrowserCore::new(config).map_err(|error| match error {
|
||||||
|
ely_browser_core::CoreError::Domain(source) => source,
|
||||||
|
_ => ely_domain::DomainError::InvalidCommand,
|
||||||
|
})
|
||||||
|
}) {
|
||||||
|
Ok(core) => ShellState::Ready(core),
|
||||||
|
Err(error) => ShellState::StartupError(error.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
state,
|
||||||
|
command_input,
|
||||||
|
last_intent: None,
|
||||||
|
_command_subscription: command_subscription,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_new_tab(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if let ShellState::Ready(core) = &mut self.state
|
||||||
|
&& let Ok(url) = UrlText::parse("ely://new-tab")
|
||||||
|
{
|
||||||
|
core.open_tab(url);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn select_tab(&mut self, tab_id: &TabId, cx: &mut Context<Self>) {
|
||||||
|
if let ShellState::Ready(core) = &mut self.state
|
||||||
|
&& core.select_tab(tab_id).is_ok()
|
||||||
|
{
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ElyShell {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
match &self.state {
|
||||||
|
ShellState::Ready(core) => match (core.snapshot(), core.active_tab().cloned()) {
|
||||||
|
(Ok(snapshot), Ok(active_tab)) => self.render_browser(snapshot, active_tab, cx),
|
||||||
|
(Err(error), _) | (_, Err(error)) => render_error(error.to_string()),
|
||||||
|
},
|
||||||
|
ShellState::StartupError(message) => render_error(message.clone()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ElyShell {
|
||||||
|
fn render_browser(
|
||||||
|
&mut self,
|
||||||
|
snapshot: BrowserSnapshot,
|
||||||
|
active_tab: BrowserTab,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.bg(rgb(ELY_THEME.canvas))
|
||||||
|
.text_color(rgb(ELY_THEME.ink))
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.child(self.render_command_bar(&snapshot, cx))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex()
|
||||||
|
.flex_1()
|
||||||
|
.overflow_hidden()
|
||||||
|
.child(self.render_sidebar(&snapshot, cx))
|
||||||
|
.child(render_web_canvas(&active_tab)),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_command_bar(
|
||||||
|
&mut self,
|
||||||
|
snapshot: &BrowserSnapshot,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.h(px(spacing::COMMAND_BAR_HEIGHT))
|
||||||
|
.px_4()
|
||||||
|
.gap_3()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.border_b_1()
|
||||||
|
.border_color(rgb(colors::HAIRLINE))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.w(px(spacing::SIDEBAR_WIDTH - spacing::XL))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.child(div().text_size(px(18.0)).font_semibold().child("ELY Browser"))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(colors::MUTED))
|
||||||
|
.child(snapshot.active_space_name.clone()),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.h(px(40.0))
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(colors::HAIRLINE_STRONG))
|
||||||
|
.bg(rgb(colors::SURFACE_CARD))
|
||||||
|
.px_3()
|
||||||
|
.child(Input::new(&self.command_input).appearance(false).cleanable(true)),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Button::new("new-tab")
|
||||||
|
.primary()
|
||||||
|
.small()
|
||||||
|
.label("+")
|
||||||
|
.tooltip("New Tab")
|
||||||
|
.on_click(cx.listener(|shell, _, _, cx| shell.open_new_tab(cx))),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_sidebar(&mut self, snapshot: &BrowserSnapshot, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.w(px(spacing::SIDEBAR_WIDTH))
|
||||||
|
.h_full()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_3()
|
||||||
|
.p_3()
|
||||||
|
.border_r_1()
|
||||||
|
.border_color(rgb(colors::HAIRLINE))
|
||||||
|
.bg(rgb(colors::CANVAS))
|
||||||
|
.child(section_label("Favorites"))
|
||||||
|
.child(empty_line("Pinned cross-space tabs appear here"))
|
||||||
|
.child(section_label("Space"))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.rounded_md()
|
||||||
|
.bg(rgb(colors::SURFACE_CARD))
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(colors::HAIRLINE))
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.child(snapshot.active_space_name.clone()),
|
||||||
|
)
|
||||||
|
.child(section_label("Tabs"))
|
||||||
|
.children(
|
||||||
|
snapshot
|
||||||
|
.tabs
|
||||||
|
.iter()
|
||||||
|
.map(|tab| self.render_tab_row(tab, tab.id() == &snapshot.active_tab_id, cx)),
|
||||||
|
)
|
||||||
|
.child(div().flex_1())
|
||||||
|
.child(section_label("Profile"))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(colors::BODY))
|
||||||
|
.child(snapshot.active_profile_name.clone()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_tab_row(
|
||||||
|
&mut self,
|
||||||
|
tab: &BrowserTab,
|
||||||
|
active: bool,
|
||||||
|
cx: &mut Context<Self>,
|
||||||
|
) -> AnyElement {
|
||||||
|
let tab_id = tab.id().clone();
|
||||||
|
let background = if active { colors::SURFACE_CARD } else { colors::CANVAS };
|
||||||
|
let border = if active { colors::HAIRLINE_STRONG } else { colors::HAIRLINE };
|
||||||
|
|
||||||
|
div()
|
||||||
|
.id(SharedString::from(tab.id().as_str().to_string()))
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(border))
|
||||||
|
.bg(rgb(background))
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.gap_1()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|style| style.bg(rgb(colors::SURFACE_CARD)))
|
||||||
|
.active(|style| style.opacity(0.82))
|
||||||
|
.on_click(cx.listener(move |shell, _, _, cx| shell.select_tab(&tab_id, cx)))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.font_semibold()
|
||||||
|
.text_color(rgb(colors::INK))
|
||||||
|
.child(tab.title().to_string()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.text_color(rgb(colors::MUTED))
|
||||||
|
.child(tab.url().as_str().to_string()),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_web_canvas(tab: &BrowserTab) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.flex_1()
|
||||||
|
.h_full()
|
||||||
|
.p_6()
|
||||||
|
.bg(rgb(colors::CANVAS_SOFT))
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.rounded_lg()
|
||||||
|
.border_1()
|
||||||
|
.border_color(rgb(colors::HAIRLINE))
|
||||||
|
.bg(rgb(colors::SURFACE_CARD))
|
||||||
|
.p_8()
|
||||||
|
.flex()
|
||||||
|
.flex_col()
|
||||||
|
.gap_4()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_size(px(26.0))
|
||||||
|
.text_color(rgb(colors::INK))
|
||||||
|
.child(tab.title().to_string()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(colors::MUTED))
|
||||||
|
.child(tab.url().as_str().to_string()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.mt_4()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(rgb(colors::BODY))
|
||||||
|
.child("Servo host boundary owns webpage rendering, input, permissions, downloads, and recovery."),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_error(message: String) -> AnyElement {
|
||||||
|
div()
|
||||||
|
.size_full()
|
||||||
|
.bg(rgb(colors::CANVAS))
|
||||||
|
.text_color(rgb(colors::ERROR))
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_center()
|
||||||
|
.child(message)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn section_label(label: &'static str) -> impl IntoElement {
|
||||||
|
div().text_xs().font_semibold().text_color(rgb(colors::MUTED)).child(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn empty_line(text: &'static str) -> impl IntoElement {
|
||||||
|
div().text_xs().text_color(rgb(colors::MUTED_SOFT)).child(text)
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "ely_browser_core"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ely_domain = { path = "../ely_domain" }
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
use ely_domain::{DomainError, TabId};
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum CoreError {
|
||||||
|
#[error(transparent)]
|
||||||
|
Domain(#[from] DomainError),
|
||||||
|
|
||||||
|
#[error("tab not found: {id}")]
|
||||||
|
TabNotFound { id: TabId },
|
||||||
|
|
||||||
|
#[error("browser state has no active tab")]
|
||||||
|
MissingActiveTab,
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod error;
|
||||||
|
mod state;
|
||||||
|
|
||||||
|
pub use error::CoreError;
|
||||||
|
pub use state::{BrowserCore, BrowserSnapshot, InitialBrowserConfig};
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
use ely_domain::{
|
||||||
|
BrowserTab, CommandIntent, DomainError, Profile, ProfileId, ProfileKind, Space, SpaceId, TabId,
|
||||||
|
UrlText,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::CoreError;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct InitialBrowserConfig {
|
||||||
|
pub space_name: String,
|
||||||
|
pub space_icon: String,
|
||||||
|
pub profile_name: String,
|
||||||
|
pub initial_url: UrlText,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InitialBrowserConfig {
|
||||||
|
pub fn ely_defaults() -> Result<Self, DomainError> {
|
||||||
|
Ok(Self {
|
||||||
|
space_name: "Work".to_string(),
|
||||||
|
space_icon: "W".to_string(),
|
||||||
|
profile_name: "Default".to_string(),
|
||||||
|
initial_url: UrlText::parse("ely://new-tab")?,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct BrowserSnapshot {
|
||||||
|
pub tabs: Vec<BrowserTab>,
|
||||||
|
pub active_tab_id: TabId,
|
||||||
|
pub active_space_name: String,
|
||||||
|
pub active_profile_name: String,
|
||||||
|
pub command_query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct BrowserCore {
|
||||||
|
spaces: Vec<Space>,
|
||||||
|
profiles: Vec<Profile>,
|
||||||
|
tabs: Vec<BrowserTab>,
|
||||||
|
active_space_id: SpaceId,
|
||||||
|
active_profile_id: ProfileId,
|
||||||
|
active_tab_id: TabId,
|
||||||
|
command_query: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserCore {
|
||||||
|
pub fn new(config: InitialBrowserConfig) -> Result<Self, CoreError> {
|
||||||
|
let space = Space::new(config.space_name, config.space_icon, 0xf54e00);
|
||||||
|
let profile = Profile::new(config.profile_name, 0x26251e, ProfileKind::Standard);
|
||||||
|
let tab = BrowserTab::new(
|
||||||
|
TabId::new(),
|
||||||
|
space.id().clone(),
|
||||||
|
profile.id().clone(),
|
||||||
|
"New Tab",
|
||||||
|
config.initial_url,
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(Self {
|
||||||
|
active_space_id: space.id().clone(),
|
||||||
|
active_profile_id: profile.id().clone(),
|
||||||
|
active_tab_id: tab.id().clone(),
|
||||||
|
spaces: vec![space],
|
||||||
|
profiles: vec![profile],
|
||||||
|
tabs: vec![tab],
|
||||||
|
command_query: String::new(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open_tab(&mut self, url: UrlText) -> TabId {
|
||||||
|
let title = tab_title(&url);
|
||||||
|
let tab = BrowserTab::new(
|
||||||
|
TabId::new(),
|
||||||
|
self.active_space_id.clone(),
|
||||||
|
self.active_profile_id.clone(),
|
||||||
|
title,
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
let tab_id = tab.id().clone();
|
||||||
|
self.tabs.push(tab);
|
||||||
|
self.active_tab_id = tab_id.clone();
|
||||||
|
tab_id
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn select_tab(&mut self, tab_id: &TabId) -> Result<(), CoreError> {
|
||||||
|
if self.tabs.iter().any(|tab| tab.id() == tab_id) {
|
||||||
|
self.active_tab_id = tab_id.clone();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(CoreError::TabNotFound { id: tab_id.clone() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_command_query(&mut self, query: impl Into<String>) {
|
||||||
|
self.command_query = query.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn submit_command(&mut self) -> Result<Option<CommandIntent>, CoreError> {
|
||||||
|
let query = self.command_query.trim();
|
||||||
|
if query.is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let intent = CommandIntent::parse(query)?;
|
||||||
|
if let CommandIntent::Navigate(url) = &intent {
|
||||||
|
self.open_tab(url.clone());
|
||||||
|
self.command_query.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Some(intent))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn snapshot(&self) -> Result<BrowserSnapshot, CoreError> {
|
||||||
|
let active_space = self
|
||||||
|
.spaces
|
||||||
|
.iter()
|
||||||
|
.find(|space| space.id() == &self.active_space_id)
|
||||||
|
.ok_or(CoreError::MissingActiveTab)?;
|
||||||
|
let active_profile = self
|
||||||
|
.profiles
|
||||||
|
.iter()
|
||||||
|
.find(|profile| profile.id() == &self.active_profile_id)
|
||||||
|
.ok_or(CoreError::MissingActiveTab)?;
|
||||||
|
|
||||||
|
Ok(BrowserSnapshot {
|
||||||
|
tabs: self.tabs.clone(),
|
||||||
|
active_tab_id: self.active_tab_id.clone(),
|
||||||
|
active_space_name: active_space.name().to_string(),
|
||||||
|
active_profile_name: active_profile.name().to_string(),
|
||||||
|
command_query: self.command_query.clone(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_tab(&self) -> Result<&BrowserTab, CoreError> {
|
||||||
|
self.tabs
|
||||||
|
.iter()
|
||||||
|
.find(|tab| tab.id() == &self.active_tab_id)
|
||||||
|
.ok_or(CoreError::MissingActiveTab)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tab_title(url: &UrlText) -> String {
|
||||||
|
if url.as_str() == "ely://new-tab" {
|
||||||
|
return "New Tab".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
url.display_host()
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
[package]
|
||||||
|
name = "ely_design_system"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
pub const PRIMARY: u32 = 0xf54e00;
|
||||||
|
pub const PRIMARY_ACTIVE: u32 = 0xd04200;
|
||||||
|
pub const INK: u32 = 0x26251e;
|
||||||
|
pub const BODY: u32 = 0x5a5852;
|
||||||
|
pub const MUTED: u32 = 0x807d72;
|
||||||
|
pub const MUTED_SOFT: u32 = 0xa09c92;
|
||||||
|
pub const HAIRLINE: u32 = 0xe6e5e0;
|
||||||
|
pub const HAIRLINE_STRONG: u32 = 0xcfcdc4;
|
||||||
|
pub const CANVAS: u32 = 0xf7f7f4;
|
||||||
|
pub const CANVAS_SOFT: u32 = 0xfafaf7;
|
||||||
|
pub const SURFACE_CARD: u32 = 0xffffff;
|
||||||
|
pub const SURFACE_STRONG: u32 = 0xe6e5e0;
|
||||||
|
pub const SUCCESS: u32 = 0x1f8a65;
|
||||||
|
pub const ERROR: u32 = 0xcf2d56;
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
pub mod colors;
|
||||||
|
pub mod motion;
|
||||||
|
pub mod spacing;
|
||||||
|
pub mod typography;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Theme {
|
||||||
|
pub canvas: u32,
|
||||||
|
pub canvas_soft: u32,
|
||||||
|
pub surface: u32,
|
||||||
|
pub ink: u32,
|
||||||
|
pub body: u32,
|
||||||
|
pub muted: u32,
|
||||||
|
pub hairline: u32,
|
||||||
|
pub accent: u32,
|
||||||
|
pub success: u32,
|
||||||
|
pub error: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const ELY_THEME: Theme = Theme {
|
||||||
|
canvas: colors::CANVAS,
|
||||||
|
canvas_soft: colors::CANVAS_SOFT,
|
||||||
|
surface: colors::SURFACE_CARD,
|
||||||
|
ink: colors::INK,
|
||||||
|
body: colors::BODY,
|
||||||
|
muted: colors::MUTED,
|
||||||
|
hairline: colors::HAIRLINE,
|
||||||
|
accent: colors::PRIMARY,
|
||||||
|
success: colors::SUCCESS,
|
||||||
|
error: colors::ERROR,
|
||||||
|
};
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub enum MotionRegister {
|
||||||
|
Productive,
|
||||||
|
Expressive,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
pub struct MotionToken {
|
||||||
|
pub register: MotionRegister,
|
||||||
|
pub duration_ms: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const HOVER_FEEDBACK: MotionToken =
|
||||||
|
MotionToken { register: MotionRegister::Productive, duration_ms: 120 };
|
||||||
|
|
||||||
|
pub const PANEL_TRANSITION: MotionToken =
|
||||||
|
MotionToken { register: MotionRegister::Productive, duration_ms: 180 };
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
pub const XXS: f32 = 4.0;
|
||||||
|
pub const XS: f32 = 8.0;
|
||||||
|
pub const SM: f32 = 12.0;
|
||||||
|
pub const BASE: f32 = 16.0;
|
||||||
|
pub const MD: f32 = 20.0;
|
||||||
|
pub const LG: f32 = 24.0;
|
||||||
|
pub const XL: f32 = 32.0;
|
||||||
|
pub const XXL: f32 = 48.0;
|
||||||
|
pub const SIDEBAR_WIDTH: f32 = 280.0;
|
||||||
|
pub const SIDEBAR_COLLAPSED_WIDTH: f32 = 56.0;
|
||||||
|
pub const COMMAND_BAR_HEIGHT: f32 = 64.0;
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||||
|
pub struct TypeToken {
|
||||||
|
pub size_px: f32,
|
||||||
|
pub line_height: f32,
|
||||||
|
pub weight: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub const DISPLAY_MD: TypeToken = TypeToken { size_px: 26.0, line_height: 1.25, weight: 400 };
|
||||||
|
|
||||||
|
pub const TITLE_MD: TypeToken = TypeToken { size_px: 18.0, line_height: 1.4, weight: 600 };
|
||||||
|
|
||||||
|
pub const BODY_MD: TypeToken = TypeToken { size_px: 16.0, line_height: 1.5, weight: 400 };
|
||||||
|
|
||||||
|
pub const CAPTION: TypeToken = TypeToken { size_px: 13.0, line_height: 1.4, weight: 400 };
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "ely_domain"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
thiserror.workspace = true
|
||||||
|
url.workspace = true
|
||||||
|
uuid.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use crate::{DomainError, UrlText};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CommandScope {
|
||||||
|
Tabs,
|
||||||
|
Bookmarks,
|
||||||
|
History,
|
||||||
|
Settings,
|
||||||
|
Plugins,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum CommandIntent {
|
||||||
|
Navigate(UrlText),
|
||||||
|
Search(String),
|
||||||
|
Command(String),
|
||||||
|
ScopedSearch { scope: CommandScope, query: String },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandIntent {
|
||||||
|
pub fn parse(input: &str) -> Result<Self, DomainError> {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(DomainError::InvalidCommand);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(command) = trimmed.strip_prefix('>') {
|
||||||
|
return non_empty_text(command).map(Self::Command);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(query) = trimmed.strip_prefix('?') {
|
||||||
|
return non_empty_text(query).map(Self::Search);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some((scope, query)) = parse_scope(trimmed) {
|
||||||
|
return non_empty_text(query).map(|query| Self::ScopedSearch { scope, query });
|
||||||
|
}
|
||||||
|
|
||||||
|
UrlText::from_address_text(trimmed).map(Self::Navigate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_scope(value: &str) -> Option<(CommandScope, &str)> {
|
||||||
|
let (scope, query) = value.split_once(' ')?;
|
||||||
|
let scope = match scope {
|
||||||
|
"@tabs" => CommandScope::Tabs,
|
||||||
|
"@bookmarks" => CommandScope::Bookmarks,
|
||||||
|
"@history" => CommandScope::History,
|
||||||
|
"@settings" => CommandScope::Settings,
|
||||||
|
"@plugins" => CommandScope::Plugins,
|
||||||
|
_ => return None,
|
||||||
|
};
|
||||||
|
Some((scope, query))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn non_empty_text(value: &str) -> Result<String, DomainError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(DomainError::InvalidCommand);
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum DomainError {
|
||||||
|
#[error("{field} cannot be empty")]
|
||||||
|
EmptyField { field: &'static str },
|
||||||
|
|
||||||
|
#[error("invalid URL: {value}")]
|
||||||
|
InvalidUrl { value: String },
|
||||||
|
|
||||||
|
#[error("invalid command query")]
|
||||||
|
InvalidCommand,
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
macro_rules! entity_id {
|
||||||
|
($name:ident, $prefix:literal) => {
|
||||||
|
#[derive(Clone, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
|
||||||
|
pub struct $name(String);
|
||||||
|
|
||||||
|
impl $name {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self(format!("{}_{}", $prefix, Uuid::now_v7().simple()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for $name {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for $name {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
entity_id!(TabId, "tab");
|
||||||
|
entity_id!(SpaceId, "space");
|
||||||
|
entity_id!(ProfileId, "profile");
|
||||||
|
entity_id!(SplitId, "split");
|
||||||
|
entity_id!(WebViewId, "webview");
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
mod command;
|
||||||
|
mod error;
|
||||||
|
mod identifiers;
|
||||||
|
mod profile;
|
||||||
|
mod space;
|
||||||
|
mod split;
|
||||||
|
mod tab;
|
||||||
|
mod url_text;
|
||||||
|
|
||||||
|
pub use command::{CommandIntent, CommandScope};
|
||||||
|
pub use error::DomainError;
|
||||||
|
pub use identifiers::{ProfileId, SpaceId, SplitId, TabId, WebViewId};
|
||||||
|
pub use profile::{Profile, ProfileKind};
|
||||||
|
pub use space::{ArchivePolicy, Space};
|
||||||
|
pub use split::{SplitAxis, SplitLayout, SplitPane};
|
||||||
|
pub use tab::{BrowserTab, TabFlags, TabState};
|
||||||
|
pub use url_text::UrlText;
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use crate::ProfileId;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ProfileKind {
|
||||||
|
Standard,
|
||||||
|
Private,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Profile {
|
||||||
|
id: ProfileId,
|
||||||
|
name: String,
|
||||||
|
color_hex: u32,
|
||||||
|
kind: ProfileKind,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Profile {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(name: impl Into<String>, color_hex: u32, kind: ProfileKind) -> Self {
|
||||||
|
Self { id: ProfileId::new(), name: name.into(), color_hex, kind }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn id(&self) -> &ProfileId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn color_hex(&self) -> u32 {
|
||||||
|
self.color_hex
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn kind(&self) -> &ProfileKind {
|
||||||
|
&self.kind
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
use crate::SpaceId;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum ArchivePolicy {
|
||||||
|
Manual,
|
||||||
|
IdleDays(u16),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct Space {
|
||||||
|
id: SpaceId,
|
||||||
|
name: String,
|
||||||
|
icon: String,
|
||||||
|
accent_hex: u32,
|
||||||
|
archive_policy: ArchivePolicy,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Space {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(name: impl Into<String>, icon: impl Into<String>, accent_hex: u32) -> Self {
|
||||||
|
Self {
|
||||||
|
id: SpaceId::new(),
|
||||||
|
name: name.into(),
|
||||||
|
icon: icon.into(),
|
||||||
|
accent_hex,
|
||||||
|
archive_policy: ArchivePolicy::Manual,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn id(&self) -> &SpaceId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn name(&self) -> &str {
|
||||||
|
&self.name
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn icon(&self) -> &str {
|
||||||
|
&self.icon
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn accent_hex(&self) -> u32 {
|
||||||
|
self.accent_hex
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn archive_policy(&self) -> &ArchivePolicy {
|
||||||
|
&self.archive_policy
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use crate::{SplitId, TabId};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum SplitAxis {
|
||||||
|
Horizontal,
|
||||||
|
Vertical,
|
||||||
|
Grid,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SplitPane {
|
||||||
|
tab_id: TabId,
|
||||||
|
weight: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SplitPane {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(tab_id: TabId, weight: u16) -> Self {
|
||||||
|
Self { tab_id, weight }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn tab_id(&self) -> &TabId {
|
||||||
|
&self.tab_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn weight(&self) -> u16 {
|
||||||
|
self.weight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct SplitLayout {
|
||||||
|
id: SplitId,
|
||||||
|
axis: SplitAxis,
|
||||||
|
panes: Vec<SplitPane>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SplitLayout {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(axis: SplitAxis, panes: Vec<SplitPane>) -> Self {
|
||||||
|
Self { id: SplitId::new(), axis, panes }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn id(&self) -> &SplitId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn axis(&self) -> &SplitAxis {
|
||||||
|
&self.axis
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn panes(&self) -> &[SplitPane] {
|
||||||
|
&self.panes
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
use crate::{ProfileId, SpaceId, SplitId, TabId, UrlText};
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum TabState {
|
||||||
|
Loading,
|
||||||
|
Ready,
|
||||||
|
Crashed,
|
||||||
|
Discarded,
|
||||||
|
Archived,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, Eq, PartialEq)]
|
||||||
|
pub struct TabFlags {
|
||||||
|
pub pinned: bool,
|
||||||
|
pub favorite: bool,
|
||||||
|
pub muted: bool,
|
||||||
|
pub unread: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct BrowserTab {
|
||||||
|
id: TabId,
|
||||||
|
space_id: SpaceId,
|
||||||
|
profile_id: ProfileId,
|
||||||
|
title: String,
|
||||||
|
url: UrlText,
|
||||||
|
state: TabState,
|
||||||
|
flags: TabFlags,
|
||||||
|
split_id: Option<SplitId>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BrowserTab {
|
||||||
|
#[must_use]
|
||||||
|
pub fn new(
|
||||||
|
id: TabId,
|
||||||
|
space_id: SpaceId,
|
||||||
|
profile_id: ProfileId,
|
||||||
|
title: impl Into<String>,
|
||||||
|
url: UrlText,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
space_id,
|
||||||
|
profile_id,
|
||||||
|
title: title.into(),
|
||||||
|
url,
|
||||||
|
state: TabState::Ready,
|
||||||
|
flags: TabFlags::default(),
|
||||||
|
split_id: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn id(&self) -> &TabId {
|
||||||
|
&self.id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn space_id(&self) -> &SpaceId {
|
||||||
|
&self.space_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn profile_id(&self) -> &ProfileId {
|
||||||
|
&self.profile_id
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn title(&self) -> &str {
|
||||||
|
&self.title
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn url(&self) -> &UrlText {
|
||||||
|
&self.url
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn state(&self) -> &TabState {
|
||||||
|
&self.state
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn flags(&self) -> &TabFlags {
|
||||||
|
&self.flags
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn split_id(&self) -> Option<&SplitId> {
|
||||||
|
self.split_id.as_ref()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use url::Url;
|
||||||
|
|
||||||
|
use crate::DomainError;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct UrlText {
|
||||||
|
value: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UrlText {
|
||||||
|
pub fn parse(value: impl Into<String>) -> Result<Self, DomainError> {
|
||||||
|
let value = value.into();
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(DomainError::EmptyField { field: "url" });
|
||||||
|
}
|
||||||
|
|
||||||
|
Url::parse(trimmed).map_err(|_| DomainError::InvalidUrl { value: trimmed.to_string() })?;
|
||||||
|
|
||||||
|
Ok(Self { value: trimmed.to_string() })
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_address_text(value: &str) -> Result<Self, DomainError> {
|
||||||
|
let trimmed = value.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return Err(DomainError::EmptyField { field: "url" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if Url::parse(trimmed).is_ok() {
|
||||||
|
return Self::parse(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if trimmed.contains('.') && !trimmed.contains(char::is_whitespace) {
|
||||||
|
return Self::parse(format!("https://{trimmed}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(DomainError::InvalidUrl { value: trimmed.to_string() })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn as_str(&self) -> &str {
|
||||||
|
&self.value
|
||||||
|
}
|
||||||
|
|
||||||
|
#[must_use]
|
||||||
|
pub fn display_host(&self) -> String {
|
||||||
|
Url::parse(&self.value)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.host_str().map(str::to_string))
|
||||||
|
.unwrap_or_else(|| self.value.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for UrlText {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(&self.value)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "ely_servo_host"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
rust-version.workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
ely_domain = { path = "../ely_domain" }
|
||||||
|
thiserror.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
use ely_domain::WebViewId;
|
||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Error, Eq, PartialEq)]
|
||||||
|
pub enum ServoHostError {
|
||||||
|
#[error("webview not found: {id}")]
|
||||||
|
WebViewNotFound { id: WebViewId },
|
||||||
|
|
||||||
|
#[error("permission request missing profile context")]
|
||||||
|
MissingProfileContext,
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
use ely_domain::{ProfileId, TabId, UrlText, WebViewId};
|
||||||
|
|
||||||
|
use crate::ServoHostError;
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum WebViewState {
|
||||||
|
Created,
|
||||||
|
Attached,
|
||||||
|
Sleeping,
|
||||||
|
Crashed,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct NavigationRequest {
|
||||||
|
pub webview_id: WebViewId,
|
||||||
|
pub tab_id: TabId,
|
||||||
|
pub url: UrlText,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub struct PermissionRequest {
|
||||||
|
pub webview_id: WebViewId,
|
||||||
|
pub tab_id: TabId,
|
||||||
|
pub profile_id: ProfileId,
|
||||||
|
pub feature: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
||||||
|
pub enum PermissionDecision {
|
||||||
|
AllowOnce,
|
||||||
|
AllowAlways,
|
||||||
|
DenyAlways,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub trait ServoHost {
|
||||||
|
fn create_webview(
|
||||||
|
&mut self,
|
||||||
|
tab_id: TabId,
|
||||||
|
profile_id: ProfileId,
|
||||||
|
) -> Result<WebViewId, ServoHostError>;
|
||||||
|
|
||||||
|
fn navigate(&mut self, request: NavigationRequest) -> Result<(), ServoHostError>;
|
||||||
|
|
||||||
|
fn set_permission(
|
||||||
|
&mut self,
|
||||||
|
request: PermissionRequest,
|
||||||
|
decision: PermissionDecision,
|
||||||
|
) -> Result<(), ServoHostError>;
|
||||||
|
|
||||||
|
fn state(&self, webview_id: &WebViewId) -> Result<WebViewState, ServoHostError>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
mod error;
|
||||||
|
mod host;
|
||||||
|
|
||||||
|
pub use error::ServoHostError;
|
||||||
|
pub use host::{NavigationRequest, PermissionDecision, PermissionRequest, ServoHost, WebViewState};
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# GPUI Reference Audit
|
||||||
|
|
||||||
|
Local reference repositories:
|
||||||
|
|
||||||
|
| Reference | Local path | Product use |
|
||||||
|
|---|---|---|
|
||||||
|
| gpui-component | `references/gpui-component` | Root window, input, button, sidebar, dock, list patterns |
|
||||||
|
| awesome-gpui | `references/awesome-gpui` | Ecosystem index and repository mapping |
|
||||||
|
| adabraka-ui | `references/adabraka-ui` | Clean desktop component patterns |
|
||||||
|
| ferrum-flow | `references/ferrum-flow` | Future visual workflow panels |
|
||||||
|
| gpui-flow | `references/gpui-flow` | Future rule/workflow graph panels |
|
||||||
|
| gpui-form | `references/gpui-form` | Future settings form derive patterns |
|
||||||
|
| gpui-hooks | `references/gpui-hooks` | Future reusable component state patterns |
|
||||||
|
| gpui-nav | `references/gpui-nav` | Future settings/sidebar navigation |
|
||||||
|
| gpui-router | `references/gpui-router` | Future `ely://settings/*` routing |
|
||||||
|
| gpui-storybook | `references/gpui-storybook` | Future component story validation |
|
||||||
|
| gpui-symbols | `references/gpui-symbols` | Future platform symbol integration |
|
||||||
|
| gpui-tea | `references/gpui-tea` | Future event-loop state model |
|
||||||
|
| gpui-video-player | `references/gpui-video-player` | Future downloaded media preview |
|
||||||
|
| plotters-gpui | `references/plotters-gpui` | Future performance charts |
|
||||||
|
| sotf | `references/sotf` | `gpui-d3rs` and `gpui-px` references |
|
||||||
|
|
||||||
|
Copied patterns in the current implementation:
|
||||||
|
|
||||||
|
- `gpui-component::Root` as the first window view.
|
||||||
|
- `gpui_component::input::InputState` with typed `InputEvent` subscription.
|
||||||
|
- `gpui_component::button::Button` for command actions.
|
||||||
|
- GPUI model discipline: core state mutates once per user action, then the view calls `cx.notify()`.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# ELY Browser Shell
|
||||||
|
|
||||||
|
```text
|
||||||
|
┌──────────────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ ELY Browser [ Search or enter address.............................. ] [+] │
|
||||||
|
├──────────────────────────────┬───────────────────────────────────────────────┤
|
||||||
|
│ ★ Favorites │ ely://new-tab │
|
||||||
|
│ │ │
|
||||||
|
│ Space │ ┌─────────────────────────────────────────┐ │
|
||||||
|
│ Work │ │ New Tab │ │
|
||||||
|
│ │ │ Clean browser surface for the current │ │
|
||||||
|
│ Tabs │ │ Space and Profile. │ │
|
||||||
|
│ ● New Tab │ └─────────────────────────────────────────┘ │
|
||||||
|
│ │ │
|
||||||
|
│ Profile │ Servo host boundary owns webpage rendering. │
|
||||||
|
│ Default │ │
|
||||||
|
└──────────────────────────────┴───────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
Motion register: productive. Command and tab interactions use immediate state changes with
|
||||||
|
hover/press feedback through GPUI styles; future pane transitions should use transform/opacity and
|
||||||
|
respect reduced-motion settings.
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
|
<string>en</string>
|
||||||
|
<key>CFBundleDisplayName</key>
|
||||||
|
<string>ELY Browser</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>ely_app</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>com.elydora.ely-browser</string>
|
||||||
|
<key>CFBundleInfoDictionaryVersion</key>
|
||||||
|
<string>6.0</string>
|
||||||
|
<key>CFBundleName</key>
|
||||||
|
<string>ELY Browser</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>APPL</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>0.1.0</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>LSApplicationCategoryType</key>
|
||||||
|
<string>public.app-category.productivity</string>
|
||||||
|
<key>LSMinimumSystemVersion</key>
|
||||||
|
<string>14.0</string>
|
||||||
|
<key>NSHighResolutionCapable</key>
|
||||||
|
<true/>
|
||||||
|
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
[toolchain]
|
||||||
|
channel = "1.95.0"
|
||||||
|
components = ["clippy", "rustfmt"]
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
edition = "2024"
|
||||||
|
max_width = 100
|
||||||
|
use_small_heuristics = "Max"
|
||||||
Executable
+15
@@ -0,0 +1,15 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
max_lines=500
|
||||||
|
status=0
|
||||||
|
|
||||||
|
while IFS= read -r -d '' file; do
|
||||||
|
lines="$(wc -l < "${file}" | tr -d ' ')"
|
||||||
|
if [[ "${lines}" -gt "${max_lines}" ]]; then
|
||||||
|
echo "${file}: ${lines} lines exceeds ${max_lines}"
|
||||||
|
status=1
|
||||||
|
fi
|
||||||
|
done < <(find crates -name '*.rs' -print0)
|
||||||
|
|
||||||
|
exit "${status}"
|
||||||
Executable
+19
@@ -0,0 +1,19 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||||
|
binary_path="${repo_root}/target/debug/ely_app"
|
||||||
|
bundle_root="${repo_root}/target/macos/ELY Browser.app"
|
||||||
|
contents_dir="${bundle_root}/Contents"
|
||||||
|
macos_dir="${contents_dir}/MacOS"
|
||||||
|
resources_dir="${contents_dir}/Resources"
|
||||||
|
|
||||||
|
cargo build -p ely_app
|
||||||
|
|
||||||
|
rm -rf "${bundle_root}"
|
||||||
|
mkdir -p "${macos_dir}" "${resources_dir}"
|
||||||
|
cp "${repo_root}/packaging/macos/Info.plist" "${contents_dir}/Info.plist"
|
||||||
|
cp "${binary_path}" "${macos_dir}/ely_app"
|
||||||
|
chmod 755 "${macos_dir}/ely_app"
|
||||||
|
|
||||||
|
echo "${bundle_root}"
|
||||||
Reference in New Issue
Block a user