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:
2026-08-26 19:13:59 -04:00
parent 2e5bd6ec34
commit 16b1ad9a14
17 changed files with 2484 additions and 1 deletions
+4
View File
@@ -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
View File
@@ -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]]
+4
View File
@@ -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"] }
+19
View File
@@ -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:
+4
View File
@@ -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
+24
View File
@@ -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"
+22
View File
@@ -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>
+163
View File
@@ -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"))
}
}
+211
View File
@@ -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>
}
}
+188
View File
@@ -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>
}
}
+7
View File
@@ -0,0 +1,7 @@
//! Components.
mod app;
mod login;
pub use app::App;
pub use login::Login;
+14 -1
View File
@@ -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);
}
+87
View File
@@ -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()
}
+349
View File
@@ -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
View File
@@ -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');
}
+1236
View File
File diff suppressed because it is too large Load Diff
+19
View File
@@ -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
}