Add the frontend shell, the token system, and a login screen
Two axes on <html>: data-theme for colour, data-style for shape and density, independent of each other. v1 shipped 12 themes x 3 layout styles as 36 hand-maintained stylesheets -- 8,300 lines of CSS, 553 custom properties, 116 !important declarations, and the last fifteen commits in the repo were print-preview CSS tweaks. Here a theme is a list of colours and a style is a list of measurements. Every token is semantic: nothing is named --blue-500, because a theme has to be able to change what blue is. The release bundle is 209 KB, 87 KB gzipped. v1's was 2.5 MB, a meaningful share of which was a 638-line dead CalDAV client that kept reqwest, ical and regex in the dependency list. Feature-gating runway-core is what that structural fix buys. One typed API client returning Result<T, ApiError> with a matchable code, replacing v1's seventeen raw RequestInit call sites across three modules, each with its own header, error and JSON handling and all returning Result<T, String>. The session cookie is HttpOnly and this code cannot read it; asking the server who you are is the only way to find out. Trunk proxies /api to the backend so the cookie is same-origin in development exactly as in production, rather than weakening it to SameSite=None for a local convenience. And the browser loop the audit called the biggest change between v1 and v2: e2e/shoot.mjs drives a real Chromium, screenshots every state across both token axes using the actual controls, and reports whatever the console said. It found that wasm-opt needed telling bulk-memory is allowed, and that offering a reference number for a mistyped password suggests a fault at our end.
This commit is contained in:
@@ -13,8 +13,12 @@
|
||||
node_modules/
|
||||
/e2e/test-results
|
||||
/e2e/playwright-report
|
||||
# Screenshots are looked at, not diffed -- the visual snapshots that get
|
||||
# committed arrive with the view milestones.
|
||||
/e2e/screenshots
|
||||
|
||||
# Editor / OS
|
||||
.DS_Store
|
||||
.idea/
|
||||
.vscode/
|
||||
/crates/runway-web/styles/generated.css
|
||||
|
||||
Generated
+14
@@ -498,6 +498,16 @@ dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "console_error_panic_hook"
|
||||
version = "0.1.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a06aeb73f470f66dcdbf7223caeebb85984942f22f1adb2a088cf9668146bbbc"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "const-str"
|
||||
version = "1.1.0"
|
||||
@@ -2427,11 +2437,15 @@ name = "runway-web"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"console_error_panic_hook",
|
||||
"gloo-net",
|
||||
"leptos",
|
||||
"leptos_router",
|
||||
"runway-core",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"wasm-bindgen",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -64,6 +64,10 @@ quick-xml = "0.42"
|
||||
# Frontend
|
||||
leptos = "0.8"
|
||||
leptos_router = "0.8"
|
||||
gloo-net = { version = "0.6", features = ["json"] }
|
||||
wasm-bindgen = "0.2"
|
||||
web-sys = "0.3"
|
||||
console_error_panic_hook = "0.1"
|
||||
|
||||
# CLI
|
||||
clap = { version = "4", features = ["derive", "env"] }
|
||||
|
||||
@@ -54,6 +54,25 @@ export RUNWAY_INSECURE_COOKIES=1 # local HTTP only
|
||||
cargo run -p runway-server
|
||||
```
|
||||
|
||||
### Frontend
|
||||
|
||||
```sh
|
||||
npm install # Tailwind v4 and the browser tooling
|
||||
cd crates/runway-web && trunk serve
|
||||
```
|
||||
|
||||
Trunk serves on `:8080` and proxies `/api` to the backend on `:3000`, so the
|
||||
session cookie is same-origin in development exactly as in production — nothing
|
||||
has to be weakened to make local work.
|
||||
|
||||
Driving a real browser is the loop v1 never had, and the reason its week grid
|
||||
could only be checked by building, deploying and squinting:
|
||||
|
||||
```sh
|
||||
npx playwright install chromium # once
|
||||
node e2e/shoot.mjs # screenshots + anything the console said
|
||||
```
|
||||
|
||||
Some tests can be pointed at a whole real calendar rather than the committed
|
||||
fixtures:
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ leptos = { workspace = true, features = ["csr"] }
|
||||
leptos_router = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
gloo-net = { workspace = true }
|
||||
wasm-bindgen = { workspace = true }
|
||||
console_error_panic_hook = { workspace = true }
|
||||
web-sys = { workspace = true, features = ["Document", "Element", "HtmlElement", "Window"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
[build]
|
||||
target = "index.html"
|
||||
dist = "dist"
|
||||
|
||||
# Tailwind v4 through its own CLI rather than Trunk's bundled copy, which is
|
||||
# still v3. The generated file is a build artefact and is not committed.
|
||||
[[hooks]]
|
||||
stage = "pre_build"
|
||||
command = "sh"
|
||||
command_arguments = [
|
||||
"-c",
|
||||
"npx --prefix ../.. @tailwindcss/cli -i styles/main.css -o styles/generated.css",
|
||||
]
|
||||
|
||||
[serve]
|
||||
port = 8080
|
||||
open = false
|
||||
|
||||
# The API is proxied rather than reached cross-origin, so the session cookie is
|
||||
# same-origin in development exactly as it is in production. A cross-origin
|
||||
# setup would need SameSite=None, which is a weaker cookie for the sake of a
|
||||
# development convenience.
|
||||
[[proxy]]
|
||||
backend = "http://127.0.0.1:3000/api"
|
||||
@@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="system" data-style="default">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>Runway</title>
|
||||
<link data-trunk rel="css" href="styles/generated.css" />
|
||||
<!--
|
||||
wasm-opt needs telling that bulk-memory instructions are allowed. The
|
||||
Rust toolchain emits them by default and Trunk's bundled wasm-opt
|
||||
refuses them unless the feature is named, which fails the release build
|
||||
with a validator error rather than anything about the code.
|
||||
-->
|
||||
<link
|
||||
data-trunk
|
||||
rel="rust"
|
||||
data-wasm-opt="z"
|
||||
data-wasm-opt-params="--enable-bulk-memory --enable-nontrapping-float-to-int --enable-sign-ext --enable-mutable-globals --enable-reference-types"
|
||||
/>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
@@ -0,0 +1,163 @@
|
||||
//! The typed API client.
|
||||
//!
|
||||
//! One place that knows how to talk to the backend, returning
|
||||
//! `Result<T, ApiError>` with a matchable `code`. v1 had **seventeen** raw
|
||||
//! `web_sys::RequestInit` call sites across three modules, each with its own
|
||||
//! header handling, its own error handling and its own JSON decoding, and every
|
||||
//! one of them returned `Result<T, String>` — so a 401 and a parse failure
|
||||
//! arrived looking identical and there was nothing to do but show the text.
|
||||
//!
|
||||
//! Requests go to a relative `/api` path, which the dev server proxies. That
|
||||
//! keeps the session cookie same-origin in development exactly as in
|
||||
//! production, so nothing has to be weakened to make local work.
|
||||
|
||||
use gloo_net::http::{Request, RequestBuilder};
|
||||
use serde::Serialize;
|
||||
use serde::de::DeserializeOwned;
|
||||
|
||||
/// A failure the interface can act on.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct ApiError {
|
||||
/// Stable, and the thing to branch on.
|
||||
pub code: String,
|
||||
/// Fit to show a person.
|
||||
pub message: String,
|
||||
/// Present on server errors. Worth showing, because it is what makes a
|
||||
/// report findable in the log.
|
||||
#[serde(default)]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
/// Whether this means "sign in again".
|
||||
pub fn is_unauthenticated(&self) -> bool {
|
||||
self.code == "unauthenticated"
|
||||
}
|
||||
|
||||
/// Whether this is worth reporting, and therefore worth showing a
|
||||
/// reference for.
|
||||
///
|
||||
/// A mistyped password is not a fault: offering somebody a reference
|
||||
/// number for it suggests something went wrong at our end and invites a
|
||||
/// bug report about their own typo.
|
||||
pub fn is_reportable(&self) -> bool {
|
||||
matches!(self.code.as_str(), "internal" | "upstream" | "decode")
|
||||
}
|
||||
|
||||
fn local(code: &str, message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
code: code.to_owned(),
|
||||
message: message.into(),
|
||||
request_id: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ApiError {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.write_str(&self.message)
|
||||
}
|
||||
}
|
||||
|
||||
async fn send<T: DeserializeOwned>(request: Request) -> Result<T, ApiError> {
|
||||
let response = request.send().await.map_err(|error| {
|
||||
ApiError::local("network", format!("could not reach the server: {error}"))
|
||||
})?;
|
||||
|
||||
if response.ok() {
|
||||
return response.json().await.map_err(|error| {
|
||||
ApiError::local(
|
||||
"decode",
|
||||
format!("the server sent something unreadable: {error}"),
|
||||
)
|
||||
});
|
||||
}
|
||||
|
||||
// The error body is the server's own shape. Falling back to the status
|
||||
// keeps this working even against a proxy that answers for it.
|
||||
let status = response.status();
|
||||
match response.json::<ApiError>().await {
|
||||
Ok(error) => Err(error),
|
||||
Err(_) => Err(ApiError::local(
|
||||
"http",
|
||||
format!("the server returned HTTP {status}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn json_request<B: Serialize>(builder: RequestBuilder, body: &B) -> Result<Request, ApiError> {
|
||||
builder
|
||||
.json(body)
|
||||
.map_err(|error| ApiError::local("encode", format!("could not build the request: {error}")))
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------- types
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct LoginRequest {
|
||||
pub server_url: String,
|
||||
pub username: String,
|
||||
pub password: String,
|
||||
}
|
||||
|
||||
/// The account the session belongs to.
|
||||
///
|
||||
/// Mirrors the server's `User` row, which holds nothing secret — the password
|
||||
/// lives encrypted in its own table and never travels.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct User {
|
||||
pub id: String,
|
||||
pub username: String,
|
||||
pub server_url: String,
|
||||
#[serde(default)]
|
||||
pub display_name: Option<String>,
|
||||
}
|
||||
|
||||
impl User {
|
||||
pub fn name(&self) -> &str {
|
||||
self.display_name.as_deref().unwrap_or(&self.username)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct SessionResponse {
|
||||
pub user: User,
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- requests
|
||||
|
||||
/// Signs in. The session arrives as an `HttpOnly` cookie the browser stores and
|
||||
/// this code cannot read, which is the point.
|
||||
pub async fn login(request: &LoginRequest) -> Result<SessionResponse, ApiError> {
|
||||
send(json_request(Request::post("/api/auth/login"), request)?).await
|
||||
}
|
||||
|
||||
/// Who the current session belongs to, if there is one.
|
||||
///
|
||||
/// `Ok(None)` for "not signed in" rather than an error: on startup that is the
|
||||
/// expected answer, not a failure.
|
||||
pub async fn session() -> Result<Option<SessionResponse>, ApiError> {
|
||||
let request = Request::get("/api/auth/session")
|
||||
.build()
|
||||
.map_err(|error| ApiError::local("encode", error.to_string()))?;
|
||||
match send::<SessionResponse>(request).await {
|
||||
Ok(session) => Ok(Some(session)),
|
||||
Err(error) if error.is_unauthenticated() => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn logout() -> Result<(), ApiError> {
|
||||
let request = Request::post("/api/auth/logout")
|
||||
.build()
|
||||
.map_err(|error| ApiError::local("encode", error.to_string()))?;
|
||||
let response = request
|
||||
.send()
|
||||
.await
|
||||
.map_err(|error| ApiError::local("network", error.to_string()))?;
|
||||
if response.ok() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(ApiError::local("http", "could not sign out"))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
//! The root component: decides whether anyone is signed in, and shows one of
|
||||
//! two things.
|
||||
//!
|
||||
//! State lives where it is owned. v1's `app.rs` was a single 2,008-line
|
||||
//! component holding 35 `use_state` hooks — every modal's open flag, every
|
||||
//! context menu's position, the colour palette, the alarm scheduler — and
|
||||
//! prop-drilled all of it, with 265 `.clone()` calls to make that possible.
|
||||
//! Here the session is the only thing this component owns, and it is offered
|
||||
//! through context rather than passed down by hand.
|
||||
|
||||
use crate::api::{self, User};
|
||||
use crate::components::Login;
|
||||
use crate::theme::{Style, Theme, apply};
|
||||
use leptos::prelude::*;
|
||||
|
||||
/// The signed-in user, for anything that needs to know.
|
||||
///
|
||||
/// A context rather than a prop: components that care ask for it, and the ones
|
||||
/// in between are not made to carry it.
|
||||
#[derive(Clone, Copy)]
|
||||
pub struct Session {
|
||||
pub user: RwSignal<Option<User>>,
|
||||
}
|
||||
|
||||
/// What is known about the session so far.
|
||||
#[derive(Clone, PartialEq)]
|
||||
enum Status {
|
||||
/// Still asking the server.
|
||||
Checking,
|
||||
SignedOut,
|
||||
SignedIn(User),
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn App() -> impl IntoView {
|
||||
let theme = RwSignal::new(Theme::System);
|
||||
let style = RwSignal::new(Style::Default);
|
||||
let user = RwSignal::new(None::<User>);
|
||||
provide_context(Session { user });
|
||||
|
||||
// Applied whenever either axis changes, and once on load.
|
||||
Effect::new(move |_| apply(theme.get(), style.get()));
|
||||
|
||||
let status = RwSignal::new(Status::Checking);
|
||||
|
||||
// Ask once, on load, whether the cookie the browser is holding is still
|
||||
// good. The token itself is HttpOnly, so this is the only way to find out.
|
||||
Effect::new(move |_| {
|
||||
leptos::task::spawn_local(async move {
|
||||
match api::session().await {
|
||||
Ok(Some(response)) => {
|
||||
user.set(Some(response.user.clone()));
|
||||
status.set(Status::SignedIn(response.user));
|
||||
}
|
||||
Ok(None) => status.set(Status::SignedOut),
|
||||
// A server that cannot be reached is not the same as being
|
||||
// signed out, but there is nothing useful to show either way
|
||||
// except the login screen with the reason on it.
|
||||
Err(_) => status.set(Status::SignedOut),
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
let on_signed_in = Callback::new(move |account: User| {
|
||||
user.set(Some(account.clone()));
|
||||
status.set(Status::SignedIn(account));
|
||||
});
|
||||
|
||||
view! {
|
||||
<main class="min-h-screen">
|
||||
{move || match status.get() {
|
||||
Status::Checking => view! { <Splash /> }.into_any(),
|
||||
Status::SignedOut => view! { <Login on_signed_in /> }.into_any(),
|
||||
Status::SignedIn(account) => {
|
||||
view! { <SignedIn account theme style /> }.into_any()
|
||||
}
|
||||
}}
|
||||
</main>
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown for the moment between loading and knowing.
|
||||
///
|
||||
/// Deliberately quiet: flashing a login form at somebody who is already signed
|
||||
/// in is worse than a beat of nothing.
|
||||
#[component]
|
||||
fn Splash() -> impl IntoView {
|
||||
view! {
|
||||
<div class="grid min-h-screen place-items-center">
|
||||
<p style="color: var(--text-subtle); font-size: var(--font-size-sm)">"Loading…"</p>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A placeholder for the calendar, which arrives with the app shell.
|
||||
///
|
||||
/// It exists now so the login flow has somewhere to land and so the token axes
|
||||
/// have something to be visible on.
|
||||
#[component]
|
||||
fn SignedIn(account: User, theme: RwSignal<Theme>, style: RwSignal<Style>) -> impl IntoView {
|
||||
let session = expect_context::<Session>();
|
||||
let signing_out = RwSignal::new(false);
|
||||
|
||||
let sign_out = move |_| {
|
||||
signing_out.set(true);
|
||||
leptos::task::spawn_local(async move {
|
||||
let _ = api::logout().await;
|
||||
session.user.set(None);
|
||||
// The session is gone server-side; reloading is the simplest way
|
||||
// to get back to a clean state without inventing a second path
|
||||
// through the same decision.
|
||||
if let Some(window) = web_sys::window() {
|
||||
let _ = window.location().reload();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="flex min-h-screen flex-col">
|
||||
<header
|
||||
class="flex items-center justify-between border-b"
|
||||
style="height: var(--header-height); padding-inline: var(--space-4); \
|
||||
background: var(--surface-raised); border-color: var(--border)"
|
||||
>
|
||||
<span style="font-weight: 600">"Runway"</span>
|
||||
<div class="flex items-center" style="gap: var(--space-3)">
|
||||
<Picker
|
||||
label="Theme"
|
||||
current=Signal::derive(move || theme.get().as_str().to_owned())
|
||||
options=Theme::ALL.map(|t| (t.as_str(), t.label())).to_vec()
|
||||
on_pick=Callback::new(move |value: String| {
|
||||
if let Some(picked) = Theme::ALL.iter().find(|t| t.as_str() == value) {
|
||||
theme.set(*picked);
|
||||
}
|
||||
})
|
||||
/>
|
||||
<Picker
|
||||
label="Density"
|
||||
current=Signal::derive(move || style.get().as_str().to_owned())
|
||||
options=Style::ALL.map(|s| (s.as_str(), s.label())).to_vec()
|
||||
on_pick=Callback::new(move |value: String| {
|
||||
if let Some(picked) = Style::ALL.iter().find(|s| s.as_str() == value) {
|
||||
style.set(*picked);
|
||||
}
|
||||
})
|
||||
/>
|
||||
<span style="color: var(--text-muted); font-size: var(--font-size-sm)">
|
||||
{account.name().to_owned()}
|
||||
</span>
|
||||
<button
|
||||
on:click=sign_out
|
||||
disabled=move || signing_out.get()
|
||||
data-testid="sign-out"
|
||||
class="cursor-pointer border"
|
||||
style="padding: var(--space-1) var(--space-3); \
|
||||
border-radius: var(--radius); border-color: var(--border); \
|
||||
background: var(--surface); color: var(--text); \
|
||||
font-size: var(--font-size-sm)"
|
||||
>
|
||||
"Sign out"
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="grid flex-1 place-items-center" data-testid="signed-in">
|
||||
<div class="text-center" style="max-width: 32rem; padding: var(--space-8)">
|
||||
<h1 style="margin: 0 0 var(--space-2); font-size: 1.5rem; font-weight: 600">
|
||||
"Signed in as " {account.username.clone()}
|
||||
</h1>
|
||||
<p style="margin: 0; color: var(--text-muted)">
|
||||
"Connected to " {account.server_url.clone()}
|
||||
</p>
|
||||
<p style="margin-top: var(--space-6); color: var(--text-subtle); \
|
||||
font-size: var(--font-size-sm)">
|
||||
"The calendar views arrive next. Both token axes above are live — \
|
||||
every colour and measurement on this page comes from them."
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
/// A labelled select, used for both token axes.
|
||||
#[component]
|
||||
fn Picker(
|
||||
label: &'static str,
|
||||
current: Signal<String>,
|
||||
options: Vec<(&'static str, &'static str)>,
|
||||
on_pick: Callback<String>,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<label class="flex items-center" style="gap: var(--space-1)">
|
||||
<span style="color: var(--text-subtle); font-size: var(--font-size-xs)">{label}</span>
|
||||
<select
|
||||
data-testid=label.to_lowercase()
|
||||
prop:value=move || current.get()
|
||||
on:change:target=move |event| on_pick.run(event.target().value())
|
||||
class="cursor-pointer border"
|
||||
style="padding: var(--space-1) var(--space-2); border-radius: var(--radius-sm); \
|
||||
border-color: var(--border); background: var(--surface); \
|
||||
color: var(--text); font-size: var(--font-size-sm)"
|
||||
>
|
||||
{options
|
||||
.into_iter()
|
||||
.map(|(value, text)| view! { <option value=value>{text}</option> })
|
||||
.collect_view()}
|
||||
</select>
|
||||
</label>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//! The login screen.
|
||||
//!
|
||||
//! Three fields and a button. Authentication is a real request to the CalDAV
|
||||
//! server — if it will list your calendars, you are who you say you are — so
|
||||
//! there is no Runway password to forget and nothing to sign up for.
|
||||
|
||||
use crate::api::{self, LoginRequest, User};
|
||||
use leptos::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Login(on_signed_in: Callback<User>) -> impl IntoView {
|
||||
let server_url = RwSignal::new(String::new());
|
||||
let username = RwSignal::new(String::new());
|
||||
let password = RwSignal::new(String::new());
|
||||
let error = RwSignal::new(None::<api::ApiError>);
|
||||
let busy = RwSignal::new(false);
|
||||
|
||||
let submit = move |event: leptos::ev::SubmitEvent| {
|
||||
event.prevent_default();
|
||||
if busy.get() {
|
||||
return;
|
||||
}
|
||||
error.set(None);
|
||||
busy.set(true);
|
||||
|
||||
let request = LoginRequest {
|
||||
server_url: server_url.get().trim().to_owned(),
|
||||
username: username.get().trim().to_owned(),
|
||||
password: password.get(),
|
||||
};
|
||||
|
||||
leptos::task::spawn_local(async move {
|
||||
match api::login(&request).await {
|
||||
Ok(response) => {
|
||||
// Cleared on the way out. It is only ever in this signal
|
||||
// and in the request that just went; v1 kept it in
|
||||
// localStorage and re-read it from eight places.
|
||||
password.set(String::new());
|
||||
busy.set(false);
|
||||
on_signed_in.run(response.user);
|
||||
}
|
||||
Err(failed) => {
|
||||
error.set(Some(failed));
|
||||
busy.set(false);
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
view! {
|
||||
<div class="grid min-h-screen place-items-center" style="padding: var(--space-4)">
|
||||
<form
|
||||
on:submit=submit
|
||||
data-testid="login-form"
|
||||
class="w-full border"
|
||||
style="max-width: 24rem; padding: var(--space-8); \
|
||||
background: var(--surface-raised); border-color: var(--border); \
|
||||
border-radius: var(--radius-lg); box-shadow: var(--shadow-md)"
|
||||
>
|
||||
<h1 style="margin: 0; font-size: 1.5rem; font-weight: 600">"Runway"</h1>
|
||||
<p style="margin: var(--space-1) 0 var(--space-6); color: var(--text-muted); \
|
||||
font-size: var(--font-size-sm)">
|
||||
"Sign in with your CalDAV account."
|
||||
</p>
|
||||
|
||||
<Field
|
||||
label="Server"
|
||||
name="server_url"
|
||||
kind="url"
|
||||
placeholder="https://example.com/dav.php/"
|
||||
value=server_url
|
||||
hint="The DAV root of your calendar server."
|
||||
/>
|
||||
<Field
|
||||
label="Username"
|
||||
name="username"
|
||||
kind="text"
|
||||
placeholder=""
|
||||
value=username
|
||||
hint=""
|
||||
/>
|
||||
<Field
|
||||
label="Password"
|
||||
name="password"
|
||||
kind="password"
|
||||
placeholder=""
|
||||
value=password
|
||||
hint=""
|
||||
/>
|
||||
|
||||
{move || {
|
||||
error
|
||||
.get()
|
||||
.map(|failed| {
|
||||
view! {
|
||||
<p
|
||||
role="alert"
|
||||
data-testid="login-error"
|
||||
class="border"
|
||||
style="margin: var(--space-4) 0 0; \
|
||||
padding: var(--space-2) var(--space-3); \
|
||||
border-radius: var(--radius); \
|
||||
border-color: var(--danger); \
|
||||
background: var(--danger-subtle); \
|
||||
color: var(--text); font-size: var(--font-size-sm)"
|
||||
>
|
||||
{failed.message.clone()}
|
||||
{failed
|
||||
.request_id
|
||||
.clone()
|
||||
.filter(|_| failed.is_reportable())
|
||||
.map(|id| {
|
||||
view! {
|
||||
<span style="display:block; margin-top: var(--space-1); \
|
||||
color: var(--text-subtle); \
|
||||
font-size: var(--font-size-xs)">
|
||||
"Reference " {id}
|
||||
</span>
|
||||
}
|
||||
})}
|
||||
</p>
|
||||
}
|
||||
})
|
||||
}}
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled=move || busy.get()
|
||||
data-testid="sign-in"
|
||||
class="w-full cursor-pointer"
|
||||
style="margin-top: var(--space-6); padding: var(--space-2) var(--space-4); \
|
||||
border: none; border-radius: var(--radius); \
|
||||
background: var(--accent); color: var(--accent-text); \
|
||||
font-size: var(--font-size); font-weight: 500"
|
||||
>
|
||||
{move || if busy.get() { "Signing in…" } else { "Sign in" }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Field(
|
||||
label: &'static str,
|
||||
name: &'static str,
|
||||
kind: &'static str,
|
||||
placeholder: &'static str,
|
||||
value: RwSignal<String>,
|
||||
hint: &'static str,
|
||||
) -> impl IntoView {
|
||||
view! {
|
||||
<div style="margin-bottom: var(--space-4)">
|
||||
<label
|
||||
for=name
|
||||
style="display: block; margin-bottom: var(--space-1); \
|
||||
font-size: var(--font-size-sm); font-weight: 500"
|
||||
>
|
||||
{label}
|
||||
</label>
|
||||
<input
|
||||
id=name
|
||||
name=name
|
||||
type=kind
|
||||
placeholder=placeholder
|
||||
autocomplete=match name {
|
||||
"username" => "username",
|
||||
"password" => "current-password",
|
||||
_ => "url",
|
||||
}
|
||||
required=true
|
||||
prop:value=move || value.get()
|
||||
on:input:target=move |event| value.set(event.target().value())
|
||||
class="w-full border"
|
||||
style="padding: var(--space-2) var(--space-3); border-radius: var(--radius); \
|
||||
border-color: var(--border); background: var(--surface); \
|
||||
color: var(--text); font-size: var(--font-size)"
|
||||
/>
|
||||
{(!hint.is_empty())
|
||||
.then(|| {
|
||||
view! {
|
||||
<p style="margin: var(--space-1) 0 0; color: var(--text-subtle); \
|
||||
font-size: var(--font-size-xs)">{hint}</p>
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
//! Components.
|
||||
|
||||
mod app;
|
||||
mod login;
|
||||
|
||||
pub use app::App;
|
||||
pub use login::Login;
|
||||
@@ -1,3 +1,16 @@
|
||||
//! The Runway frontend.
|
||||
|
||||
mod api;
|
||||
mod components;
|
||||
mod theme;
|
||||
|
||||
use leptos::prelude::*;
|
||||
|
||||
fn main() {
|
||||
println!("runway-web: not yet implemented");
|
||||
// Turns a wasm panic into a readable console message instead of
|
||||
// "unreachable executed". v1 had 74 `web_sys::console` calls and no panic
|
||||
// hook, so a panic looked like the app simply stopping.
|
||||
console_error_panic_hook::set_once();
|
||||
|
||||
mount_to_body(components::App);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
//! Applying the two token axes to the document.
|
||||
//!
|
||||
//! `data-theme` and `data-style` are set on `<html>` and nothing else knows
|
||||
//! about them: every component reads semantic custom properties. Changing
|
||||
//! either is one attribute write, not a stylesheet swap — v1 loaded a different
|
||||
//! CSS file per layout style and maintained 36 theme/style combinations by hand.
|
||||
|
||||
use wasm_bindgen::JsCast;
|
||||
|
||||
/// A colour scheme.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Theme {
|
||||
/// Follow the operating system.
|
||||
#[default]
|
||||
System,
|
||||
Light,
|
||||
Dark,
|
||||
Nord,
|
||||
}
|
||||
|
||||
impl Theme {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "system",
|
||||
Self::Light => "light",
|
||||
Self::Dark => "dark",
|
||||
Self::Nord => "nord",
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [Self; 4] = [Self::System, Self::Light, Self::Dark, Self::Nord];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::System => "System",
|
||||
Self::Light => "Light",
|
||||
Self::Dark => "Dark",
|
||||
Self::Nord => "Nord",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shape and density.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||
pub enum Style {
|
||||
#[default]
|
||||
Default,
|
||||
Compact,
|
||||
Comfortable,
|
||||
}
|
||||
|
||||
impl Style {
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "default",
|
||||
Self::Compact => "compact",
|
||||
Self::Comfortable => "comfortable",
|
||||
}
|
||||
}
|
||||
|
||||
pub const ALL: [Self; 3] = [Self::Default, Self::Compact, Self::Comfortable];
|
||||
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Default => "Default",
|
||||
Self::Compact => "Compact",
|
||||
Self::Comfortable => "Comfortable",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Writes both axes onto `<html>`.
|
||||
pub fn apply(theme: Theme, style: Style) {
|
||||
let Some(root) = document_element() else {
|
||||
return;
|
||||
};
|
||||
let _ = root.set_attribute("data-theme", theme.as_str());
|
||||
let _ = root.set_attribute("data-style", style.as_str());
|
||||
}
|
||||
|
||||
fn document_element() -> Option<web_sys::Element> {
|
||||
web_sys::window()?
|
||||
.document()?
|
||||
.document_element()?
|
||||
.dyn_into::<web_sys::Element>()
|
||||
.ok()
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
/*
|
||||
* Two axes, set on <html>.
|
||||
*
|
||||
* data-theme — colour (system | light | dark | nord)
|
||||
* data-style — shape/density (default | compact | comfortable)
|
||||
*
|
||||
* They are independent. v1 shipped 12 themes × 3 layout styles as 36
|
||||
* hand-maintained stylesheets — 8,300 lines of CSS with 553 custom properties
|
||||
* and 116 `!important` declarations, and the last fifteen commits in the repo
|
||||
* were CSS tweaks. Here a theme is a list of colours and a style is a list of
|
||||
* measurements; adding either is a short block, and no combination needs its
|
||||
* own file.
|
||||
*
|
||||
* Everything below is *semantic*. Nothing is named for what it looks like
|
||||
* (`--blue-500`) because a theme has to be able to change what it looks like;
|
||||
* things are named for what they are for.
|
||||
*/
|
||||
|
||||
/* ---------------------------------------------------------------- colour -- */
|
||||
|
||||
:root,
|
||||
[data-theme="light"],
|
||||
[data-theme="system"] {
|
||||
color-scheme: light;
|
||||
|
||||
/* Surfaces, from furthest back to nearest front. */
|
||||
--surface-sunken: oklch(96% 0.004 250);
|
||||
--surface: oklch(99% 0.002 250);
|
||||
--surface-raised: oklch(100% 0 0);
|
||||
--surface-overlay: oklch(100% 0 0);
|
||||
--surface-hover: oklch(95% 0.006 250);
|
||||
--surface-active: oklch(92% 0.008 250);
|
||||
|
||||
/* Lines. */
|
||||
--border-subtle: oklch(93% 0.005 250);
|
||||
--border: oklch(88% 0.007 250);
|
||||
--border-strong: oklch(75% 0.01 250);
|
||||
|
||||
/* Text, from most to least prominent. */
|
||||
--text: oklch(25% 0.01 250);
|
||||
--text-muted: oklch(50% 0.012 250);
|
||||
--text-subtle: oklch(62% 0.01 250);
|
||||
--text-inverted: oklch(99% 0 0);
|
||||
|
||||
/* The one colour that means "this is the action". */
|
||||
--accent: oklch(55% 0.16 250);
|
||||
--accent-hover: oklch(48% 0.17 250);
|
||||
--accent-text: oklch(99% 0 0);
|
||||
--accent-subtle: oklch(94% 0.03 250);
|
||||
|
||||
/* Outcomes. */
|
||||
--danger: oklch(55% 0.2 25);
|
||||
--danger-subtle: oklch(95% 0.04 25);
|
||||
--danger-text: oklch(99% 0 0);
|
||||
--warning: oklch(70% 0.15 75);
|
||||
--warning-subtle: oklch(96% 0.05 85);
|
||||
--success: oklch(60% 0.14 150);
|
||||
--success-subtle: oklch(95% 0.04 150);
|
||||
|
||||
/* The calendar grid itself. */
|
||||
--grid-line: oklch(92% 0.005 250);
|
||||
--grid-line-major: oklch(85% 0.008 250);
|
||||
--grid-label: oklch(60% 0.01 250);
|
||||
--today: oklch(96% 0.04 250);
|
||||
--now-line: oklch(58% 0.2 25);
|
||||
--weekend: oklch(97% 0.004 250);
|
||||
--other-month: oklch(97% 0.003 250);
|
||||
--all-day-band: oklch(98% 0.003 250);
|
||||
|
||||
/* Events drawn in a calendar's own colour need a readable text colour and a
|
||||
consistent way to tint. */
|
||||
--event-text: oklch(22% 0.02 250);
|
||||
--event-tint: 88%;
|
||||
--event-edge: 62%;
|
||||
|
||||
--focus-ring: oklch(60% 0.18 250);
|
||||
--shadow-sm: 0 1px 2px oklch(20% 0.02 250 / 8%);
|
||||
--shadow-md: 0 4px 12px oklch(20% 0.02 250 / 10%);
|
||||
--shadow-lg: 0 12px 32px oklch(20% 0.02 250 / 14%);
|
||||
}
|
||||
|
||||
/* "system" follows the operating system rather than picking a side. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme]),
|
||||
[data-theme="system"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--surface-sunken: oklch(18% 0.008 260);
|
||||
--surface: oklch(21% 0.009 260);
|
||||
--surface-raised: oklch(25% 0.01 260);
|
||||
--surface-overlay: oklch(27% 0.011 260);
|
||||
--surface-hover: oklch(28% 0.012 260);
|
||||
--surface-active: oklch(32% 0.014 260);
|
||||
|
||||
--border-subtle: oklch(27% 0.01 260);
|
||||
--border: oklch(33% 0.012 260);
|
||||
--border-strong: oklch(45% 0.015 260);
|
||||
|
||||
--text: oklch(93% 0.005 260);
|
||||
--text-muted: oklch(72% 0.01 260);
|
||||
--text-subtle: oklch(58% 0.012 260);
|
||||
--text-inverted: oklch(18% 0.008 260);
|
||||
|
||||
--accent: oklch(68% 0.14 250);
|
||||
--accent-hover: oklch(74% 0.15 250);
|
||||
--accent-text: oklch(16% 0.02 250);
|
||||
--accent-subtle: oklch(30% 0.05 250);
|
||||
|
||||
--danger: oklch(65% 0.18 25);
|
||||
--danger-subtle: oklch(30% 0.06 25);
|
||||
--danger-text: oklch(15% 0.02 25);
|
||||
--warning: oklch(78% 0.13 75);
|
||||
--warning-subtle: oklch(32% 0.05 75);
|
||||
--success: oklch(70% 0.13 150);
|
||||
--success-subtle: oklch(28% 0.05 150);
|
||||
|
||||
--grid-line: oklch(28% 0.01 260);
|
||||
--grid-line-major: oklch(36% 0.012 260);
|
||||
--grid-label: oklch(62% 0.012 260);
|
||||
--today: oklch(28% 0.03 250);
|
||||
--now-line: oklch(68% 0.19 25);
|
||||
--weekend: oklch(19% 0.008 260);
|
||||
--other-month: oklch(19% 0.006 260);
|
||||
--all-day-band: oklch(23% 0.009 260);
|
||||
|
||||
--event-text: oklch(95% 0.01 260);
|
||||
--event-tint: 32%;
|
||||
--event-edge: 55%;
|
||||
|
||||
--focus-ring: oklch(72% 0.15 250);
|
||||
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 30%);
|
||||
--shadow-md: 0 4px 12px oklch(0% 0 0 / 36%);
|
||||
--shadow-lg: 0 12px 32px oklch(0% 0 0 / 45%);
|
||||
}
|
||||
}
|
||||
|
||||
/* An explicit choice wins over the operating system's, in both directions. */
|
||||
[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--surface-sunken: oklch(18% 0.008 260);
|
||||
--surface: oklch(21% 0.009 260);
|
||||
--surface-raised: oklch(25% 0.01 260);
|
||||
--surface-overlay: oklch(27% 0.011 260);
|
||||
--surface-hover: oklch(28% 0.012 260);
|
||||
--surface-active: oklch(32% 0.014 260);
|
||||
|
||||
--border-subtle: oklch(27% 0.01 260);
|
||||
--border: oklch(33% 0.012 260);
|
||||
--border-strong: oklch(45% 0.015 260);
|
||||
|
||||
--text: oklch(93% 0.005 260);
|
||||
--text-muted: oklch(72% 0.01 260);
|
||||
--text-subtle: oklch(58% 0.012 260);
|
||||
--text-inverted: oklch(18% 0.008 260);
|
||||
|
||||
--accent: oklch(68% 0.14 250);
|
||||
--accent-hover: oklch(74% 0.15 250);
|
||||
--accent-text: oklch(16% 0.02 250);
|
||||
--accent-subtle: oklch(30% 0.05 250);
|
||||
|
||||
--danger: oklch(65% 0.18 25);
|
||||
--danger-subtle: oklch(30% 0.06 25);
|
||||
--danger-text: oklch(15% 0.02 25);
|
||||
--warning: oklch(78% 0.13 75);
|
||||
--warning-subtle: oklch(32% 0.05 75);
|
||||
--success: oklch(70% 0.13 150);
|
||||
--success-subtle: oklch(28% 0.05 150);
|
||||
|
||||
--grid-line: oklch(28% 0.01 260);
|
||||
--grid-line-major: oklch(36% 0.012 260);
|
||||
--grid-label: oklch(62% 0.012 260);
|
||||
--today: oklch(28% 0.03 250);
|
||||
--now-line: oklch(68% 0.19 25);
|
||||
--weekend: oklch(19% 0.008 260);
|
||||
--other-month: oklch(19% 0.006 260);
|
||||
--all-day-band: oklch(23% 0.009 260);
|
||||
|
||||
--event-text: oklch(95% 0.01 260);
|
||||
--event-tint: 32%;
|
||||
--event-edge: 55%;
|
||||
|
||||
--focus-ring: oklch(72% 0.15 250);
|
||||
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 30%);
|
||||
--shadow-md: 0 4px 12px oklch(0% 0 0 / 36%);
|
||||
--shadow-lg: 0 12px 32px oklch(0% 0 0 / 45%);
|
||||
}
|
||||
|
||||
/* A second theme, here to prove the axis works rather than to be the final
|
||||
set. The curated list lands with M27. */
|
||||
[data-theme="nord"] {
|
||||
color-scheme: dark;
|
||||
|
||||
--surface-sunken: oklch(27% 0.02 260);
|
||||
--surface: oklch(31% 0.021 260);
|
||||
--surface-raised: oklch(35% 0.022 260);
|
||||
--surface-overlay: oklch(37% 0.023 260);
|
||||
--surface-hover: oklch(38% 0.024 260);
|
||||
--surface-active: oklch(42% 0.026 260);
|
||||
|
||||
--border-subtle: oklch(36% 0.022 260);
|
||||
--border: oklch(42% 0.025 260);
|
||||
--border-strong: oklch(55% 0.03 260);
|
||||
|
||||
--text: oklch(92% 0.012 250);
|
||||
--text-muted: oklch(76% 0.015 250);
|
||||
--text-subtle: oklch(62% 0.018 250);
|
||||
--text-inverted: oklch(27% 0.02 260);
|
||||
|
||||
--accent: oklch(72% 0.09 230);
|
||||
--accent-hover: oklch(78% 0.1 230);
|
||||
--accent-text: oklch(25% 0.02 260);
|
||||
--accent-subtle: oklch(40% 0.04 230);
|
||||
|
||||
--danger: oklch(63% 0.14 20);
|
||||
--danger-subtle: oklch(38% 0.06 20);
|
||||
--danger-text: oklch(96% 0.01 20);
|
||||
--warning: oklch(80% 0.1 80);
|
||||
--warning-subtle: oklch(40% 0.05 80);
|
||||
--success: oklch(75% 0.1 140);
|
||||
--success-subtle: oklch(38% 0.05 140);
|
||||
|
||||
--grid-line: oklch(37% 0.022 260);
|
||||
--grid-line-major: oklch(45% 0.025 260);
|
||||
--grid-label: oklch(68% 0.018 250);
|
||||
--today: oklch(38% 0.04 230);
|
||||
--now-line: oklch(70% 0.13 20);
|
||||
--weekend: oklch(29% 0.02 260);
|
||||
--other-month: oklch(29% 0.018 260);
|
||||
--all-day-band: oklch(33% 0.021 260);
|
||||
|
||||
--event-text: oklch(95% 0.012 250);
|
||||
--event-tint: 40%;
|
||||
--event-edge: 58%;
|
||||
|
||||
--focus-ring: oklch(78% 0.1 230);
|
||||
--shadow-sm: 0 1px 2px oklch(0% 0 0 / 28%);
|
||||
--shadow-md: 0 4px 12px oklch(0% 0 0 / 34%);
|
||||
--shadow-lg: 0 12px 32px oklch(0% 0 0 / 42%);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------- shape and density */
|
||||
|
||||
:root,
|
||||
[data-style="default"] {
|
||||
--radius-sm: 0.25rem;
|
||||
--radius: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
--radius-full: 9999px;
|
||||
|
||||
--border-width: 1px;
|
||||
|
||||
/* One number the spacing scale is derived from, so density is a single
|
||||
dial rather than a hundred hand-tuned paddings. */
|
||||
--space-unit: 0.25rem;
|
||||
--space-1: calc(var(--space-unit) * 1);
|
||||
--space-2: calc(var(--space-unit) * 2);
|
||||
--space-3: calc(var(--space-unit) * 3);
|
||||
--space-4: calc(var(--space-unit) * 4);
|
||||
--space-6: calc(var(--space-unit) * 6);
|
||||
--space-8: calc(var(--space-unit) * 8);
|
||||
|
||||
--font-ui: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto,
|
||||
"Helvetica Neue", Arial, sans-serif;
|
||||
--font-size: 0.9375rem;
|
||||
--font-size-sm: 0.8125rem;
|
||||
--font-size-xs: 0.6875rem;
|
||||
--line-height: 1.5;
|
||||
|
||||
/* Calendar measurements. Every view reads these rather than hard-coding
|
||||
pixels; v1's week view did its own arithmetic and could only be checked
|
||||
by building, deploying and squinting. */
|
||||
--hour-height: 3rem;
|
||||
--day-min-height: 6rem;
|
||||
--event-gap: 1px;
|
||||
--sidebar-width: 15rem;
|
||||
--header-height: 3.5rem;
|
||||
}
|
||||
|
||||
[data-style="compact"] {
|
||||
--radius-sm: 0.125rem;
|
||||
--radius: 0.25rem;
|
||||
--radius-lg: 0.375rem;
|
||||
--border-width: 1px;
|
||||
--space-unit: 0.1875rem;
|
||||
--font-size: 0.875rem;
|
||||
--font-size-sm: 0.75rem;
|
||||
--font-size-xs: 0.625rem;
|
||||
--line-height: 1.4;
|
||||
--hour-height: 2.25rem;
|
||||
--day-min-height: 4.5rem;
|
||||
--sidebar-width: 13rem;
|
||||
--header-height: 3rem;
|
||||
}
|
||||
|
||||
[data-style="comfortable"] {
|
||||
--radius-sm: 0.375rem;
|
||||
--radius: 0.75rem;
|
||||
--radius-lg: 1rem;
|
||||
--border-width: 1px;
|
||||
--space-unit: 0.3125rem;
|
||||
--font-size: 1rem;
|
||||
--font-size-sm: 0.875rem;
|
||||
--font-size-xs: 0.75rem;
|
||||
--line-height: 1.6;
|
||||
--hour-height: 4rem;
|
||||
--day-min-height: 7.5rem;
|
||||
--sidebar-width: 17rem;
|
||||
--header-height: 4rem;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ base -- */
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html {
|
||||
background: var(--surface-sunken);
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
font-family: var(--font-ui);
|
||||
font-size: var(--font-size);
|
||||
line-height: var(--line-height);
|
||||
color: var(--text);
|
||||
background: var(--surface-sunken);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* One focus treatment, everywhere, and never removed. v1 had 116
|
||||
`!important` declarations partly because focus kept being fought over. */
|
||||
:where(a, button, input, select, textarea, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--focus-ring);
|
||||
outline-offset: 2px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
+119
@@ -0,0 +1,119 @@
|
||||
// Screenshots of the running app, for looking at.
|
||||
//
|
||||
// The audit called driving a real browser "the single biggest change in what's
|
||||
// possible between v1 and v2", because the week grid could previously only be
|
||||
// checked by building, deploying and squinting. This is the smallest version of
|
||||
// that: point it at a running dev server and it writes PNGs, one per state and
|
||||
// per token-axis combination, and reports anything the console complained about.
|
||||
//
|
||||
// node e2e/shoot.mjs [baseUrl] [outDir]
|
||||
|
||||
import { chromium } from '@playwright/test';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
|
||||
const baseUrl = process.argv[2] ?? 'http://127.0.0.1:8080';
|
||||
const outDir = process.argv[3] ?? 'e2e/screenshots';
|
||||
|
||||
const CREDENTIALS = {
|
||||
server: process.env.RUNWAY_CALDAV_URL ?? 'http://localhost:8800/dav.php/',
|
||||
username: process.env.RUNWAY_CALDAV_USER ?? 'testuser',
|
||||
password: process.env.RUNWAY_CALDAV_PASSWORD ?? 'testpassword',
|
||||
};
|
||||
|
||||
await mkdir(outDir, { recursive: true });
|
||||
|
||||
const browser = await chromium.launch();
|
||||
const context = await browser.newContext({ viewport: { width: 1280, height: 800 } });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Anything the page complains about is a finding, not noise. v1 needed the
|
||||
// developer to reproduce a panic by hand to see it at all.
|
||||
const problems = [];
|
||||
page.on('console', (message) => {
|
||||
const text = message.text();
|
||||
// "Failed to load resource" is the browser narrating any non-2xx response.
|
||||
// A 401 from the session check on a signed-out page is the app working, so
|
||||
// it is not a finding; genuine script failures arrive as `pageerror`.
|
||||
const isResponseNarration = text.startsWith('Failed to load resource');
|
||||
// Trunk emits `integrity` on a modulepreload, which Chrome ignores and
|
||||
// grumbles about. A dev-server artefact, not ours.
|
||||
const isTrunkPreload = text.includes('integrity') && text.includes('preload');
|
||||
if ((message.type() === 'error' || message.type() === 'warning') && !isResponseNarration && !isTrunkPreload) {
|
||||
problems.push(`${message.type()}: ${text}`);
|
||||
}
|
||||
});
|
||||
page.on('pageerror', (error) => problems.push(`pageerror: ${error.message}`));
|
||||
|
||||
async function shoot(name) {
|
||||
await page.screenshot({ path: `${outDir}/${name}.png` });
|
||||
console.log(` ${outDir}/${name}.png`);
|
||||
}
|
||||
|
||||
/** Sets both token axes and re-shoots, which is what makes them checkable.
|
||||
*
|
||||
* Uses the real pickers when the page has them, so the shot proves the control
|
||||
* works rather than only that the CSS does. */
|
||||
async function shootAxes(prefix, combinations) {
|
||||
const hasPickers = await page.locator('[data-testid="theme"]').count();
|
||||
for (const [theme, style] of combinations) {
|
||||
if (hasPickers) {
|
||||
await page.selectOption('[data-testid="theme"]', theme);
|
||||
await page.selectOption('[data-testid="density"]', style);
|
||||
} else {
|
||||
await page.evaluate(
|
||||
([theme, style]) => {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.setAttribute('data-style', style);
|
||||
},
|
||||
[theme, style],
|
||||
);
|
||||
}
|
||||
await page.waitForTimeout(120);
|
||||
await shoot(`${prefix}-${theme}-${style}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`shooting ${baseUrl}`);
|
||||
|
||||
await page.goto(baseUrl, { waitUntil: 'networkidle' });
|
||||
await page.waitForSelector('[data-testid="login-form"]', { timeout: 15000 });
|
||||
await shootAxes('login', [
|
||||
['light', 'default'],
|
||||
['dark', 'default'],
|
||||
['nord', 'default'],
|
||||
['light', 'compact'],
|
||||
['light', 'comfortable'],
|
||||
]);
|
||||
|
||||
// Back to something neutral before typing, so the shot of the filled form is
|
||||
// the one a person would see.
|
||||
await page.evaluate(() => {
|
||||
document.documentElement.setAttribute('data-theme', 'light');
|
||||
document.documentElement.setAttribute('data-style', 'default');
|
||||
});
|
||||
|
||||
await page.fill('#server_url', CREDENTIALS.server);
|
||||
await page.fill('#username', CREDENTIALS.username);
|
||||
await page.fill('#password', 'obviously-wrong');
|
||||
await page.click('[data-testid="sign-in"]');
|
||||
await page.waitForSelector('[data-testid="login-error"]', { timeout: 15000 });
|
||||
await shoot('login-error');
|
||||
|
||||
await page.fill('#password', CREDENTIALS.password);
|
||||
await page.click('[data-testid="sign-in"]');
|
||||
await page.waitForSelector('[data-testid="signed-in"]', { timeout: 20000 });
|
||||
await shootAxes('signed-in', [
|
||||
['light', 'default'],
|
||||
['dark', 'default'],
|
||||
['nord', 'compact'],
|
||||
]);
|
||||
|
||||
await browser.close();
|
||||
|
||||
if (problems.length) {
|
||||
console.log('\nthe page complained:');
|
||||
for (const problem of problems) console.log(` ${problem}`);
|
||||
process.exitCode = 1;
|
||||
} else {
|
||||
console.log('\nno console errors or warnings');
|
||||
}
|
||||
Generated
+1236
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"name": "runway",
|
||||
"version": "0.1.0",
|
||||
"description": "Build tooling for the Runway frontend: Tailwind v4 and the browser-driving scripts.",
|
||||
"directories": {
|
||||
"doc": "docs"
|
||||
},
|
||||
"scripts": {
|
||||
"css": "tailwindcss -i crates/runway-web/styles/main.css -o crates/runway-web/styles/generated.css",
|
||||
"shoot": "node e2e/shoot.mjs"
|
||||
},
|
||||
"type": "commonjs",
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@tailwindcss/cli": "^4.3.3",
|
||||
"tailwindcss": "^4.3.3"
|
||||
},
|
||||
"private": true
|
||||
}
|
||||
Reference in New Issue
Block a user