git-ents.gitmain
⌘K
foforge
commit bdffa49
web: replace the workbench UI with the design handoff

Recreate the whole ents-web surface from the "Workbench" Claude Design handoff, retiring every legacy style. The crate stays server-rendered maud with a no-JS baseline; this is a reskin, not a rewrite.

Foundation:

  • new ents.css keyed to the handoff’s tokens/geometry/type, keyed to the existing class taxonomy, with a derived dark scheme

  • self-hosted IBM Plex (woff2 vendored under assets/fonts/, OFL), served at GET /fonts/{name} so the design’s type ships with no network fetch

  • sprite.svg redrawn to the handoff’s 24x24 stroke icons

  • editor_open renders the signature teal EditorPill (file:line + a single editor mark); id-chip gains an initials avatar; shared avatar() and promoted split_scope/scope_class helpers

Every page reskinned to the shared vocabulary: dashboard desk, code
blob view, commits list, commit detail (checks, reviews, verdict picker, diffs), issues master-detail, threads, members cards, meta, account, login, and the generic reflection views.

Router tests updated for the intentional token/class renames. The standalone /reviews surface (net-new backend) is deferred.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Joseph D. Carpinelli · 29 days ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

crates/cli/ents-web/src/assets.rs @@ -1,10 +1,11 @@ //! Static assets embedded at compile time so the built binary stays //! self-contained -- no runtime fetch, no separate asset bundle to ship -//! alongside `git-ents`. `ents.css` is the hand-rolled pre-redo stylesheet -//! (`pre-redo:crates/git-ents-server/src/web/style.css`), ported rather -//! than vendored -- including its type stack, which this crate's own -//! system-font fallback carries rather than the pre-redo Google Fonts load -//! (see `ents.css`'s own header comment). `ents.js` is new to this crate +//! alongside `git-ents`. `ents.css` is the hand-rolled workbench +//! stylesheet, keyed to the design handoff's tokens and component specs; +//! its `@font-face` rules load the [`FONTS`] IBM Plex faces this crate +//! serves itself (`GET /fonts/{name}`) rather than fetching them from +//! Google Fonts, so the design's exact type ships without a network +//! dependency. `ents.js` is new to this crate //! (pre-redo had no client-side script at all): a vanilla, //! dependency-free progressive enhancement over `crate::pages::files`'s //! raw-source blob view -- click-to-select a line or range and an inline @@ -18,18 +19,14 @@ //! `include_str!`-and-tag pattern //! `pre-redo:crates/git-ents-server/src/web/icons.rs` used. The workbench //! shell's own chrome (the `.rail` page-family icons, the `.palette` -//! search glass, the `.branch` pill) draws from [`sprite`] instead: one -//! hand-rolled `<symbol>` sprite embedded per page by -//! `crate::pages::layout`, each use site a tiny [`icon_use`] reference -//! rather than a repeated inline SVG. Every sprite symbol -- the `i-ed-*` -//! editor marks `crate::pages`'s `editor_open` uses included -- is an -//! original drawing in the sprite's own stroke style, never a vendored -//! asset, so no third-party icon license applies to the sprite. The -//! editor marks are simplified glyphs *evoking* each product's logo -//! (shown nominatively, naming the editor `$ENTS_EDITOR`/`$EDITOR` -//! already configured), not copies of the trademarked logo files; the -//! Octicons under `assets/icons/` remain this module's only third-party -//! assets. +//! search glass, the `.branch` pill, the `.editor-open` pill's `↗` mark) +//! draws from [`sprite`] instead: one hand-rolled `<symbol>` sprite +//! embedded per page by `crate::pages::layout`, each use site a tiny +//! [`icon_use`] reference rather than a repeated inline SVG. Every sprite +//! symbol is an original drawing in the design handoff's 24×24 stroke style +//! (`stroke-width: 1.7`, round caps), never a vendored asset, so no +//! third-party icon license applies to the sprite; the Octicons under +//! `assets/icons/` remain this module's only third-party assets. use std::sync::LazyLock; @@ -41,6 +38,55 @@ /// [`crate::router`]'s `GET /ents.js` serves -- see this module's own doc. pub(crate) const SCRIPT: &str = include_str!("assets/ents.js"); +/// The self-hosted IBM Plex webfonts the workbench renders in +/// (`assets/fonts/`, SIL OFL, see `assets/fonts/LICENSE`), embedded so the +/// design's exact type is served without a runtime Google Fonts fetch -- +/// the same "self-contained binary, no network dependency" rule +/// [`OVERRIDES`] and the vendored Octicons already hold. `ents.css`'s +/// `@font-face` rules name each by its `GET /fonts/{name}` URL; [`font`] +/// resolves that name back to these bytes. +pub(crate) const FONTS: &[(&str, &[u8])] = &[ + ( + "plex-sans-400.woff2", + include_bytes!("assets/fonts/plex-sans-400.woff2"), + ), + ( + "plex-sans-500.woff2", + include_bytes!("assets/fonts/plex-sans-500.woff2"), + ), + ( + "plex-sans-600.woff2", + include_bytes!("assets/fonts/plex-sans-600.woff2"), + ), + ( + "plex-sans-700.woff2", + include_bytes!("assets/fonts/plex-sans-700.woff2"), + ), + ( + "plex-mono-400.woff2", + include_bytes!("assets/fonts/plex-mono-400.woff2"), + ), + ( + "plex-mono-500.woff2", + include_bytes!("assets/fonts/plex-mono-500.woff2"), + ), + ( + "plex-mono-600.woff2", + include_bytes!("assets/fonts/plex-mono-600.woff2"), + ), +]; + +/// The embedded woff2 bytes for `name`, or `None` for a name no +/// `@font-face` rule references -- [`crate::router`]'s `GET /fonts/{name}` +/// handler serves the hit and 404s the miss, so an unknown path can never +/// read outside this fixed table. +pub(crate) fn font(name: &str) -> Option<&'static [u8]> { + FONTS + .iter() + .find(|(file, _)| *file == name) + .map(|(_, bytes)| *bytes) +} + /// Adapt a vendored Octicon to this UI: tag it with the `.icon` class the /// stylesheet targets and mark it decorative for assistive tech. Every /// vendored file opens with a bare `<svg …>` element, so a single prefix
crates/cli/ents-web/src/editor.rs @@ -13,7 +13,9 @@ //! `vscode://file/...`). Neovim has no scheme of its own, so its links //! use the community `nvim://file/...` shape -- they work only where the //! reader has registered a handler for it, which is stated here rather -//! than hidden: the icon still names the editor the user configured. +//! than hidden: the affordance's `title` names the editor the user +//! configured (`crate::pages`'s `editor_open` renders the shared teal +//! `↗` pill for every editor alike). use std::path::Path; use std::sync::LazyLock; @@ -41,15 +43,6 @@ } } - /// The editor's `crate::assets::sprite` symbol id. - pub(crate) fn icon(self) -> &'static str { - match self { - Self::Zed => "i-ed-zed", - Self::VsCode => "i-ed-code", - Self::Neovim => "i-ed-nvim", - } - } - /// The URL-scheme prefix up to and including `file` -- the deep link /// is `<scheme>/<absolute path>[:<line>]`. fn scheme(self) -> &'static str {
crates/cli/ents-web/src/router.rs @@ -107,6 +107,7 @@ .route("/inbox", get(pages::inbox::list::<O>)) .route("/style.css", get(style)) .route("/ents.js", get(script)) + .route("/fonts/{name}", get(font)) // Layer order: axum runs the last-added layer first, so the // session middleware (added below) resolves the session before // the auth middleware consults it. @@ -266,6 +267,27 @@ ) } +/// `GET /fonts/{name}`: one embedded IBM Plex woff2 face +/// (`crate::assets::font`), named by `ents.css`'s own `@font-face` `src` +/// URLs. Immutable and content-addressed by filename, so it carries a +/// year-long `immutable` cache; a name outside the fixed [`assets::FONTS`] +/// table is a plain 404, never a path escape. Served under the same +/// no-session-gating rule as [`style`] -- a face is needed to render every +/// page, including one reached before a session exists. +async fn font(axum::extract::Path(name): axum::extract::Path<String>) -> Response { + match assets::font(&name) { + Some(bytes) => ( + [ + (header::CONTENT_TYPE, "font/woff2"), + (header::CACHE_CONTROL, "public, max-age=31536000, immutable"), + ], + bytes, + ) + .into_response(), + None => axum::http::StatusCode::NOT_FOUND.into_response(), + } +} + /// Bind a loopback-or-otherwise socket for [`serve_on`], returning the /// listener before any request is served so a caller can read back /// [`std::net::TcpListener::local_addr`] (necessary for `addr`'s port `0`,
crates/cli/ents-web/tests/router.rs @@ -400,7 +400,7 @@ .to_bytes(); let body = String::from_utf8(body.to_vec()).expect("utf8 css"); assert!(!body.is_empty()); - assert!(body.contains("--color-bg")); + assert!(body.contains("--bg")); } /// `roots.web-agnostic`: the dashboard actually renders real content @@ -997,7 +997,7 @@ ); let detail = get_body(&router, &format!("/issues/{issue_id}")).await; assert!( - detail.contains(&format!("class=\"active\" href=\"/issues/{issue_id}\"")), + detail.contains(&format!("class=\"side-row active\" href=\"/issues/{issue_id}\"")), "the viewed issue highlights in the sidebar" ); } @@ -2090,8 +2090,8 @@ let page = get_body(&router, &format!("/commit/{oid}")).await; assert!(page.contains("Reviews"), "the reviews section renders"); assert!( - page.contains("class=\"verdict\""), - "the verdict renders prominently" + page.contains("class=\"verdict verdict-request-changes\""), + "the verdict renders prominently, colored by its value" ); assert!(page.contains("request-changes"), "the verdict text renders"); assert!(page.contains("needs a test"), "the review body renders");
crates/cli/ents-web/src/assets/ents.css @@ -1,466 +1,647 @@ -/* Hand-rolled stylesheet, ported from the pre-redo web UI - * (`pre-redo:crates/git-ents-server/src/web/style.css`) and reverified - * against the markup `crate::pages::layout` and `crate::render` actually - * emit. The `--s-*` syntax-token variables and their `.code .keyword`-family - * consumers, and the `--diff-add`/`--diff-del` variables and `.diff` - * rules, are ported alongside `crate::pages::files`'s `arborium` highlighter - * and `crate::pages::commits`'s unified-diff view. Still dropped: the - * clone-URL copy button (`.copy-btn`, `.clone`), live-check output - * (`.checks-grid`, `.status-*`, `.terminal-*`), and every chrome class for a - * page family this crate does not have (releases, issues, settings, - * account-strip sign-in). The pre-redo brand type stack (DM Sans, IBM Plex - * Mono, Lora, loaded from Google Fonts) is dropped in favor of system font - * stacks, so this sheet never depends on a network fetch. Added beyond the - * pre-redo sheet: the `.wb`/`.rail`/`.wb-bar`/`.palette` workbench shell - * (`crate::pages::layout_shell`, adapted from the design project's - * `proposal-c.css`); `.meta-layout`/`.meta-rail` for the `meta` group's - * page-family rail (`crate::pages::META_SECTIONS`) and `.id-chip` for the - * bar's signing-identity link; `.comment-meta`/`.outdated` for the file-anchored - * comment cards `crate::pages::comments::comments_section` renders below a - * blob view, which otherwise reuse `.card`/`.doc-body` as-is; and - * `.blob-header`/`.blob-actions`/`.entry-size` for - * `crate::pages::files::blob_header`/`dir_listing`'s own metadata, plus - * `tr.sel`/`.blob-add`/`.composer-*` for `assets/ents.js`'s client-side - * line selection and inline comment composer -- pre-redo had neither a - * blob header bar nor any client-side script at all. - */ +/* The workbench stylesheet -- the git-ents "Design Component" handoff + * (design_handoff_git_ents_workbench) recreated as one hand-rolled sheet, + * keyed to the class taxonomy `crate::pages::layout*` and `crate::render` + * already emit (`.wb`/`.rail`/`.wb-bar`/`.split`/`.tree`/`.card`/`.scope`/ + * `.status`/`.editor-open`/`.blob`/`.comment-meta` ...). The design's exact + * tokens (color, geometry, type) live in `:root` below; a page changes its + * markup only where a component needs structure the shared classes here + * cannot express (an avatar, a state pill's dot, a two-line sidebar row) -- + * those additive classes are defined here too, so every page draws from one + * vocabulary. Type is self-hosted IBM Plex (see the `@font-face` block and + * `crate::assets::FONTS`); no rule here depends on a network fetch. + * + * The `--s-*` syntax-token variables feed `.code .keyword`-family consumers + * (`crate::pages::files`'s `arborium` highlighter); the `--diff-*` and + * `--status-*` variables feed `.diff` and `.status` + * (`crate::pages::commits`). */ + +@font-face { font-family: "IBM Plex Sans"; font-style: normal; font-weight: 400; font-display: swap; src: url("/fonts/plex-sans-400.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Sans"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/plex-sans-500.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Sans"; font-style: normal; font-weight: 600; font-display: swap; src: url("/fonts/plex-sans-600.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Sans"; font-style: normal; font-weight: 700; font-display: swap; src: url("/fonts/plex-sans-700.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 400; font-display: swap; src: url("/fonts/plex-mono-400.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 500; font-display: swap; src: url("/fonts/plex-mono-500.woff2") format("woff2"); } +@font-face { font-family: "IBM Plex Mono"; font-style: normal; font-weight: 600; font-display: swap; src: url("/fonts/plex-mono-600.woff2") format("woff2"); } + +/* The handoff's light scheme -- its exact token values. */ :root { - --font-sans: system-ui, -apple-system, "Segoe UI", sans-serif; - --font-serif: ui-serif, Georgia, serif; - --font-mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; - --max-width: 78rem; - --content-narrow: 58rem; - /* The brighter "Proposal C" pass (the workbench mocks' proposal-c.css - * token overrides, merged in as THE tokens rather than layered over the - * old, dimmer pre-redo values). */ - --color-bg: #fdfcf8; - --color-surface: #ffffff; - --color-text: #2a2518; - --color-text-muted: #8a7e6a; - --color-link: #c08a12; - --color-link-hover: #a8760c; - --color-border: #e9e4d6; - --color-code-bg: #f6f4ec; - --color-accent: #c08a12; - --color-accent-subtle: #c08a120f; - --shadow-sm: 0 1px 3px #0000000d; - --shadow-md: 0 4px 16px #0000000f; - --radius-sm: 10px; - --radius-pill: 100px; - --s-comment: #9c8f74; - --s-keyword: #9d0006; - --s-func: #427b58; - --s-type: #b57614; - --s-string: #79740e; - --s-const: #8f3f71; - --s-op: #7c6f57; - --s-prop: #076678; - --diff-add: #4e9a0622; - --diff-del: #cc241d22; - --status-pass: #4e9a06; - --status-fail: #cc241d; - --status-error: #b57614; + --bg: #f4f2ee; + --surface: #ffffff; + --surface-2: #faf9f6; + --raised: #fbfaf7; + --ink: #1b1c21; + --ink-2: #54555d; + --ink-3: #8b8c94; + --ink-4: #b6b6bd; + --line: #e6e3dd; + --line-2: #efece7; + --accent: #4a3fc4; + --accent-2: #6a5fe0; + --accent-soft: #ecebfb; + --accent-line: #d8d5f6; + --editor: #0d8f9c; + --editor-soft: #dff3f3; + --editor-line: #b9e3e2; + --green: #1c7a49; + --green-soft: #e3f2e8; + --red: #c23b34; + --red-soft: #fae9e7; + --amber: #a9791d; + --amber-soft: #f6efdd; + --violet: #7a3fb0; + --violet-soft: #f1e8f8; + + --sans: "IBM Plex Sans", system-ui, -apple-system, sans-serif; + --mono: "IBM Plex Mono", ui-monospace, "SF Mono", Menlo, monospace; + + --radius-card: 12px; + --radius-inner: 8px; + --radius-chip: 6px; + --radius-pill: 20px; + + --shadow-pop: 0 24px 60px rgba(20, 20, 30, .28); + --shadow-toast: 0 12px 30px rgba(20, 20, 30, .32); + + /* Deterministic entity/scope chip palette (README: 6 colors, hashed by + * name), consumed by `.scope-c0`..`.scope-c5`. */ + --c0-fg: #4a3fc4; --c0-bg: #ecebfb; + --c1-fg: #0d8f9c; --c1-bg: #dff3f3; + --c2-fg: #1c7a49; --c2-bg: #e3f2e8; + --c3-fg: #a9791d; --c3-bg: #f6efdd; + --c4-fg: #b23c8c; --c4-bg: #f8e8f3; + --c5-fg: #c23b34; --c5-bg: #fae9e7; + + /* Syntax tokens, tuned to the paper palette. */ + --s-comment: #8b8c94; + --s-keyword: #a3324f; + --s-func: #0d8f9c; + --s-type: #a9791d; + --s-string: #1c7a49; + --s-const: #7a3fb0; + --s-op: #54555d; + --s-prop: #4a3fc4; + --diff-add: rgba(28, 122, 73, .10); + --diff-del: rgba(194, 59, 52, .09); + --status-pass: #1c7a49; + --status-fail: #c23b34; + --status-error: #a9791d; } + +/* Dark scheme (README: "dark is a roadmap item -- design both"), derived + * from the light palette: the same hues over an ink ground. */ @media (prefers-color-scheme: dark) { :root { - --color-bg: #1d1b15; - --color-surface: #282419; - --color-text: #f3eedd; - --color-text-muted: #a89e88; - --color-link: #e8b23c; - --color-link-hover: #f2c55c; - --color-border: #453e2a; - --color-code-bg: #322d1e; - --color-accent: #e8b23c; - --color-accent-subtle: #e8b23c12; - --shadow-sm: 0 1px 3px #00000040; - --shadow-md: 0 4px 16px #0000004d; - --s-comment: #928374; - --s-keyword: #fb4934; - --s-func: #8ec07c; - --s-type: #fabd2f; - --s-string: #b8bb26; - --s-const: #d3869b; - --s-op: #a89984; - --s-prop: #83a598; - --diff-add: #b8bb2620; - --diff-del: #fb493420; - --status-pass: #b8bb26; - --status-fail: #fb4934; - --status-error: #fabd2f; + --bg: #16161a; + --surface: #1d1e23; + --surface-2: #232429; + --raised: #26272d; + --ink: #ecedf2; + --ink-2: #b7b8c0; + --ink-3: #85868f; + --ink-4: #5b5c65; + --line: #303138; + --line-2: #292a30; + --accent: #9d95f5; + --accent-2: #b3abff; + --accent-soft: #262353; + --accent-line: #3a3672; + --editor: #3fc6d2; + --editor-soft: #16333650; + --editor-line: #2c5a5e; + --green: #5cc98b; + --green-soft: #143a2650; + --red: #f0796f; + --red-soft: #3a1e1c50; + --amber: #d9a94a; + --amber-soft: #37301c50; + --violet: #c79be6; + --violet-soft: #2c1f3a50; + --c0-fg: #9d95f5; --c0-bg: #262353; + --c1-fg: #3fc6d2; --c1-bg: #16333650; + --c2-fg: #5cc98b; --c2-bg: #143a2650; + --c3-fg: #d9a94a; --c3-bg: #37301c50; + --c4-fg: #e58fc4; --c4-bg: #3a1f3350; + --c5-fg: #f0796f; --c5-bg: #3a1e1c50; + --s-comment: #85868f; + --s-keyword: #f0796f; + --s-func: #3fc6d2; + --s-type: #d9a94a; + --s-string: #5cc98b; + --s-const: #c79be6; + --s-op: #b7b8c0; + --s-prop: #9d95f5; + --diff-add: rgba(92, 201, 139, .13); + --diff-del: rgba(240, 121, 111, .13); + --status-pass: #5cc98b; + --status-fail: #f0796f; + --status-error: #d9a94a; + --shadow-pop: 0 24px 60px rgba(0, 0, 0, .55); + --shadow-toast: 0 12px 30px rgba(0, 0, 0, .6); } } + *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } -html { font-size: 17px; -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } +html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; } body { - font-family: var(--font-sans); - background: var(--color-bg); - color: var(--color-text); - line-height: 1.7; + font-family: var(--sans); + font-size: 14px; + line-height: 1.5; + background: var(--bg); + color: var(--ink); min-height: 100vh; - display: flex; - flex-direction: column; - background-image: radial-gradient(58rem 30rem at 50% -10rem, var(--color-accent-subtle), transparent 72%); - background-attachment: fixed; + text-rendering: optimizeLegibility; } -h1, h2, h3, h4, h5, h6 { font-family: var(--font-serif); } -a { color: var(--color-link); text-decoration: underline; text-decoration-color: color-mix(in srgb, var(--color-link) 25%, transparent); text-underline-offset: 2px; transition: color .15s, text-decoration-color .15s; } -a:hover { color: var(--color-link-hover); text-decoration-color: currentColor; } -a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; } -.icon { flex-shrink: 0; fill: currentColor; vertical-align: -0.125em; } +h1, h2, h3, h4, h5, h6 { font-family: var(--sans); font-weight: 600; } +a { color: var(--accent); text-decoration: none; } +a:hover { color: var(--accent-2); text-decoration: underline; } +::selection { background: var(--accent-soft); } +a:focus-visible, button:focus-visible, input:focus-visible, textarea:focus-visible, select:focus-visible, summary:focus-visible { + outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; +} +.icon { flex-shrink: 0; fill: none; stroke: currentColor; vertical-align: -0.125em; } +.muted { color: var(--ink-3); } +::-webkit-scrollbar { width: 10px; height: 10px; } +::-webkit-scrollbar-thumb { background: var(--line); border-radius: 8px; border: 3px solid var(--bg); } -/* The workbench shell (`crate::pages::layout_shell`, the "Proposal C" - * chrome): a `.wb` grid pairing the sticky icon `.rail` with a `.wb-main` - * column whose sticky `.wb-bar` top bar carries the repo name, branch - * pill, `.palette` search, and `.id-chip` identity link. */ -.wb { display: grid; grid-template-columns: 56px minmax(0, 1fr); min-height: 100vh; width: 100%; } -.rail { position: sticky; top: 0; height: 100vh; display: flex; flex-direction: column; align-items: center; gap: .3rem; padding: .8rem 0; border-right: 1px solid var(--color-border); background: var(--color-surface); } -.rail .nav-mark { font-size: 1.35rem; margin-bottom: .6rem; } -.nav-mark { color: var(--color-accent); } -.rail a { display: flex; align-items: center; justify-content: center; width: 38px; height: 38px; border-radius: 10px; color: var(--color-text-muted); } -.rail a:hover { background: var(--color-code-bg); color: var(--color-text); } -.rail a.active { background: var(--color-accent-subtle); color: var(--color-accent); } -.rail a .icon { width: 17px; height: 17px; } +/* ============================ SHELL ============================ * + * `crate::pages::layout_shell`: a `.wb` row of the fixed 60px icon + * `.rail` and a `.wb-main` column whose sticky 56px `.wb-bar` carries the + * repo name + branch pill, the `.palette` search, and the `.id-chip`. */ +.wb { display: flex; min-height: 100vh; width: 100%; } +.rail { + position: sticky; top: 0; align-self: flex-start; height: 100vh; width: 60px; flex: none; + background: var(--surface); border-right: 1px solid var(--line); + display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 0; z-index: 40; +} +.rail .nav-mark { + width: 34px; height: 34px; border-radius: 9px; background: var(--ink); color: var(--surface); + display: flex; align-items: center; justify-content: center; + font-family: var(--mono); font-weight: 600; font-size: 15px; letter-spacing: -1px; margin-bottom: 10px; +} +.rail a { + display: flex; align-items: center; justify-content: center; width: 42px; height: 42px; + border-radius: 11px; color: var(--ink-3); +} +.rail a:hover { background: var(--surface-2); color: var(--ink); text-decoration: none; } +.rail a.active { background: var(--accent-soft); color: var(--accent); } +.rail a .icon { width: 19px; height: 19px; stroke-width: 1.7; } .rail .spacer { flex: 1; } -.wb-main { min-width: 0; display: flex; flex-direction: column; } -.wb-bar { position: sticky; top: 0; z-index: 50; display: flex; align-items: center; gap: 1rem; height: 52px; padding: 0 1.25rem; border-bottom: 1px solid var(--color-border); background: color-mix(in srgb, var(--color-bg) 90%, transparent); backdrop-filter: blur(10px); } -.repo-path { font-family: var(--font-mono); font-size: .98rem; display: flex; align-items: center; gap: .5rem; white-space: nowrap; min-width: 0; } -.repo-path .here { color: var(--color-accent); font-weight: 600; } -.branch { font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: .1rem .6rem; display: inline-flex; align-items: center; gap: .3rem; } -.branch .icon { width: 13px; height: 13px; color: var(--color-accent); } -/* `.palette` is a `form`, so it must undo the stacked-field form rule - * below (column direction, row gap, bottom margin). */ -.palette { flex: 0 1 26rem; margin-left: auto; position: relative; display: flex; flex-direction: row; align-items: center; gap: 0; margin-bottom: 0; } -.palette .icon { position: absolute; left: .65rem; width: 14px; height: 14px; color: var(--color-text-muted); pointer-events: none; } -.palette input { width: 100%; font-family: var(--font-sans); font-size: .82rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem 3rem .42rem 2rem; transition: border-color .15s; } -.palette input:focus { border-color: var(--color-accent); } -.palette kbd { position: absolute; right: .55rem; font-family: var(--font-mono); font-size: .66rem; color: var(--color-text-muted); border: 1px solid var(--color-border); border-radius: 5px; padding: 0 .35rem; background: var(--color-code-bg); } -.id-chip { font-family: var(--font-mono); font-size: .78rem; font-weight: 600; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .25rem .75rem; white-space: nowrap; text-decoration: none; transition: border-color .15s, color .15s; } -.id-chip:hover { color: var(--color-accent); border-color: var(--color-accent); text-decoration: none; } -/* The `meta` tab's page-family rail (`crate::pages::META_SECTIONS`), - * rendered beside every meta-namespace page's own content - * (`crate::pages::layout_meta`). */ +.wb-main { flex: 1; min-width: 0; display: flex; flex-direction: column; } +.wb-bar { + position: sticky; top: 0; z-index: 30; height: 56px; flex: none; + background: var(--surface); border-bottom: 1px solid var(--line); + display: flex; align-items: center; gap: 16px; padding: 0 18px; +} +.repo-path { display: flex; align-items: center; gap: 9px; flex: none; min-width: 0; } +.repo-path .here { font-weight: 600; font-size: 14px; color: var(--ink); } +.branch { + display: inline-flex; align-items: center; gap: 5px; height: 24px; padding: 0 9px; + background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-pill); + font-family: var(--mono); font-size: 11.5px; color: var(--ink-2); +} +.branch .icon { width: 12px; height: 12px; stroke-width: 2; } +/* `.palette` is a form: undo the stacked-field form rules (column, gap, + * bottom margin). Styled as the design's centered palette trigger. */ +.palette { + flex: 1; max-width: 520px; margin: 0 auto; height: 34px; + display: flex; flex-direction: row; align-items: center; gap: 10px; margin-bottom: 0; + padding: 0 12px; background: var(--surface-2); border: 1px solid var(--line); + border-radius: 9px; position: relative; +} +.palette:focus-within { border-color: var(--accent); } +.palette .icon { width: 15px; height: 15px; stroke-width: 1.8; color: var(--ink-3); flex: none; } +.palette input { + flex: 1; min-width: 0; border: 0; outline: 0; background: transparent; + font-family: var(--sans); font-size: 13px; color: var(--ink); +} +.palette input::placeholder { color: var(--ink-3); } +.palette kbd { + font-family: var(--mono); font-size: 11px; color: var(--ink-3); + background: var(--surface); border: 1px solid var(--line); border-bottom-width: 2px; + border-radius: 5px; padding: 2px 6px; flex: none; +} +.id-chip { + flex: none; display: inline-flex; align-items: center; gap: 8px; height: 34px; + padding: 0 12px 0 6px; border: 1px solid var(--line); border-radius: var(--radius-pill); + color: var(--ink); font-size: 12.5px; font-weight: 500; +} +.id-chip:hover { border-color: var(--accent-line); color: var(--ink); text-decoration: none; } +.id-chip .avatar { width: 24px; height: 24px; font-size: 11px; } + +/* Initials avatar on the indigo->teal gradient (README: no image assets). */ +.avatar { + display: inline-flex; align-items: center; justify-content: center; flex: none; + width: 20px; height: 20px; border-radius: 50%; + background: linear-gradient(135deg, var(--accent), var(--editor)); + color: #fff; font-size: 9px; font-weight: 600; text-transform: lowercase; letter-spacing: -.02em; +} + +/* ========================= META RAIL ========================= * + * `crate::pages::layout_meta` -- the governance sub-rail beside every + * meta-namespace page (`crate::pages::META_SECTIONS`). */ .meta-layout { display: grid; grid-template-columns: 13rem minmax(0, 1fr); gap: 2rem; align-items: start; } -.meta-rail { display: flex; flex-direction: column; gap: .1rem; position: sticky; top: 64px; } -.meta-rail a { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); text-decoration: none; padding: .4rem .6rem; border-radius: var(--radius-sm); } -.meta-rail a:hover { color: var(--color-text); background: var(--color-code-bg); } -.meta-rail a.active { color: var(--color-accent); font-weight: 600; background: var(--color-accent-subtle); } - +.meta-rail { display: flex; flex-direction: column; gap: 2px; position: sticky; top: 78px; } +.meta-rail a { font-size: 13px; color: var(--ink-2); padding: 7px 11px; border-radius: var(--radius-inner); } +.meta-rail a:hover { color: var(--ink); background: var(--surface-2); text-decoration: none; } +.meta-rail a.active { color: var(--accent); font-weight: 600; background: var(--accent-soft); } @media (max-width: 900px) { .meta-layout { grid-template-columns: minmax(0, 1fr); } .meta-rail { position: static; flex-direction: row; flex-wrap: wrap; } } -.content { max-width: var(--max-width); width: 100%; margin: 0 auto; padding: 2.25rem 1.5rem 3rem; flex: 1; } -/* Master-detail split (`crate::pages::layout_split`): a sticky `.tree` - * sidebar beside a padded `.pane` -- the pane, not the shell, owns the - * content padding here, so blob and diff content never sits on the - * viewport edge. */ -.split { display: grid; grid-template-columns: 16rem minmax(0, 1fr); flex: 1; align-items: start; } -.tree { border-right: 1px solid var(--color-border); background: var(--color-surface); padding: .9rem .6rem; font-family: var(--font-mono); font-size: .8rem; position: sticky; top: 52px; height: calc(100vh - 52px); overflow-y: auto; } -.tree a { display: block; padding: .16rem .5rem; border-radius: 6px; color: var(--color-text); text-decoration: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.tree a:hover { background: var(--color-code-bg); } -.tree a.active { background: var(--color-accent-subtle); color: var(--color-accent); font-weight: 600; } +/* ===================== CONTENT / SPLIT ===================== */ +.content { max-width: 1120px; width: 100%; margin: 0 auto; padding: 30px 34px 60px; flex: 1; } +.readable { max-width: 760px; } +/* Master-detail (`crate::pages::layout_split`): a sticky scrollable + * sidebar beside a `min-height:0` detail pane. */ +.split { display: grid; grid-template-columns: 300px minmax(0, 1fr); height: calc(100vh - 56px); } +.tree { + border-right: 1px solid var(--line); background: var(--surface); + overflow: auto; position: sticky; top: 0; height: 100%; padding: 10px 8px; + font-family: var(--mono); font-size: 12px; +} +.tree a { + display: flex; align-items: center; gap: 7px; height: 28px; padding: 0 10px; + border-radius: 7px; color: var(--ink-2); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.tree a:hover { background: var(--surface-2); text-decoration: none; } +.tree a.active { background: var(--accent-soft); color: var(--accent); font-weight: 600; } .tree a span { display: block; overflow: hidden; text-overflow: ellipsis; } -.tree .dir { color: var(--color-text-muted); font-weight: 600; } -.tree .i1 { padding-left: 1.3rem; } -.tree .i2 { padding-left: 2.5rem; } -.tree .i3 { padding-left: 3.7rem; } -.tree-note { display: block; padding: .16rem .5rem; color: var(--color-text-muted); } -.tree .where { font-size: .68rem; font-weight: 400; color: var(--color-text-muted); } -.pane { padding: 1.4rem 1.75rem 2.5rem; min-width: 0; } +.tree a .icon { width: 13px; height: 13px; stroke-width: 1.7; color: var(--ink-3); } +.tree .dir { color: var(--ink-2); } +.tree .i1 { padding-left: 24px; } +.tree .i2 { padding-left: 38px; } +.tree .i3 { padding-left: 52px; } +.tree-note { display: block; padding: 4px 10px; color: var(--ink-3); } +.tree .where { font-size: 11px; font-weight: 400; color: var(--ink-3); } +.pane { padding: 20px 28px 70px; min-width: 0; overflow: auto; } +.pane .readable, .pane > div { max-width: 820px; margin: 0 auto; } + +/* A generic two-line master-detail sidebar row (issues, reviews, commit + * lane): a title line and a mono `.side-meta` locator, its active state the + * design's accent-soft fill + inset 3px bar. Sits inside `nav.tree`, so it + * opts out of the tree's own flex-row link layout. */ +.tree a.side-row { + display: flex; flex-direction: column; align-items: stretch; gap: 4px; height: auto; + padding: 11px 15px; border-radius: 0; border-bottom: 1px solid var(--line-2); + font-family: var(--sans); white-space: normal; +} +.tree a.side-row.active { background: var(--accent-soft); box-shadow: inset 3px 0 0 var(--accent); color: inherit; } +.side-title { font-size: 13px; font-weight: 500; color: var(--ink); line-height: 1.35; text-wrap: pretty; } +.side-row.active .side-title { font-weight: 600; } +.side-meta { display: flex; align-items: center; gap: 6px; } +.side-meta .dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--ink-4); } +.side-meta .locator { font-family: var(--mono); font-size: 11px; color: var(--ink-3); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.tree-head { + display: flex; align-items: center; justify-content: space-between; gap: 8px; + padding: 13px 15px; border-bottom: 1px solid var(--line-2); + position: sticky; top: 0; background: var(--surface); z-index: 2; + font-family: var(--sans); font-size: 13px; font-weight: 600; +} @media (max-width: 900px) { - .split { grid-template-columns: minmax(0, 1fr); } - .tree { position: static; height: auto; max-height: 14rem; border-right: none; border-bottom: 1px solid var(--color-border); } + .split { grid-template-columns: minmax(0, 1fr); height: auto; } + .tree { position: static; height: auto; max-height: 16rem; border-right: none; border-bottom: 1px solid var(--line); } + .pane { height: auto; } } -/* Single-column reading content (an entity's cards, a discussion thread, - * a form) caps at `--content-narrow` so it never smears across a wide - * viewport; wide surfaces (the commits table, file lists, diffs) stay at - * the shell's full `--max-width`. */ -.readable { max-width: var(--content-narrow); } -.page-header { margin-bottom: 1.75rem; padding-bottom: 1.25rem; border-bottom: 1px solid var(--color-border); position: relative; display: flex; align-items: center; gap: .75rem; flex-wrap: wrap; } -.page-header::after { content: ""; position: absolute; bottom: -1px; left: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; } -.page-title { font-family: var(--font-serif); font-size: 1.5rem; font-weight: 700; letter-spacing: -.01em; line-height: 1.3; } -/* Cards: the container every generic (reflection-driven) view and every - * bare list/definition list a custom page renders lands inside. */ -.card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); margin-bottom: 1.5rem; overflow: hidden; } -.card-header { display: flex; align-items: center; gap: .5rem; font-family: var(--font-mono); font-size: .72rem; font-weight: 600; text-transform: uppercase; letter-spacing: .06em; color: var(--color-text-muted); background: var(--color-code-bg); padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); } -.card-row { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; font-family: var(--font-mono); font-size: .9rem; } -.card-row + .card-row { border-top: 1px solid var(--color-border); } -.card-row .icon { color: var(--color-text-muted); } -.card-row.is-dir .icon { color: var(--color-accent); } -.card-row a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); flex: 1; min-width: 0; word-break: break-all; } -.card-row a:hover { color: var(--color-accent); } -.card-row a.row-link { display: flex; align-items: center; gap: .65rem; text-decoration: none; } -.card-row:has(.row-link):hover { background: var(--color-code-bg); } +/* ===================== PAGE HEADER ===================== */ +.page-header { margin-bottom: 22px; display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; flex-wrap: wrap; } +.page-title { font-size: 22px; font-weight: 600; letter-spacing: -.3px; line-height: 1.25; } +.page-sub { margin-top: 5px; color: var(--ink-3); font-size: 13px; } +.page-header .mono { font-family: var(--mono); color: var(--ink-2); } -/* A directory listing's per-entry byte size (`crate::pages::files::dir_listing`, - * `human_size`) -- muted, mono, right-aligned, absent for a directory - * entry. */ -.entry-size { margin-left: auto; font-family: var(--font-mono); font-size: .82rem; color: var(--color-text-muted); white-space: nowrap; } - -/* The workbench dashboard (`GET /`, `crate::pages::dashboard`): three - * cards on a `.desk` grid (working tree, needs attention, issues), then - * a full-width `.desk-wide` History card. `.content` already pads the - * column, so the desk itself only spaces its cards. */ -.desk { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 1.25rem; align-items: start; margin-bottom: 1.25rem; } -.desk .card, .desk-wide .card { margin-bottom: 0; } +/* ========================= CARDS ========================= */ +.card { + background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-card); + overflow: hidden; margin-bottom: 18px; +} +.card-header { + display: flex; align-items: center; justify-content: space-between; gap: 10px; + padding: 13px 16px; border-bottom: 1px solid var(--line-2); + font-size: 13px; font-weight: 600; color: var(--ink); +} .card-header .btn-ghost { margin-left: auto; } -.muted { color: var(--color-text-muted); } -.btn { display: inline-block; font-size: .78rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: 8px; padding: .3rem .8rem; cursor: pointer; text-decoration: none; } -.btn-ghost { color: var(--color-accent); background: transparent; border: 1px solid var(--color-border); text-transform: none; letter-spacing: 0; } -.btn-ghost:hover { border-color: var(--color-accent); color: var(--color-accent); } -/* Needs-attention / issue rows: a block link pairing a `.what` line with - * a muted, mono `.where` locator. */ -.attention-row { display: block; padding: .7rem 1.1rem; color: inherit; text-decoration: none; } -.attention-row:hover { background: var(--color-code-bg); text-decoration: none; } -.attention-row + .attention-row { border-top: 1px solid var(--color-border); } -.attention-row .what { display: block; font-size: .88rem; } -.attention-row .where { display: block; font-family: var(--font-mono); font-size: .74rem; color: var(--color-text-muted); margin-top: .15rem; } -/* Scoped-Commits scope chips (`crate::pages::dashboard::scope_class`): - * `.scope-c{0..5}` maps a stable hash of the scope name onto the six - * `--s-*` syntax-token colors. */ -.scope { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; border-radius: var(--radius-pill); padding: 0 .55rem; white-space: nowrap; } -.scope-c0 { color: var(--s-prop); background: color-mix(in srgb, var(--s-prop) 12%, transparent); } -.scope-c1 { color: var(--s-func); background: color-mix(in srgb, var(--s-func) 12%, transparent); } -.scope-c2 { color: var(--s-const); background: color-mix(in srgb, var(--s-const) 12%, transparent); } -.scope-c3 { color: var(--s-type); background: color-mix(in srgb, var(--s-type) 14%, transparent); } -.scope-c4 { color: var(--s-keyword); background: color-mix(in srgb, var(--s-keyword) 12%, transparent); } -.scope-c5 { color: var(--s-string); background: color-mix(in srgb, var(--s-string) 14%, transparent); } -/* History rows: the oid link stays its own width; only the subject cell - * flexes and ellipsizes. */ -.history .card-row { flex-wrap: nowrap; } -.history .card-row a { flex: none; text-decoration: none; } -.history .card-row a:hover { text-decoration: underline; } -.desk-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-sans); font-size: .88rem; } -.blankslate { text-align: center; padding: 3rem 1.5rem; } -.blankslate h2 { font-family: var(--font-serif); font-size: 1.3rem; font-weight: 700; margin-bottom: .5rem; } -.blankslate p { color: var(--color-text-muted); } -.blankslate code { font-family: var(--font-mono); background: var(--color-code-bg); padding: .15rem .45rem; border-radius: 5px; font-size: .85rem; } - -@media (max-width: 1100px) { - .desk { grid-template-columns: minmax(0, 1fr); } +.card-row { + display: flex; align-items: center; gap: 10px; padding: 9px 16px; + font-family: var(--mono); font-size: 12.5px; } +.card-row + .card-row { border-top: 1px solid var(--line-2); } +.card-row .icon { color: var(--ink-3); stroke-width: 1.7; } +.card-row.is-dir .icon { color: var(--accent); } +.card-row a { color: var(--ink); flex: 1; min-width: 0; word-break: break-all; } +.card-row a:hover { color: var(--accent); } +.card-row a.row-link { display: flex; align-items: center; gap: 10px; text-decoration: none; } +.card-row:has(.row-link):hover { background: var(--surface-2); } +.entry-size { margin-left: auto; font-family: var(--mono); font-size: 11.5px; color: var(--ink-3); white-space: nowrap; } -/* The generic reflection-driven views (`crate::render`): a definition list - * for one entity, a table for a list of entities, a plain list for bare - * name lists (toolchains, inbox). */ -.entity-view { font-family: var(--font-mono); font-size: .9rem; } -.entity-view dt { padding: .7rem 1.1rem .1rem; font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); } -.entity-view dd { padding: 0 1.1rem .7rem; word-break: break-all; } -.entity-view dt + dt { padding-top: .1rem; } +/* ========================= DASHBOARD ========================= * + * `crate::pages::dashboard`: three cards on a `.desk` grid, then a + * full-width `.desk-wide` history card. */ +.desk { display: grid; grid-template-columns: 1.15fr 1fr 1fr; gap: 18px; align-items: start; margin-bottom: 18px; } +.desk .card, .desk-wide .card { margin-bottom: 0; } +.desk-wide { grid-column: 1 / -1; } +.attention-row { display: flex; flex-direction: column; gap: 3px; padding: 10px 16px; color: inherit; } +.attention-row:hover { background: var(--surface-2); text-decoration: none; } +.attention-row + .attention-row { border-top: 1px solid var(--line-2); } +.attention-row .what { font-size: 12.5px; color: var(--ink); text-wrap: pretty; } +.attention-row .what .lead { color: var(--amber); font-weight: 500; } +.attention-row .where { font-family: var(--mono); font-size: 11px; color: var(--ink-3); } +.history .card-row { flex-wrap: nowrap; gap: 12px; } +.history .card-row a { flex: none; color: var(--accent); text-decoration: none; } +.history .card-row a:hover { text-decoration: underline; } +.desk-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--sans); font-size: 13px; color: var(--ink); } +.desk-when { flex: none; font-size: 12px; color: var(--ink-3); } +@media (max-width: 1100px) { .desk { grid-template-columns: minmax(0, 1fr); } } -.entity-list { width: 100%; border-collapse: collapse; font-family: var(--font-mono); font-size: .85rem; } -.entity-list th, .entity-list td { padding: .6rem 1.1rem; text-align: left; } +/* ===================== BUTTONS ===================== */ +.btn { + display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 16px; + font-size: 13px; font-weight: 500; color: #fff; background: var(--accent); + border: none; border-radius: var(--radius-inner); cursor: pointer; +} +.btn:hover { background: var(--accent-2); text-decoration: none; color: #fff; } +.btn-sm { height: 24px; padding: 0 10px; font-size: 11.5px; border-radius: var(--radius-chip); } +.btn-ghost { + color: var(--ink-2); background: var(--surface); border: 1px solid var(--line); +} +.btn-ghost:hover { border-color: var(--accent-line); color: var(--accent); background: var(--surface); } + +/* ===================== CHIPS & BADGES ===================== * + * One `.chip` base; state/verdict/check/scope variants layer color and, for + * state/verdict, a leading dot via `.chip .dot`. */ +.chip { + display: inline-flex; align-items: center; gap: 5px; height: 19px; padding: 0 8px; + border-radius: 5px; font-size: 10.5px; font-weight: 600; white-space: nowrap; + color: var(--ink-2); background: var(--surface-2); +} +.chip .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; } +/* Pill-shaped status chips carry more presence (issue/review state). */ +.chip-pill { height: 22px; padding: 0 10px; border-radius: var(--radius-pill); font-size: 11.5px; gap: 5px; } + +.badge { font-family: var(--mono); font-size: 10.5px; color: var(--ink-3); background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-pill); padding: 1px 8px; } + +/* Issue state (`crate::pages::issues`). */ +.state-open, .comment-state-open { color: var(--green); background: var(--green-soft); } +.state-closed, .comment-state-resolved { color: var(--ink-2); background: var(--line-2); } +.state-in-progress { color: var(--amber); background: var(--amber-soft); } +/* Review state (`crate::pages::commits`/reviews). */ +.review-draft { color: var(--violet); background: var(--violet-soft); } +.review-in-review { color: var(--amber); background: var(--amber-soft); } +.review-approved { color: var(--green); background: var(--green-soft); } +.review-changes-requested { color: var(--red); background: var(--red-soft); } +.review-closed { color: var(--ink-2); background: var(--line-2); } +/* Review verdict badge. */ +.verdict { display: inline-flex; align-items: center; height: 19px; padding: 0 8px; border-radius: 5px; font-size: 10.5px; font-weight: 600; } +.verdict-approve { color: var(--green); background: var(--green-soft); } +.verdict-request-changes { color: var(--red); background: var(--red-soft); } +.verdict-comment { color: var(--ink-2); background: var(--line-2); } + +/* Check-status chips (`crate::pages::commits::checks_section`): a glyph + * plus the value, one color per pass/fail/error. */ +.status { display: inline-flex; align-items: center; gap: 4px; font-family: var(--mono); font-size: 10.5px; font-weight: 600; border-radius: 5px; padding: 2px 8px; } +.status::before { font-weight: 700; } +.status-pass { color: var(--status-pass); background: var(--green-soft); } +.status-pass::before { content: "\2713"; } +.status-fail { color: var(--status-fail); background: var(--red-soft); } +.status-fail::before { content: "\2715"; } +.status-error { color: var(--status-error); background: var(--amber-soft); } +.status-error::before { content: "!"; } + +/* Scope chips (`crate::pages::dashboard::scope_class`): deterministic + * color, fixed 52px width so commit subjects align (README). */ +.scope { + display: inline-flex; align-items: center; justify-content: center; flex: none; + width: 52px; height: 19px; border-radius: 5px; + font-family: var(--mono); font-size: 10px; font-weight: 600; white-space: nowrap; + overflow: hidden; text-overflow: ellipsis; +} +.scope-c0 { color: var(--c0-fg); background: var(--c0-bg); } +.scope-c1 { color: var(--c1-fg); background: var(--c1-bg); } +.scope-c2 { color: var(--c2-fg); background: var(--c2-bg); } +.scope-c3 { color: var(--c3-fg); background: var(--c3-bg); } +.scope-c4 { color: var(--c4-fg); background: var(--c4-bg); } +.scope-c5 { color: var(--c5-fg); background: var(--c5-bg); } + +/* ===================== EDITOR PILL ===================== * + * `crate::pages::editor_open` -- the signature web<->editor motif: a small + * teal pill deep-linking `file:line` into the serving user's editor. + * Rendered nothing at all when no editor is configured. */ +.editor-open { + display: inline-flex; align-items: center; gap: 3px; height: 20px; padding: 0 7px; + background: var(--editor-soft); border: 1px solid var(--editor-line); border-radius: var(--radius-chip); + color: var(--editor); font-family: var(--mono); font-size: 10.5px; font-weight: 600; white-space: nowrap; +} +.editor-open:hover { color: var(--editor); text-decoration: none; filter: brightness(.96); } +.editor-open .icon { width: 11px; height: 11px; stroke-width: 2.4; } + +/* ===================== ENTITY VIEWS ===================== * + * The reflection-driven views (`crate::render`): a definition list for one + * entity, a table for a list, a plain list for bare name lists. */ +.entity-view { font-family: var(--mono); font-size: 12.5px; } +.entity-view dt { padding: 12px 16px 2px; font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-3); } +.entity-view dd { padding: 0 16px 12px; word-break: break-all; color: var(--ink); } +.entity-view dt + dt { padding-top: 2px; } + +.entity-list { width: 100%; border-collapse: collapse; font-family: var(--mono); font-size: 12px; } +.entity-list th, .entity-list td { padding: 10px 16px; text-align: left; } .entity-list th { white-space: nowrap; } -/* `render::list_table` marks a cell `.long-token` when one whitespace-free - * token (an ssh key's base64 body, an unbroken hash) is too long to wrap - * at word boundaries; only those cells may break mid-token, so a short - * value like a variant name never shreds. */ .entity-list td.long-token { word-break: break-all; } -.entity-list thead { background: var(--color-code-bg); font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); } -.entity-list tbody tr + tr { border-top: 1px solid var(--color-border); } -.entity-list td a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } -.entity-list td a:hover { color: var(--color-accent); } - -/* The commits list (`crate::pages::commits::list`) reuses `.entity-list`, - * but its oid/author/when columns are short tokens that should stay on - * one line however narrow the table gets; only the subject column -- - * ordinary prose -- wraps at word boundaries. */ +.entity-list thead { background: var(--surface-2); font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-3); } +.entity-list tbody tr + tr { border-top: 1px solid var(--line-2); } +.entity-list td a { color: var(--accent); } .commits-table th:nth-child(1), .commits-table td:nth-child(1), .commits-table th:nth-child(3), .commits-table td:nth-child(3), -.commits-table th:nth-child(4), .commits-table td:nth-child(4) { - white-space: nowrap; -} +.commits-table th:nth-child(4), .commits-table td:nth-child(4) { white-space: nowrap; } -.string-list { list-style: none; font-family: var(--font-mono); font-size: .9rem; } -.string-list li { display: flex; align-items: center; gap: .65rem; padding: .7rem 1.1rem; } -.string-list li + li { border-top: 1px solid var(--color-border); } -.string-list a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } -.string-list a:hover { color: var(--color-accent); } +.string-list { list-style: none; font-family: var(--mono); font-size: 12.5px; } +.string-list li { display: flex; align-items: center; gap: 10px; padding: 10px 16px; } +.string-list li + li { border-top: 1px solid var(--line-2); } +.string-list a { color: var(--ink); } +.string-list a:hover { color: var(--accent); } -/* A per-entity read failure (an older or unrelated schema's tree shape) - * on a show page's own card (`crate::render::unreadable`) -- graceful - * degradation, never a 500. */ -.card-row.unreadable span { - color: var(--color-text-muted); - font-style: italic; +/* Per-entity read failure (`crate::render::unreadable`). */ +.card-row.unreadable span { color: var(--ink-3); font-style: italic; } +.unreadable-detail { display: block; margin-top: 2px; color: var(--ink-3); font-style: normal; font-family: var(--mono); font-size: 11.5px; word-break: break-all; } +.unreadable-note { margin-bottom: 18px; } +.unreadable-note summary { + display: inline-flex; align-items: center; gap: 5px; font-family: var(--mono); font-size: 10.5px; font-weight: 600; + color: var(--amber); background: var(--amber-soft); border: 1px solid var(--editor-line); border-color: color-mix(in srgb, var(--amber) 30%, transparent); + border-radius: var(--radius-pill); padding: 2px 10px; cursor: pointer; list-style: none; user-select: none; -webkit-user-select: none; } -.unreadable-detail { - display: block; - margin-top: .2rem; - color: var(--color-text-muted); - font-style: normal; - font-family: var(--font-mono); - font-size: .78rem; - word-break: break-all; -} - -/* The subtle unreadable-entities disclosure a list page renders when refs - * under its prefix failed to read back (`crate::render::unreadable_disclosure`): - * a muted, badge-shaped `<details>` summary that must never dominate the - * page, expanding -- the element's own no-JS toggle -- to a small card of - * refname/error pairs. */ -.unreadable-note { margin-bottom: 1.25rem; } -.unreadable-note summary { display: inline-flex; align-items: center; gap: .35rem; font-family: var(--font-mono); font-size: .72rem; font-weight: 600; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: .1rem .6rem; cursor: pointer; list-style: none; user-select: none; -webkit-user-select: none; } .unreadable-note summary::-webkit-details-marker { display: none; } -.unreadable-note summary:hover { color: var(--color-text); } -.unreadable-note[open] summary { margin-bottom: .6rem; } +.unreadable-note[open] summary { margin-bottom: 10px; } .unreadable-note .card { margin-bottom: 0; } -.unreadable-note dd { color: var(--color-text-muted); font-size: .78rem; } +.unreadable-note dd { color: var(--ink-3); font-size: 11.5px; } -/* Bare `dl`/`ul`/`form` markup a custom page (toolchains, comments) emits - * directly, styled the same as the generic views above so a page needs no - * per-page CSS hook to look consistent. */ -dl { font-family: var(--font-mono); font-size: .9rem; margin-bottom: 1.5rem; } -dl dt { font-size: .72rem; text-transform: uppercase; letter-spacing: .05em; color: var(--color-text-muted); margin-top: .6rem; } +/* Bare dl/ul markup a custom page emits directly. */ +dl { font-family: var(--mono); font-size: 12.5px; margin-bottom: 18px; } +dl dt { font-size: 10.5px; text-transform: uppercase; letter-spacing: .05em; color: var(--ink-3); margin-top: 10px; } dl dd { word-break: break-all; } -ul:not(.string-list) { list-style: none; font-family: var(--font-mono); font-size: .9rem; margin-bottom: 1.5rem; } -ul:not(.string-list) li { padding: .35rem 0; } +ul:not(.string-list):not(.doc-body ul) { list-style: none; font-family: var(--mono); font-size: 12.5px; margin-bottom: 18px; } +ul:not(.string-list):not(.doc-body ul) li { padding: 5px 0; } -.badge { font-family: var(--font-mono); font-size: .68rem; color: var(--color-text-muted); background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-pill); padding: 0 .5rem; margin-left: .5rem; } +/* ===================== BLANKSLATE ===================== */ +.blankslate { + text-align: center; padding: 40px 24px; border: 1px dashed var(--line); + border-radius: var(--radius-card); background: var(--surface); +} +.blankslate h2 { font-size: 13.5px; font-weight: 600; color: var(--ink-2); margin-bottom: 4px; } +.blankslate p { color: var(--ink-3); font-size: 12.5px; } +.blankslate code { font-family: var(--mono); background: var(--surface-2); border: 1px solid var(--line); padding: 1px 6px; border-radius: 5px; font-size: 12px; } +.card .blankslate { border: none; } -/* Member identity cards (`crate::pages::members::member_card`): username - * prominent, key type as an accent badge, key material truncated through - * the middle with the full line behind a details toggle. */ -.member-head { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap; padding: .8rem 1.1rem; } -.member-head .badge { margin-left: 0; } -.member-name { font-family: var(--font-mono); font-weight: 700; font-size: 1.05rem; color: var(--color-text); } -a.member-name { text-decoration: none; } -a.member-name:hover { color: var(--color-accent); } -.key-badge { font-family: var(--font-mono); font-size: .68rem; font-weight: 600; color: var(--color-accent); background: var(--color-accent-subtle); border: 1px solid color-mix(in srgb, var(--color-accent) 30%, transparent); border-radius: var(--radius-pill); padding: 0 .55rem; white-space: nowrap; } -.member-key { padding: 0 1.1rem .8rem; font-family: var(--font-mono); font-size: .82rem; } -.member-key code { color: var(--color-text-muted); background: var(--color-code-bg); border-radius: 5px; padding: .1rem .45rem; } -.member-key details { margin-top: .4rem; } -.member-key summary { font-size: .72rem; color: var(--color-text-muted); cursor: pointer; user-select: none; -webkit-user-select: none; } -.member-key pre { margin-top: .3rem; padding: .5rem .7rem; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); white-space: pre-wrap; word-break: break-all; font-size: .74rem; } +/* ===================== MEMBER CARDS ===================== * + * `crate::pages::members::member_card`: avatar + username + key-type/state/ + * provenance badges, key material truncated with a full-key disclosure. */ +.member-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; padding: 14px 16px; } +.member-name { font-family: var(--mono); font-weight: 600; font-size: 15px; color: var(--ink); } +a.member-name:hover { color: var(--accent); } +.key-badge { font-family: var(--mono); font-size: 10.5px; font-weight: 600; color: var(--editor); background: var(--editor-soft); border: 1px solid var(--editor-line); border-radius: 5px; padding: 1px 8px; white-space: nowrap; } +.member-key { padding: 0 16px 14px; font-family: var(--mono); font-size: 12px; } +.member-key code { color: var(--ink-3); background: var(--surface-2); border-radius: 5px; padding: 2px 6px; } +.member-key details { margin-top: 6px; } +.member-key summary { font-size: 11px; color: var(--ink-3); cursor: pointer; user-select: none; -webkit-user-select: none; } +.member-key pre { margin-top: 5px; padding: 9px 11px; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-inner); white-space: pre-wrap; word-break: break-all; font-size: 11px; } -/* Forms (`account`, `comments`, `issues`, the review form): each labeled - * field is a compact two-column grid row -- the label in a fixed left - * column, its control on the right -- stacking back to one column under - * the mobile breakpoint. Controls size to their content: the short - * token-shaped fields (`rev`, `lines`, `state`, `verdict`) stop at - * `12rem` instead of stretching, while `title`/`path` inputs and `body` - * textareas keep the full control column. */ -form { display: flex; flex-direction: column; gap: .6rem; margin-bottom: 1.5rem; } -form label { display: grid; grid-template-columns: 8rem minmax(0, 1fr); gap: .25rem .9rem; align-items: center; font-family: var(--font-sans); font-size: .82rem; font-weight: 600; color: var(--color-text-muted); } -form label:has(textarea) { align-items: start; } -form input, form textarea { font-family: var(--font-mono); font-size: .85rem; color: var(--color-text); background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .42rem .7rem; } -form input:focus, form textarea:focus { border-color: var(--color-accent); } -form input[name="rev"], form input[name="lines"], form input[name="state"], form input[name="verdict"] { width: 12rem; justify-self: start; } -form textarea { resize: vertical; min-height: 9rem; } -form button { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: background .15s; } -form button:hover { background: var(--color-link-hover); } +/* ===================== FORMS ===================== * + * A labeled field is a stacked label + control. Short token fields (`rev`, + * `lines`, `state`, `verdict`) stop short instead of stretching. */ +form { display: flex; flex-direction: column; gap: 15px; margin-bottom: 18px; } +form label { display: flex; flex-direction: column; gap: 6px; font-size: 12px; font-weight: 500; color: var(--ink-2); } +form input, form textarea, form select { + font-family: var(--mono); font-size: 13px; color: var(--ink); + background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-inner); + padding: 9px 12px; width: 100%; +} +form input { height: 34px; } +form input:focus, form textarea:focus, form select:focus { border-color: var(--accent); outline: none; } +form input[name="rev"], form input[name="lines"], form input[name="state"], form input[name="verdict"] { max-width: 12rem; } +form textarea { resize: vertical; min-height: 120px; line-height: 1.55; font-family: var(--sans); } +form button { + align-self: flex-start; display: inline-flex; align-items: center; gap: 6px; height: 34px; padding: 0 18px; + font-size: 13px; font-weight: 500; color: #fff; background: var(--accent); + border: none; border-radius: var(--radius-inner); cursor: pointer; +} +form button:hover { background: var(--accent-2); } -/* Breadcrumbs (pure path navigation) and the file browser - * (`crate::pages::files`). */ -.crumbs { font-family: var(--font-mono); font-size: .92rem; margin-bottom: 1.25rem; display: flex; flex-wrap: wrap; align-items: center; gap: .3rem; word-break: break-all; } -.crumbs a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } -.crumbs .sep { color: var(--color-text-muted); opacity: .55; } -.crumbs .here { color: var(--color-text-muted); } +/* Segmented closed-enum picker (state/verdict/review-state): a row of + * buttons where the current option fills with its own chip color. */ +.picker { display: flex; flex-wrap: wrap; gap: 6px; } +.picker button, .picker .opt { + display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 13px; + border-radius: var(--radius-inner); cursor: pointer; font-size: 12.5px; font-weight: 500; + color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); +} +.picker .opt .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--ink-4); } +.picker .opt.active { font-weight: 600; } -/* The blob header bar (`crate::pages::files::blob_header`): a file's name - * and metadata on the left, the history/comment actions that used to - * trail `.crumbs` on the right -- rendered above every blob view (raw - * source, a rendered document, or a binary placeholder), matching the - * `.card-header` family look it sits beside. */ -.blob-header { display: flex; align-items: center; flex-wrap: wrap; gap: .5rem .9rem; padding: .55rem 1.1rem; border-bottom: 1px solid var(--color-border); background: var(--color-code-bg); border-radius: var(--radius-sm) var(--radius-sm) 0 0; } -.blob-title { font-family: var(--font-mono); font-weight: 600; } -.blob-meta { font-size: .78rem; color: var(--color-text-muted); } -.blob-actions { margin-left: auto; display: flex; align-items: center; gap: .9rem; font-size: .82rem; color: var(--color-text-muted); } -.blob-actions a { color: inherit; text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } -.blob-actions a:hover { color: var(--color-accent); } +/* Label picker chips (`labelPicker`): pick existing or type-and-create. */ +.label-chip { + display: inline-flex; align-items: center; gap: 5px; height: 26px; padding: 0 10px; + border-radius: var(--radius-chip); cursor: pointer; font-family: var(--mono); font-size: 11px; + color: var(--ink-2); background: var(--surface-2); border: 1px solid var(--line); +} +.label-chip.on { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); font-weight: 600; } -/* Commit history and view (`crate::pages::commits`). */ -.commit-subject { font-weight: 600; font-size: 1.05rem; margin-bottom: .3rem; } -.commit-msg { font-family: var(--font-mono); font-size: .9rem; white-space: pre-wrap; word-break: break-word; margin-bottom: .3rem; } -.commit-meta { color: var(--color-text-muted); font-size: .85rem; margin-top: .2rem; } -.commit-meta a { text-decoration: underline; text-decoration-color: color-mix(in srgb, currentColor 25%, transparent); } +/* ===================== CRUMBS ===================== */ +.crumbs { display: flex; flex-wrap: wrap; align-items: center; gap: 7px; margin-bottom: 16px; font-family: var(--mono); font-size: 12px; color: var(--ink-3); word-break: break-all; } +.crumbs a { color: var(--ink-3); } +.crumbs a:hover { color: var(--accent); } +.crumbs .sep { color: var(--ink-4); opacity: .8; display: inline-flex; } +.crumbs .sep .icon { width: 12px; height: 12px; } +.crumbs .here { color: var(--ink); } -/* The line-per-row source view (`crate::pages::files::source_view`): a - * `<table>` inside one `overflow-x: auto` wrapper so the whole blob scrolls - * together, its `.blob-nums` gutter cell pinned via `position: sticky` so - * line numbers stay put while wide code scrolls under them -- the same - * frozen-gutter behavior the previous two-`<pre>`-column layout got for - * free, now reconstructed for a per-line table so a comment card - * (`tr.blob-comment-row`) can interleave as a full-width row between any - * two line rows. */ -.blob { overflow-x: auto; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); margin-bottom: 1.5rem; } -.blob table { border-collapse: collapse; width: 100%; font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; } -/* Breathing room above the first row and below the last, without a - * margin/padding on the table itself -- that would leave a gap in the - * sticky gutter's own continuous background column. */ -.blob tr:first-child td { padding-top: .45rem; } -.blob tr:last-child td { padding-bottom: .45rem; } -.blob td.blob-nums { position: sticky; left: 0; text-align: right; color: var(--color-text-muted); background: var(--color-code-bg); border-right: 1px solid var(--color-border); padding: 0 1ch; user-select: none; -webkit-user-select: none; white-space: nowrap; vertical-align: top; } -.blob-nums a { display: block; color: inherit; text-decoration: none; padding: 0 .4ch; border-radius: 4px; outline: none; cursor: pointer; } -.blob-nums a:hover { color: var(--color-accent); } -.blob-nums a:target { color: var(--color-accent); font-weight: 700; } -.blob-nums a:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; } -.blob td.blob-code { padding: 0 1.25rem; white-space: pre; color: var(--color-text); vertical-align: top; } +/* ===================== BLOB VIEW ===================== * + * `crate::pages::files`: a blob header bar over the line-per-row source + * table. */ +.blob-header { + display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; padding: 11px 15px; + background: var(--surface); border: 1px solid var(--line); border-bottom: none; + border-radius: var(--radius-inner) var(--radius-inner) 0 0; +} +.blob-header .icon { width: 15px; height: 15px; stroke-width: 1.7; color: var(--ink-3); } +.blob-title { font-family: var(--mono); font-size: 13px; font-weight: 600; color: var(--ink); } +.blob-meta { font-size: 12px; color: var(--ink-3); } +.blob-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; font-size: 12.5px; color: var(--ink-3); } +.blob-actions a { color: var(--ink-2); } +.blob-actions a:hover { color: var(--accent); } +.blob { + overflow-x: auto; background: var(--surface); border: 1px solid var(--line); + border-radius: 0 0 var(--radius-inner) var(--radius-inner); margin-bottom: 18px; padding: 8px 0 10px; +} +/* A blob rendered without a header bar keeps all four corners rounded. */ +.blob:first-child { border-radius: var(--radius-inner); } +.blob table { border-collapse: collapse; width: 100%; font-family: var(--mono); font-size: 12.5px; line-height: 21px; } +.blob td.blob-nums { + position: sticky; left: 0; width: 52px; text-align: right; padding: 0 12px 0 0; vertical-align: top; + color: var(--ink-4); background: var(--surface); user-select: none; -webkit-user-select: none; white-space: nowrap; +} +.blob-nums a { display: block; color: inherit; padding: 0 .4ch; border-radius: 4px; cursor: pointer; } +.blob-nums a:hover { color: var(--accent); } +.blob-nums a:target { color: var(--accent); font-weight: 600; } +.blob td.blob-code { padding: 0 16px 0 4px; white-space: pre; color: var(--ink); vertical-align: top; } .blob-code code { font-family: inherit; } -.blob tr.blob-comment-row td { padding: 0; background: var(--color-surface); } -.blob tr.blob-comment-row .card { margin: .5rem 1rem; } -.binary { padding: 2.5rem; text-align: center; font-family: var(--font-mono); font-size: .85rem; color: var(--color-text-muted); } +.blob tr.blob-comment-row td { padding: 0; background: var(--surface); } +.blob tr.blob-comment-row .card { margin: 5px 26px 7px 52px; } +.binary { padding: 40px; text-align: center; font-family: var(--mono); font-size: 12.5px; color: var(--ink-3); } -/* Client-side line selection (`assets/ents.js`): `.blob-nums a:target` above - * is the no-JS fallback for a single anchored line; `tr.sel` is the - * script's own richer selection, spanning a whole clicked/shift-clicked - * range. */ -.blob tr.sel td { background: var(--color-accent-subtle); } -.blob tr.sel td.blob-nums { color: var(--color-accent); font-weight: 700; } - -/* The gutter's "+" comment affordance (`assets/ents.js`): injected once per - * line row, absolutely positioned inside the already-positioned (sticky) - * `.blob-nums` cell so it costs no layout of its own and never shifts line - * height, hidden until that row is hovered or the button itself is - * focused. */ -.blob-add { position: absolute; top: 50%; left: .2rem; transform: translateY(-50%); width: 15px; height: 15px; line-height: 14px; padding: 0; text-align: center; font-size: .78rem; font-weight: 700; color: var(--color-bg); background: var(--color-accent); border: none; border-radius: 4px; opacity: 0; cursor: pointer; transition: opacity .1s; } +/* Client-side line selection (`assets/ents.js`): `tr.sel` spans a + * clicked/shift-clicked range with the design's accent-soft + inset bar. */ +.blob tr.sel td { background: var(--accent-soft); } +.blob tr.sel td.blob-nums { color: var(--accent); font-weight: 600; box-shadow: inset 3px 0 0 var(--accent); } +.blob-add { + position: absolute; top: 50%; left: 6px; transform: translateY(-50%); + width: 17px; height: 17px; line-height: 16px; padding: 0; text-align: center; + font-size: 13px; font-weight: 600; color: #fff; background: var(--accent); + border: none; border-radius: 5px; opacity: 0; cursor: pointer; transition: opacity .1s; +} +.blob td.blob-nums { position: sticky; } /* keep the affordance's containing block */ .blob tr:hover .blob-add, .blob-add:focus-visible { opacity: 1; } -/* The inline comment composer (`crate::pages::files::composer_template`, - * cloned and shown by `assets/ents.js`): mirrors `tr.blob-comment-row - * .card`'s own margin so it lands flush with the comment cards it - * follows. */ -.blob tr.blob-composer td { padding: 0; background: var(--color-surface); } -.composer-form { margin: .5rem 1rem; background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); padding: .75rem 1rem; } -.composer-form textarea { min-height: 6rem; } -.composer-buttons { display: flex; flex-direction: row; gap: .5rem; } -.composer-buttons button { margin: 0; } -.composer-cancel { align-self: flex-start; font-size: .88rem; font-weight: 600; color: var(--color-text); background: transparent; border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: .45rem 1rem; cursor: pointer; transition: border-color .15s, color .15s; } -.composer-cancel:hover { color: var(--color-accent); border-color: var(--color-accent); } +/* Inline comment composer (`assets/ents.js` clones `composer-template`). */ +.blob tr.blob-composer td { padding: 0; background: var(--surface); } +.composer-form { + margin: 5px 26px 8px 52px; background: var(--surface); border: 1px solid var(--accent); + border-radius: 9px; padding: 12px 13px; box-shadow: 0 4px 14px rgba(74, 63, 196, .1); +} +.composer-form textarea { min-height: 74px; } +.composer-buttons { display: flex; flex-direction: row; gap: 8px; } +.composer-buttons button { margin: 0; height: 28px; padding: 0 14px; } +.composer-cancel { + align-self: flex-start; height: 28px; padding: 0 12px; font-size: 12.5px; font-weight: 500; + color: var(--ink-2); background: var(--surface); border: 1px solid var(--line); + border-radius: 7px; cursor: pointer; +} +.composer-cancel:hover { color: var(--accent); border-color: var(--accent-line); } -/* File-anchored comment cards (`crate::pages::comments::comment_card`), - * whether interleaved into a blob's own table (`tr.blob-comment-row`, - * above) or listed below it (`comments_section`/`outdated_comments_section`) - * -- each comment is its own `.card`, its body reusing `.doc-body`'s prose - * styling (a comment body renders as AsciiDoc, same as a rendered document - * blob). */ -.comment-meta { display: flex; flex-wrap: wrap; align-items: center; gap: .5rem; padding: .7rem 1.1rem; font-size: .82rem; color: var(--color-text-muted); border-bottom: 1px solid var(--color-border); } -.comment-meta .author { color: var(--color-text); font-weight: 600; } -.outdated { color: var(--color-text-muted); font-style: italic; } -/* State/verdict pills on comment, issue, and review cards - * (`crate::pages::{comments,issues,commits}`). */ -.comment-state, .verdict { display: inline-block; padding: .05rem .5rem; border: 1px solid var(--color-border); border-radius: var(--radius-sm); font-size: .78rem; font-weight: 600; text-transform: lowercase; color: var(--color-text-muted); } -.verdict { color: var(--color-text); } -/* Open-in-editor affordances (`crate::pages::editor_open`): a small, - * muted icon deep-linking a code location into the serving user's own - * editor ($ENTS_EDITOR, then $EDITOR). */ -.editor-open { display: inline-flex; align-items: center; vertical-align: -2px; color: var(--color-text-muted); } -.editor-open:hover { color: var(--color-accent); } -.editor-open .icon { width: 14px; height: 14px; } -/* Check-status chips (`crate::pages::commits::checks_section`): one color - * per value of the closed pass/fail/error result taxonomy. */ -.status { display: inline-block; font-family: var(--font-mono); font-size: .72rem; font-weight: 700; border-radius: var(--radius-pill); padding: .05rem .6rem; text-transform: lowercase; } -.status-pass { color: var(--status-pass); background: color-mix(in srgb, var(--status-pass) 12%, transparent); } -.status-fail { color: var(--status-fail); background: color-mix(in srgb, var(--status-fail) 12%, transparent); } -.status-error { color: var(--status-error); background: color-mix(in srgb, var(--status-error) 14%, transparent); } -/* The reply/resolve/reopen and comment composer forms on a thread card. */ -.comment-actions { display: flex; flex-wrap: wrap; align-items: flex-start; gap: .6rem; padding: .7rem 1.1rem; border-top: 1px solid var(--color-border); } +/* ===================== COMMITS ===================== */ +.commit-subject { font-size: 19px; font-weight: 600; letter-spacing: -.2px; margin-bottom: 8px; } +.commit-msg { font-family: var(--mono); font-size: 12.5px; white-space: pre-wrap; word-break: break-word; color: var(--ink-2); margin-bottom: 8px; } +.commit-meta { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; color: var(--ink-3); font-size: 12px; font-family: var(--mono); margin-top: 6px; } +.commit-meta a { color: var(--ink-3); } +.commit-meta a:hover { color: var(--accent); } -/* Syntax-highlight token classes (`crate::pages::files::highlight`, `arborium`'s `HtmlFormat::ClassNames`). */ +/* ===================== COMMENT CARDS ===================== * + * `crate::pages::comments::comment_card` -- the universal conversational + * primitive: avatar + author + relative time + optional reply/state, an + * AsciiDoc body reusing `.doc-body`. */ +.comment-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 12px 14px 6px; font-size: 11.5px; color: var(--ink-3); } +.comment-meta .author { color: var(--ink); font-weight: 600; font-size: 12.5px; } +.comment-meta .reply { font-family: var(--mono); font-size: 10px; } +.comment-meta .spacer { flex: 1; } +.comment-body { padding: 0 14px 12px; font-size: 13px; line-height: 1.55; color: var(--ink-2); text-wrap: pretty; } +.comment-card + .comment-card { border-top: 1px solid var(--line-2); } +.outdated { color: var(--amber); background: var(--amber-soft); font-family: var(--mono); font-size: 10px; padding: 2px 7px; border-radius: 5px; } +.comment-state { display: inline-flex; align-items: center; height: 18px; padding: 0 7px; border-radius: 5px; font-size: 10px; font-weight: 600; color: var(--amber); background: var(--amber-soft); } +.comment-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: 10px 14px; border-top: 1px solid var(--line-2); background: var(--surface-2); } +.comment-actions form { margin: 0; } +/* An anchored blob thread card's header strip (thread on L<n>). */ +.thread-head { display: flex; align-items: center; gap: 8px; padding: 6px 13px; background: var(--surface-2); border-bottom: 1px solid var(--line-2); font-family: var(--mono); font-size: 11px; color: var(--ink-3); } +.thread-head .dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex: none; } + +/* ===================== SYNTAX ===================== * + * `crate::pages::files::highlight` (`arborium` `ClassNames`). */ .code .keyword, .code .macro, .code .tag { color: var(--s-keyword); } .code .function, .code .constructor { color: var(--s-func); } .code .type { color: var(--s-type); } @@ -469,61 +650,114 @@ .code .comment { color: var(--s-comment); font-style: italic; } .code .operator, .code .punctuation { color: var(--s-op); } .code .property, .code .attribute { color: var(--s-prop); } -.code .title { color: var(--s-keyword); font-weight: 700; } -.code .strong { font-weight: 700; } +.code .title { color: var(--s-keyword); font-weight: 600; } +.code .strong { font-weight: 600; } .code .emphasis { font-style: italic; } .code .link, .code .url, .code .reference { color: var(--s-prop); text-decoration: underline; } .code .markup { color: var(--s-func); } -/* Unified diffs (`crate::pages::commits::diff_view`). */ -.diff { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius-sm); box-shadow: var(--shadow-sm); overflow-x: auto; margin-bottom: 1.5rem; font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; padding: .6rem 0; } -.diff .ln { display: block; padding: 0 1rem; white-space: pre; } -.diff .add { background: var(--diff-add); } -.diff .del { background: var(--diff-del); } -.diff .hunk { color: var(--s-prop); background: var(--color-code-bg); } -.diff .meta { color: var(--color-text-muted); } -.diff .file { color: var(--color-text); font-weight: 600; background: var(--color-code-bg); padding-top: .3rem; padding-bottom: .3rem; } +/* ===================== DIFFS ===================== * + * `crate::pages::commits::diff_view`. */ +.diff { background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-inner); overflow-x: auto; margin-bottom: 12px; font-family: var(--mono); font-size: 12px; line-height: 19px; } +.diff .ln { display: block; padding: 2px 14px; white-space: pre; } +.diff .add { background: var(--diff-add); color: var(--green); } +.diff .del { background: var(--diff-del); color: var(--red); } +.diff .hunk { color: var(--accent); background: var(--surface-2); } +.diff .meta { color: var(--ink-3); } +.diff .file { color: var(--ink); font-weight: 600; background: var(--surface-2); border-bottom: 1px solid var(--line); padding: 9px 14px; } -/* A rendered document's own metadata -- Markdown frontmatter and AsciiDoc - * header attributes (`crate::render::properties_table`) -- as a bordered - * definition list above the document body, reusing `.entity-view`'s own - * dt/dd rhythm. `pre-wrap` on the value cell keeps an unparsed nested - * structure's raw text readable line by line. */ -.doc-props { border: 1px solid var(--color-border); border-radius: var(--radius-sm); background: var(--color-code-bg); padding: .35rem 0; margin: 0 0 1.5rem; } +/* ===================== RENDERED DOCS ===================== */ +.doc-props { border: 1px solid var(--line); border-radius: var(--radius-inner); background: var(--surface-2); padding: 6px 0; margin: 0 0 18px; } .doc-props dd { white-space: pre-wrap; } - -/* Rendered Markdown/AsciiDoc documents (`crate::markdown`, `crate::asciidoc`). */ -.doc-body { padding: 40px 48px 52px; max-width: 44rem; overflow-wrap: break-word; } +.doc-body { padding: 28px 32px 36px; max-width: 44rem; overflow-wrap: break-word; font-size: 14px; line-height: 1.62; color: var(--ink-2); } .doc-body > :first-child { margin-top: 0; } -.doc-body h1, .doc-body h2, .doc-body h3, .doc-body h4 { font-family: var(--font-serif); font-weight: 700; letter-spacing: -.01em; line-height: 1.25; margin: 1.8rem 0 .9rem; } -.doc-body h1 { font-size: 2.4rem; letter-spacing: -.02em; margin-top: 0; } -.doc-body .doc-subtitle { font-family: var(--font-serif); font-style: italic; font-size: 1.18rem; color: var(--color-text-muted); margin: -.4rem 0 1.2rem; } -.doc-body h2 { font-size: 1.4rem; font-weight: 600; position: relative; padding-bottom: .55rem; } -.doc-body h2::after { content: ""; position: absolute; left: 0; bottom: 0; width: 3rem; height: 2px; background: var(--color-accent); border-radius: 1px; } -.doc-body h3 { font-size: 1.15rem; font-weight: 600; } +.doc-body h1, .doc-body h2, .doc-body h3, .doc-body h4 { font-family: var(--sans); font-weight: 600; letter-spacing: -.01em; line-height: 1.3; margin: 1.6rem 0 .8rem; color: var(--ink); } +.doc-body h1 { font-size: 1.9rem; letter-spacing: -.02em; margin-top: 0; } +.doc-body .doc-subtitle { font-style: italic; font-size: 1.1rem; color: var(--ink-3); margin: -.3rem 0 1.1rem; } +.doc-body h2 { font-size: 1.35rem; padding-bottom: .5rem; border-bottom: 1px solid var(--line); } +.doc-body h3 { font-size: 1.12rem; } .doc-body p, .doc-body ul, .doc-body ol { margin: 0 0 1rem; } -.doc-body ul, .doc-body ol { padding-left: 1.4rem; list-style: revert; font-family: inherit; } -.doc-body li { margin: .25rem 0; padding: 0; } -.doc-body a { font-weight: 500; } -.doc-body code, .doc-body .literal { font-family: var(--font-mono); font-size: .86em; background: var(--color-code-bg); padding: .1rem .35rem; border-radius: 5px; } -.doc-body pre { font-family: var(--font-mono); font-size: .82rem; line-height: 1.55; background: var(--color-code-bg); border: 1px solid var(--color-border); border-radius: var(--radius-sm); padding: 1rem 1.2rem; overflow-x: auto; margin: 0 0 1rem; } -.doc-body pre code { background: none; padding: 0; font-size: inherit; } -.doc-body blockquote { border-left: 3px solid var(--color-accent); padding: .2rem 0 .2rem 1.1rem; margin: 0 0 1rem; color: var(--color-text-muted); } -.doc-body table { border-collapse: collapse; margin: 0 0 1rem; font-size: .92rem; } -.doc-body th, .doc-body td { border: 1px solid var(--color-border); padding: .4rem .7rem; text-align: left; } -.doc-body th { background: var(--color-code-bg); font-weight: 600; } +.doc-body ul, .doc-body ol { padding-left: 1.4rem; list-style: revert; } +.doc-body li { margin: .25rem 0; } +.doc-body a { font-weight: 500; text-decoration: underline; } +.doc-body code, .doc-body .literal { font-family: var(--mono); font-size: .86em; background: var(--surface-2); border: 1px solid var(--line); padding: 1px 5px; border-radius: 5px; } +.doc-body pre { font-family: var(--mono); font-size: 12.5px; line-height: 1.55; background: var(--surface-2); border: 1px solid var(--line); border-radius: var(--radius-inner); padding: 14px 16px; overflow-x: auto; margin: 0 0 1rem; } +.doc-body pre code { background: none; border: none; padding: 0; font-size: inherit; } +.doc-body blockquote { border-left: 3px solid var(--accent); padding: .2rem 0 .2rem 1.1rem; margin: 0 0 1rem; color: var(--ink-3); } +.doc-body table { border-collapse: collapse; margin: 0 0 1rem; font-size: .95rem; } +.doc-body th, .doc-body td { border: 1px solid var(--line); padding: .4rem .7rem; text-align: left; } +.doc-body th { background: var(--surface-2); font-weight: 600; } .doc-body img { max-width: 100%; height: auto; } -.doc-body hr { border: none; border-top: 1px solid var(--color-border); margin: 1.8rem 0; } +.doc-body hr { border: none; border-top: 1px solid var(--line); margin: 1.6rem 0; } +/* ===================== LOGIN ===================== * + * `crate::pages::login` -- the inverted device-code flow: a dark code + * block, only ever mounted on a hosted deployment. */ +.login-code { + font-family: var(--mono); font-size: 15px; color: #fff; background: var(--ink); + border-radius: var(--radius-inner); padding: 16px 18px; overflow-x: auto; white-space: pre; +} +.login-code .code { color: var(--editor); font-weight: 600; } + +/* ============ PAGE-SCOPED ADDITIONS ============ * + * Classes the per-page reskin introduced, collected here so the whole + * design system stays in one stylesheet (the page modules never edit CSS + * themselves; they report the rule and it lands here). */ + +/* The editor pill sits inside flex rows (`.card-row`, `.blob-actions`, + * `.commit-meta`) and must never stretch like a `.card-row a` link does. */ +.editor-open { flex: none; } +.ed-loc { white-space: nowrap; } + +/* Dashboard working-tree change-kind chips (README ChangeKindLabel), + * layered on the `.chip` base. */ +.chip-added { color: var(--green); background: var(--green-soft); } +.chip-modified { color: var(--amber); background: var(--amber-soft); } +.chip-deleted { color: var(--red); background: var(--red-soft); } +.chip-renamed { color: var(--accent); background: var(--accent-soft); } +.chip-untracked { color: var(--ink-2); background: var(--line-2); } + +/* Dashboard working-tree path: ellipsize from the front (`direction:rtl`) + * so the filename stays visible when the path overflows. */ +.desk-path { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; direction: rtl; text-align: left; } + +/* Dashboard header's right-aligned deployment-mode line. */ +.desk-status { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--ink-3); } +.desk-status .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--editor); flex: none; } + +/* Files' outdated-comments section heading (warn glyph + title). */ +.outdated-head { display: flex; align-items: center; gap: 8px; margin: 0 0 6px; font-size: 12.5px; font-weight: 600; color: var(--ink-2); } +.outdated-head .icon { width: 14px; height: 14px; stroke-width: 1.9; color: var(--amber); } + +/* Issues sidebar status dot, colored by issue state (the chip's own leading + * dot already inherits via `.chip .dot`). */ +.side-meta .dot.state-open { background: var(--green); } +.side-meta .dot.state-in-progress { background: var(--amber); } +.side-meta .dot.state-closed { background: var(--ink-4); } + +/* Segmented pickers: hide the native radio and fill the chosen option. + * Issues' state picker is server-rendered (`.opt.active.state-*`, colored by + * state); the commit verdict picker is plain radios, so its checked option + * fills via `:has()`. */ +.picker .opt { position: relative; } +.picker .opt input { position: absolute; width: 1px; height: 1px; opacity: 0; margin: 0; pointer-events: none; } +.picker .opt:has(input:checked) { color: var(--accent); background: var(--accent-soft); border-color: var(--accent-line); font-weight: 600; } +.picker .opt:has(input:checked) .dot, .picker .opt.active .dot { background: currentColor; } +.picker .opt.active.state-open { color: var(--green); background: var(--green-soft); border-color: color-mix(in srgb, var(--green) 40%, transparent); } +.picker .opt.active.state-in-progress { color: var(--amber); background: var(--amber-soft); border-color: color-mix(in srgb, var(--amber) 40%, transparent); } +.picker .opt.active.state-closed { color: var(--ink-2); background: var(--line-2); border-color: var(--line); } + +/* Disclosure summaries (issue Edit, account login-mapping) read as + * clickable. */ +details > summary { cursor: pointer; } + +/* ===================== RESPONSIVE ===================== */ @media (max-width: 640px) { - html { font-size: 16px; } - .content { padding: 1.5rem 1.25rem 2.5rem; } - form label { grid-template-columns: minmax(0, 1fr); } - .wb-bar { gap: .6rem; padding: 0 .9rem; } - .palette { flex-basis: 14rem; } + .content { padding: 22px 18px 40px; } + .wb-bar { gap: 10px; padding: 0 12px; } + .palette { max-width: none; } .palette kbd { display: none; } - .page-title { font-size: 1.3rem; } - .doc-body { padding: 1.75rem 1.5rem 2rem; } - .doc-body h1 { font-size: 1.9rem; } - .doc-body h2 { font-size: 1.25rem; } + .page-title { font-size: 19px; } + .doc-body { padding: 22px 20px 28px; } + .doc-body h1 { font-size: 1.6rem; } }
crates/cli/ents-web/src/assets/sprite.svg @@ -1,15 +1,18 @@ <svg style="display:none" xmlns="http://www.w3.org/2000/svg"> - <symbol id="i-home" viewBox="0 0 16 16"><path d="M2 7.5 8 2l6 5.5V14H9.5v-3.5h-3V14H2z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> - <symbol id="i-files" viewBox="0 0 16 16"><path d="M1.5 3.5h4l1.5 2h7.5v7h-13z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> - <symbol id="i-commit" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M.5 8h4.9M10.6 8h4.9" stroke="currentColor" stroke-width="1.5"/></symbol> - <symbol id="i-issue" viewBox="0 0 16 16"><circle cx="8" cy="8" r="6" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="8" cy="8" r="1.6" fill="currentColor"/></symbol> - <symbol id="i-comment" viewBox="0 0 16 16"><path d="M2 2.5h12v8H8l-3.5 3v-3H2z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/></symbol> - <symbol id="i-meta" viewBox="0 0 16 16"><circle cx="8" cy="8" r="2.2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M8 1.2v2.2M8 12.6v2.2M1.2 8h2.2M12.6 8h2.2M3.5 3.5l1.5 1.5M11 11l1.5 1.5M12.5 3.5 11 5M5 11l-1.5 1.5" stroke="currentColor" stroke-width="1.5"/></symbol> - <symbol id="i-people" viewBox="0 0 16 16"><circle cx="5.5" cy="5.5" r="2.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M1.5 14a4 4 0 0 1 8 0" fill="none" stroke="currentColor" stroke-width="1.5"/><circle cx="11.5" cy="6.5" r="2" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M11 14a3.5 3.5 0 0 1 3.8-3.2" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol> - <symbol id="i-person" viewBox="0 0 16 16"><circle cx="8" cy="5" r="2.6" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M2.5 14a5.5 5.5 0 0 1 11 0" fill="none" stroke="currentColor" stroke-width="1.5"/></symbol> - <symbol id="i-search" viewBox="0 0 16 16"><circle cx="7" cy="7" r="4.5" fill="none" stroke="currentColor" stroke-width="1.5"/><path d="M10.5 10.5 14 14" stroke="currentColor" stroke-width="1.5"/></symbol> - <symbol id="i-menu" viewBox="0 0 16 16"><path d="M2 4h12M2 8h12M2 12h12" stroke="currentColor" stroke-width="1.5"/></symbol> - <symbol id="i-ed-zed" viewBox="0 0 16 16"><path d="M2.5 3.5h11l-11 9h11" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round" stroke-linecap="round"/><path d="M6.5 8h3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol> - <symbol id="i-ed-code" viewBox="0 0 16 16"><path d="M11.6.9 5.2 6.7 2.4 4.5l-1.3.7L4.3 8l-3.2 2.8 1.3.7 2.8-2.2 6.4 5.8 3.5-1.7v-11zM11.4 4.6v6.8L7.7 8z" fill="currentColor"/></symbol> - <symbol id="i-ed-nvim" viewBox="0 0 16 16"><path d="M2.8 4.6 5.8 1.4v13.2l-3-3.2zM13.2 11.4l-3 3.2V1.4l3 3.2z" fill="currentColor"/><path d="M5.8 3.8l4.4 8.4" stroke="currentColor" stroke-width="1.6"/></symbol> + <symbol id="i-home" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 12l9-8 9 8"/><path d="M5 10v10h14V10"/></symbol> + <symbol id="i-files" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18v14H3z"/><path d="M8 10l-2 2 2 2"/><path d="M14 10l2 2-2 2"/></symbol> + <symbol id="i-commit" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 3v6M12 15v6"/></symbol> + <symbol id="i-review" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M9 5H7a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2h-2"/><path d="M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v0a1 1 0 0 1-1 1h-4a1 1 0 0 1-1-1z"/><path d="M9 14l2 2 4-4"/></symbol> + <symbol id="i-issue" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 8v4M12 16h.01"/></symbol> + <symbol id="i-comment" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a8 8 0 0 1-11.5 7.2L4 20l1-4.4A8 8 0 1 1 21 12z"/></symbol> + <symbol id="i-meta" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M12 2v3M12 19v3M4.2 4.2l2.1 2.1M17.7 17.7l2.1 2.1M2 12h3M19 12h3M4.2 19.8l2.1-2.1M17.7 6.3l2.1-2.1"/></symbol> + <symbol id="i-people" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3.2"/><path d="M3.5 20a5.5 5.5 0 0 1 11 0"/><circle cx="17" cy="9" r="2.6"/><path d="M16 14.4a4.4 4.4 0 0 1 4.5 4"/></symbol> + <symbol id="i-person" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21v-1a7 7 0 0 1 16 0v1"/></symbol> + <symbol id="i-search" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="7"/><path d="M20 20l-3.5-3.5"/></symbol> + <symbol id="i-branch" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="8" r="2.5"/><path d="M6 8.5v7M18 10.5c0 4-6 1.5-6 5"/></symbol> + <symbol id="i-menu" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18M3 12h18M3 18h18"/></symbol> + <symbol id="i-file" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h9l3 3v15H6z"/><path d="M14 3v4h4"/></symbol> + <symbol id="i-folder" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7h5l2 2h11v9H3z"/></symbol> + <symbol id="i-warn" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l9 16H3z"/><path d="M12 10v4M12 17h.01"/></symbol> + <symbol id="i-editor" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"><path d="M7 17L17 7M9 7h8v8"/></symbol> </svg>
crates/cli/ents-web/src/pages/account.rs @@ -69,6 +69,7 @@ "Account", html! { div.readable { + h2 { "Signing key" } @match &enrolled { Some((username, member)) => { p { @@ -80,14 +81,22 @@ (super::members::member_card(username.as_str(), member, true)) } None => { - div.card { + div.card.member-card { + div.member-head { + span.member-name { "Unenrolled key" } + @if let Some(key_type) = pubkey.split_whitespace().next() { + span.key-badge { (key_type) } + } + } p { "This signing key is not enrolled as a " a href="/members" { "member" } " of this repository. Edits still sign with it; enroll " "the key to have them attributed to a username." } - pre { (pubkey) } + div.member-key { + pre { (pubkey) } + } } } } @@ -99,7 +108,7 @@ } (view) details { - summary { "Edit" } + summary { "Edit login mapping" } form method="post" action="/account" { (super::csrf_input(&session)) label { "member" input type="text" name="member" value=(member_value) list="members"; }
crates/cli/ents-web/src/pages/comments.rs @@ -172,6 +172,10 @@ }; let resolved = comment.state == "resolved"; let return_to = format!("/comments/{id}"); + let authorship = ents_model::namespace::comment_ref(&id) + .ok() + .and_then(|ref_name| state.refs.get(ref_name.as_ref()).ok().flatten()) + .and_then(|tip| super::commit_authorship(&*state.objects(), tip).ok()); let body = crate::asciidoc::to_html(&comment.body).unwrap_or_else(|_| html! { p { (comment.body) } }); Ok(super::layout( @@ -184,28 +188,39 @@ div.readable { div.card { div.comment-meta { - span.comment-state { (comment.state) } + @if let Some((author, seconds)) = &authorship { + (super::avatar(author)) + span.author { (author) } + span { (super::ago(*seconds)) } + } + @if let Some(parent) = &comment.parent { + a.reply href={ "/comments/" (parent) } { + "\u{21b3} reply to " (ents_forge::abbreviate_id(parent)) + } + } @if let Some(context) = &comment.context { (context_link(context)) } - @if let Some(parent) = &comment.parent { - a href={ "/comments/" (parent) } { - "in reply to " (ents_forge::abbreviate_id(parent)) - } - } + span.spacer {} @if let Some((anchor, _)) = &projected { a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } { (anchor.path) (line_label(anchor.lines)) } (super::editor_open(&state, &anchor.path, anchor.lines.map(|range| range.start))) + } @else { + span { "unanchored" } } + @if let Some((_, Projection::Outdated { .. })) = &projected { + span.outdated { "outdated" } + } + span.comment-state { (comment.state) } } + div.comment-body { (body) } @if let Some((_, projection)) = &projected { div.comment-meta { span { "at " (query.rev) ": " (projection_label(projection)) } } } - div.doc-body { (body) } } (action_forms(&session, &id, resolved, &return_to)) } @@ -287,23 +302,30 @@ html! { div.card { div.comment-meta { - a href={ "/comments/" (id) } { (ents_forge::abbreviate_id(id)) } @if let Some((author, seconds)) = &authorship { + (super::avatar(author)) span.author { (author) } span { (super::ago(*seconds)) } } - span.comment-state { (comment.state) } + a href={ "/comments/" (id) } { (ents_forge::abbreviate_id(id)) } + @if comment.parent.is_some() { + span.reply { "\u{21b3} reply" } + } @if let Some(context) = &comment.context { (context_link(context)) } + span.spacer {} @if let Some(anchor) = &anchor { a href={ "/files/" (anchor.path) (line_fragment(anchor.lines)) } { (anchor.path) (line_label(anchor.lines)) } (super::editor_open(state, &anchor.path, anchor.lines.map(|range| range.start))) + } @else { + span { "unanchored" } } + span.comment-state { (comment.state) } } - div.doc-body { (body) } + div.comment-body { (body) } } } } @@ -770,8 +792,10 @@ html! { div.card id={ "comment-" (index) } { div.comment-meta { + (super::avatar(&comment.author)) span.author { (comment.author) } span { (super::ago(comment.seconds)) } + span.spacer {} @if let Some(range) = comment.lines { @match link { LinkMode::SameFile => { @@ -793,7 +817,7 @@ span.outdated { "outdated" } } } - div.doc-body { (comment.body) } + div.comment-body { (comment.body) } } } } @@ -826,15 +850,17 @@ div.card id={ "thread-" (id) } { div.comment-meta { @if let Some((author, seconds)) = &authorship { + (super::avatar(author)) span.author { (author) } span { (super::ago(*seconds)) } } @if comment.parent.is_some() { - span { "reply" } + span.reply { "\u{21b3} reply" } } + span.spacer {} span.comment-state { (comment.state) } } - div.doc-body { (body) } + div.comment-body { (body) } (action_forms(session, id, comment.state == "resolved", return_to)) } }
crates/cli/ents-web/src/pages/commits.rs @@ -101,22 +101,10 @@ @if rows.is_empty() { (blankslate()) } @else { - div.card { + div.card.history { div.card-header { "commits" } - table.entity-list.commits-table { - thead { - tr { th { "commit" } th { "subject" } th { "author" } th { "when" } } - } - tbody { - @for row in &rows { - tr { - td { a href={ "/commit/" (row.oid) } { code { (row.short) } } } - td { (row.subject) } - td { (row.author) } - td { (row.ago) } - } - } - } + @for row in &rows { + (commit_row(row)) } } @if let Some(from) = older { @@ -196,6 +184,31 @@ (rows, older) } +/// One [`CommitRow`] as a `.card-row` (the design's `CommitRow` component, +/// README's Commits/Commit-detail screens): its short oid as an accent mono +/// link, a `.scope` chip ([`super::split_scope`]/[`super::scope_class`]) +/// when the subject carries a Scoped-Commits prefix, the (possibly +/// stripped) description ellipsized in the remaining space, the author, +/// and its relative age -- the one place a commit row's markup is spelled, +/// so [`list`]'s pager reads the same row [`super::dashboard`]'s History +/// card already renders. +fn commit_row(row: &CommitRow) -> Markup { + html! { + div.card-row { + a href={ "/commit/" (row.oid) } { code { (row.short) } } + @match super::split_scope(&row.subject) { + Some((scope, rest)) => { + span class={ "scope " (super::scope_class(scope)) } { (scope) } + span.desk-subject { (rest) } + }, + None => { span.desk-subject { (row.subject) } }, + } + span.muted { (row.author) } + span.entry-size { (row.ago) } + } + } +} + /// The empty-history placeholder ([`super::blankslate`]): an unborn /// `HEAD`, or a repository this page could not open at all. fn blankslate() -> Markup { @@ -256,7 +269,7 @@ }; let empty_tree = repo.empty_tree(); let old_tree_ref = old_tree.as_ref().unwrap_or(&empty_tree); - let (diff, truncated) = diff_sections(&repo, old_tree_ref, &new_tree); + let (diff, truncated) = diff_sections(&state, &repo, old_tree_ref, &new_tree); let comments = super::comments::for_commit(&state, object_id); let checks = checks_section(&state, object_id); let reviews = reviews_section(&state, &session, object_id, &oid); @@ -476,7 +489,8 @@ @for ((target, member), review) in &reviews { div.card { div.comment-meta { - span.verdict { (review.verdict) } + span class={ "verdict verdict-" (review.verdict) } { (review.verdict) } + (super::avatar(member.as_str())) span.author { (member) } @let reviewer = ents_model::namespace::review_ref(target, member) .ok() @@ -584,19 +598,32 @@ } /// The start-a-review form (`POST /commit/{oid}/review`): a verdict and -/// a body. The verdict is a closed `select` over -/// [`ents_forge::review::Verdict`]'s three variants -- `model.review` -/// makes it a hard enum, unlike issue and comment states. +/// a body. The verdict is a closed `.picker` (README's `VerdictPicker`) of +/// radio inputs over [`ents_forge::review::Verdict`]'s three variants -- +/// `model.review` makes it a hard enum, unlike issue and comment states -- +/// defaulting to `approve`, the same default a bare `select`'s first option +/// would submit. fn start_review_form(session: &Session, oid: &str) -> Markup { html! { + h3 { "Start a review" } form method="post" action=(format!("/commit/{oid}/review")) { (super::csrf_input(session)) - label { - "verdict" - select name="verdict" { - option value="approve" { "approve" } - option value="request-changes" { "request-changes" } - option value="comment" { "comment" } + p.muted { "verdict" } + div.picker { + label.opt { + input type="radio" name="verdict" value="approve" checked; + span.dot {} + "approve" + } + label.opt { + input type="radio" name="verdict" value="request-changes"; + span.dot {} + "request-changes" + } + label.opt { + input type="radio" name="verdict" value="comment"; + span.dot {} + "comment" } } label { "body" textarea name="body" {} } @@ -720,7 +747,8 @@ /// which case the caller shows a truncation notice). Best-effort: a change /// this function cannot read renders as a bare file header with no hunks /// rather than failing the whole page. -fn diff_sections( +fn diff_sections<O>( + state: &AppState<O>, repo: &gix::Repository, old_tree: &gix::Tree<'_>, new_tree: &gix::Tree<'_>, @@ -742,7 +770,7 @@ if is_tree_change(&change) { return Ok(std::ops::ControlFlow::Continue(())); } - let (section, bytes) = render_change(repo, &change); + let (section, bytes) = render_change(state, repo, &change); total = total.saturating_add(bytes); sections.push(section); if total > MAX_DIFF_BYTES { @@ -766,12 +794,18 @@ } /// One changed file's `.diff` section: a `.file`-classed header naming the -/// path (and, on a rename, the old path it moved from), followed by either -/// a `.meta`-classed "binary file changed" notice or the colorized unified -/// diff between its old and new blob content. Returns the section's -/// rendered byte cost, so [`diff_sections`] can track the page's overall -/// budget. -fn render_change(repo: &gix::Repository, change: &Change<'_, '_, '_>) -> (Markup, usize) { +/// path (and, on a rename, the old path it moved from) beside its own +/// [`super::editor_open`] pill -- the web↔editor handoff motif beside every +/// code location (README's `EditorPill` inventory entry names diff headers +/// explicitly) -- followed by either a `.meta`-classed "binary file +/// changed" notice or the colorized unified diff between its old and new +/// blob content. Returns the section's rendered byte cost, so +/// [`diff_sections`] can track the page's overall budget. +fn render_change<O>( + state: &AppState<O>, + repo: &gix::Repository, + change: &Change<'_, '_, '_>, +) -> (Markup, usize) { let (old_id, new_id, path, rename_from) = match *change { Change::Addition { location, id, .. } => ( None, @@ -823,6 +857,8 @@ span.ln.file { @if let Some(from) = &rename_from { (from) " \u{2192} " } (path) + " " + (super::editor_open(state, &path, None)) "\n" } };
crates/cli/ents-web/src/pages/dashboard.rs @@ -23,6 +23,7 @@ use maud::{Markup, html}; use crate::error::Result; +use crate::pages::{scope_class, split_scope}; use crate::state::AppState; /// How many commits the History card shows -- a dashboard lane, not the @@ -66,19 +67,40 @@ ); let attention = attention_card(&state, &open_comments, open_issues.len()); - Ok(super::layout( + // A bespoke `.page-header` rather than [`super::layout`]'s plain + // title-only one: the desk's header carries a subtitle and a + // right-aligned "local · trusted deployment" line beside the title, + // which the shared helper has no slot for. [`super::layout_shell`] is + // the same chrome one layer down, so this still shares every other + // page's `.wb`/`.wb-bar` shell -- only the `.content` wrapper below is + // grown by hand to match. + Ok(super::layout_shell( &repo, &super::identity_label(&state), super::Tab::Overview, "Dashboard", html! { - div.desk { - (working_tree_card(&state, changes.as_deref())) - (attention) - (issues_card(&open_issues)) - } - div.desk-wide { - (history_card(&history_title, &history)) + main.content { + div.page-header { + div { + h1.page-title { "Dashboard" } + p.page-sub { + "Your morning desk \u{2014} " span.mono { "git status" } " for review and ticketing." + } + } + div.desk-status { + span.dot {} + "local \u{b7} trusted deployment" + } + } + div.desk { + (working_tree_card(&state, changes.as_deref())) + (attention) + (issues_card(&open_issues)) + } + div.desk-wide { + (history_card(&history_title, &history)) + } } }, )) @@ -86,26 +108,37 @@ /// The "Working tree" card: every changed file [`worktree_changes`] found, /// each linking into the Files browser with its open-in-editor affordance -/// ([`super::editor_open`]) beside it and its change kind right-aligned. -/// `None` (the status walk itself failed) renders a note row; an empty -/// list renders a "clean" row -- either way the card itself always -/// renders, so the desk's shape is stable. +/// ([`super::editor_open`]) beside it and its change kind chip +/// ([`kind_chip_class`]) right-aligned. The path sits in a +/// `.desk-path` container so a long path ellipsizes from the front +/// (`direction: rtl`) rather than colliding with the editor pill and kind +/// chip -- both of the latter render inside a plain wrapping `span` so +/// `ents.css`'s blanket `.card-row a { flex: 1 }` rule (aimed at the path +/// link) never reaches into them through the descendant selector. `None` +/// (the status walk itself failed) renders a note row; an empty list +/// renders a "clean" row -- either way the card itself always renders, so +/// the desk's shape is stable. fn working_tree_card<O: Find>( state: &AppState<O>, changes: Option<&[(String, &'static str)]>, ) -> Markup { html! { section.card { - div.card-header { "Working tree" } + div.card-header { + "Working tree" + @if let Some(changes) = changes { + span.entry-size { (changes.len()) " changed" } + } + } @match changes { None => { div.card-row.muted { "Working-tree status unavailable." } }, Some([]) => { div.card-row.muted { "Clean \u{2014} no uncommitted changes." } }, Some(changes) => { @for (path, kind) in changes { div.card-row { - a href={ "/files/" (path) } { (path) } - (super::editor_open(state, path, None)) - span.entry-size { (kind) } + a.desk-path href={ "/files/" (path) } { (path) } + span { (super::editor_open(state, path, None)) } + span class={ "chip " (kind_chip_class(kind)) } { (kind) } } } }, @@ -114,6 +147,24 @@ } } +/// The `.chip-*` color class for a working-tree row's change-kind label +/// (README's ChangeKindLabel component): green for a new addition, amber +/// for an ordinary modification, red for a deletion or an unresolved +/// conflict, indigo for a rename, and the neutral gray fallback for +/// anything else ([`change_kind`]'s "untracked" and "type changed" both +/// land here, matching the prototype's own `kindStyle` fallback). `.chip` +/// itself (`ents.css`) supplies the pill's shape; this only picks its +/// color. +fn kind_chip_class(kind: &str) -> &'static str { + match kind { + "added" => "chip-added", + "modified" => "chip-modified", + "deleted" | "conflict" => "chip-deleted", + "renamed" => "chip-renamed", + _ => "chip-untracked", + } +} + /// The "Needs attention" card: every open comment thread, each linking to /// its own page and naming where its anchor lands ([`comment_where`]), /// closed by an open-issues count line when any issues are open. @@ -130,7 +181,10 @@ } @for (id, comment) in open_comments { a.attention-row href={ "/comments/" (id) } { - span.what { "open thread \u{2014} \u{201c}" (what_line(&comment.body)) "\u{201d}" } + span.what { + span.lead { "open thread" } + " \u{2014} \u{201c}" (what_line(&comment.body)) "\u{201d}" + } span class="where" { (comment_where(state, comment)) } } } @@ -153,7 +207,7 @@ section.card { div.card-header { "Issues" - a.btn.btn-ghost href="/issues" { "New" } + a.btn.btn-ghost.btn-sm href="/issues" { "+ New" } } @if open_issues.is_empty() { div.card-row.muted { "No open issues." } @@ -188,7 +242,7 @@ }, None => { span.desk-subject { (row.subject) } }, } - span.entry-size { (row.ago) } + span.desk-when { (row.ago) } } } } @@ -227,28 +281,6 @@ .unwrap_or_else(|| "unanchored".to_owned()) } -/// Split a Scoped-Commits subject (`<scope>: <description>`, -/// scopedcommits.com) into its scope and description -- `None` when the -/// subject carries no `^[a-z-]+:` prefix, in which case the whole subject -/// renders unchipped. -fn split_scope(subject: &str) -> Option<(&str, &str)> { - let (scope, rest) = subject.split_once(':')?; - if scope.is_empty() || !scope.chars().all(|c| c.is_ascii_lowercase() || c == '-') { - return None; - } - Some((scope, rest.trim_start())) -} - -/// The `.scope-c{n}` color class for `scope`: a stable hash of the scope -/// name onto the stylesheet's six `--s-*` syntax-token colors, so the same -/// scope always chips the same color across pages and requests. -fn scope_class(scope: &str) -> String { - let hash = scope.bytes().fold(0u32, |acc, byte| { - acc.wrapping_mul(31).wrapping_add(u32::from(byte)) - }); - format!("scope-c{}", hash.checked_rem(6).unwrap_or(0)) -} - /// Every changed path in the working tree against `HEAD` and the index -- /// `gix`'s own status walk (`gix::Repository::status`), deduplicated by /// path (a file both staged and modified appears in the head-to-index and
crates/cli/ents-web/src/pages/effects.rs @@ -179,13 +179,16 @@ })?; let body = match effect { Ok(effect) => { - let query_status = match effect.trigger.parse::<Query>() { - Ok(_) => "parses".to_owned(), - Err(error) => format!("does not parse: {error}"), + let (label, status_class) = match effect.trigger.parse::<Query>() { + Ok(_) => ("parses".to_owned(), "status-pass"), + Err(error) => (format!("does not parse: {error}"), "status-fail"), }; html! { (crate::render::view(&effect)) - p { "trigger query: " (query_status) } + p { + "trigger query: " + span class={ "status " (status_class) } { (label) } + } } } Err(detail) => crate::render::unreadable(&detail),
crates/cli/ents-web/src/pages/files.rs @@ -246,13 +246,16 @@ } /// The Code split's `.tree` sidebar (`crate::pages::layout_split`): a -/// crumb trail back to the repository root, then the entries of the -/// directory at `dir` (the viewed directory itself, or a viewed blob's -/// parent), directories first -- not a full recursive tree, just enough -/// context to move one level in any direction. `active` names the full -/// path of the entry (or trailing crumb) being viewed. Best-effort: a -/// subtree that fails to read renders an empty entry list rather than -/// failing the page around it. +/// crumb trail back to the repository root (plain labels, no icon -- the +/// same bare style [`crumbs`] renders above the pane), then the entries of +/// the directory at `dir` (the viewed directory itself, or a viewed blob's +/// parent) each as an icon +/// ([`assets::icon_folder`]/[`assets::icon_file`]) beside its name, +/// directories first -- not a full recursive tree, just enough context to +/// move one level in any direction. `active` names the full path of the +/// entry (or trailing crumb) being viewed, and gets `.active` +/// ([`tree_class`]). Best-effort: a subtree that fails to read renders an +/// empty entry list rather than failing the page around it. fn tree_sidebar(head_tree: &gix::Tree<'_>, dir: &str, active: &str) -> Markup { let mut entries = if dir.is_empty() { tree_entries(head_tree).unwrap_or_default() @@ -292,6 +295,7 @@ @let full = if dir.is_empty() { name.clone() } else { format!("{dir}/{name}") }; a class=(tree_class(*is_dir, entry_depth, full == active)) href=(child_href(dir, name)) { + @if *is_dir { (assets::icon_folder()) } @else { (assets::icon_file()) } (name) @if *is_dir { "/" } } } @@ -614,8 +618,9 @@ editor: Markup, } -/// The header bar above every blob view -- the file's name and metadata on -/// the left (`span.blob-title`/`span.blob-meta`), the actions that used to +/// The header bar above every blob view -- a leading file icon +/// ([`assets::icon_file`]), the file's name and metadata +/// (`span.blob-title`/`span.blob-meta`), the actions that used to /// trail [`crumbs`] on the right (`span.blob-actions`): a jump into /// `crate::pages::commits`'s `GET /commits` history (the file browser's /// one entry point into commit history, since history is a view of the @@ -631,6 +636,7 @@ fn blob_header(meta: &BlobHeaderMeta<'_>) -> Markup { html! { div.blob-header { + (assets::icon_file()) span.blob-title { (meta.name) } span.blob-meta { @if let Some(lines) = meta.line_count { @@ -789,18 +795,24 @@ /// The below-the-blob section for comments with no current line range to /// interleave at (a whole-file anchor, or -/// `ents_anchor::Projection::Outdated`) -- titled to distinguish it from -/// the inline cards [`source_view`] interleaves directly into the blob, -/// since every comment reaching here either predates line-level anchoring -/// or has literally gone stale. Renders nothing at all when `comments` is -/// empty (mirrors [`super::comments::comments_section`]'s identical -/// stance). +/// `ents_anchor::Projection::Outdated`) -- a warn-glyphed heading +/// (`div.outdated-head`, [`assets::icon_use`]'s `"i-warn"`) distinguishing +/// it from the inline cards [`source_view`] interleaves directly into the +/// blob, since every comment reaching here either predates line-level +/// anchoring or has literally gone stale, then each comment through the +/// same [`super::comments::comment_card`] every other blob-adjacent comment +/// renders through. Renders nothing at all when `comments` is empty +/// (mirrors [`super::comments::comments_section`]'s identical stance). fn outdated_comments_section(comments: &[(usize, &super::comments::FileComment)]) -> Markup { if comments.is_empty() { return html! {}; } html! { - h2 { "Outdated Comments" } + div.outdated-head { + (assets::icon_use("i-warn")) + span { "Outdated Comments" } + } + p.muted { "Anchored to lines that no longer map onto HEAD." } @for &(index, comment) in comments { (super::comments::comment_card(index, comment, super::comments::LinkMode::SameFile)) }
crates/cli/ents-web/src/pages/issues.rs @@ -50,6 +50,7 @@ .into_iter() .map(|entry| (entry.refname, entry.error)) .collect(); + let labels = known_labels(&rows); Ok(super::layout_split( &super::RepoHeader::from_state(&state), &super::identity_label(&state), @@ -65,29 +66,49 @@ html! { "Open one with the form below." }, )) } - h2 { "Open an Issue" } - (new_form(&session)) + div.card { + div.card-header { "Open an Issue" } + (new_form(&session, &labels)) + } (super::members_datalist(&state)) } }, )) } -/// The Issues split's `.tree` sidebar: every issue as a two-line row -- -/// its title, then a muted locator of its state, assignees, and labels -- -/// linking to its own page, `active` naming the viewed issue's id. +/// The Issues split's `.tree` sidebar: a `.tree-head` naming the family and +/// carrying the "+ New" link into the composer, then every issue as a +/// two-line `.side-row` -- its title (`.side-title`), then a `.side-meta` +/// locator of a state-colored `.dot`, its state, assignees, and labels -- +/// linking to its own page, `.active` naming the viewed issue's id. fn issues_sidebar(rows: &[(String, ents_forge::Issue)], active: Option<&str>) -> Markup { html! { + div.tree-head { + span { "Issues" } + a.btn.btn-sm.btn-ghost[active.is_some()] href="/issues" { "+ New" } + } @if rows.is_empty() { span.tree-note { "No issues yet." } } @for (id, issue) in rows { - a.active[active == Some(id.as_str())] href={ "/issues/" (id) } { - span { (issue.title) } - span class="where" { - (issue.state) - @if !issue.assignees.is_empty() { " \u{b7} " (join_members(&issue.assignees)) } - @if !issue.labels.is_empty() { " \u{b7} " (issue.labels.join(", ")) } + a.side-row.active[active == Some(id.as_str())] href={ "/issues/" (id) } { + span.side-title { (issue.title) } + span.side-meta { + (state_dot(&issue.state)) + span.locator { + (issue.state) + " \u{b7} " + @if issue.assignees.is_empty() { + "unassigned" + } @else { + "@" (issue.assignees[0].as_str()) + @if issue.assignees.len() > 1 { + " +" (issue.assignees.len() - 1) + } + } + " \u{b7} " + @if issue.labels.is_empty() { "no labels" } @else { (issue.labels.join(", ")) } + } } } } @@ -144,6 +165,7 @@ // navigation chrome, never a reason to fail the issue's own page. let (rows, _unreadable) = issue::list_all(state.refs.as_ref(), &*state.objects()).unwrap_or_default(); + let labels = known_labels(&rows); Ok(super::layout_split( &super::RepoHeader::from_state(&state), &super::identity_label(&state), @@ -154,20 +176,47 @@ (super::child_crumbs("issues", "/issues", ents_forge::abbreviate_id(&id))) div.readable { div.card { - dl { - dt { "state" } dd { span.comment-state { (issue.state) } } - dt { "assignees" } dd { (join_members(&issue.assignees)) } - dt { "labels" } dd { (issue.labels.join(", ")) } + h1.commit-subject { (issue.title) } + dl.entity-view { + dt { "state" } + dd { (state_chip(&issue.state)) } + dt { "assignees" } + dd { + @if issue.assignees.is_empty() { + span { "unassigned" } + } @else { + @for assignee in &issue.assignees { + (super::avatar(assignee.as_str())) " @" (assignee.as_str()) " " + } + } + } + dt { "labels" } + dd { + @if issue.labels.is_empty() { + span { "none" } + } @else { + @for label in &issue.labels { + span.label-chip { (label) } " " + } + } + } } div.doc-body { (body) } } details { - summary { "Edit" } - (edit_form(&session, &issue)) + summary { "Edit state, assignees, labels" } + (edit_form(&session, &issue, &labels)) (super::members_datalist(&state)) } h2 { "Discussion" } - (crate::pages::comments::thread_section(&state, &session, &thread, &return_to)) + @if thread.is_empty() { + (super::blankslate( + "No comments yet", + html! { "Start the discussion below." }, + )) + } @else { + (crate::pages::comments::thread_section(&state, &session, &thread, &return_to)) + } h2 { "Add a Comment" } (comment_form(&session, &id)) } @@ -350,65 +399,57 @@ Ok(Redirect::to(&format!("/issues/{id}"))) } -/// The open-an-issue form (`POST /issues`). The `state` field is a free -/// text input with a [`state_datalist`] of the conventional values, never -/// a closed `select` -- `model.issue` keeps states an open vocabulary -/// ("custom states are schema, not platform features"; see -/// [`ents_forge::Issue`]'s own doc). -fn new_form(session: &Session) -> Markup { +/// The open-an-issue form (`POST /issues`). State picks from +/// [`state_picker`]'s closed three-option enumeration (the redesign's +/// `StatePicker`/`StateChip`, open / in-progress / closed) rather than the +/// pre-redesign free-text-plus-datalist field: `model.issue` itself still +/// stores state as an arbitrary string (`ents_forge::Issue`'s own doc, +/// "custom states are schema, not a platform feature"), so this form +/// narrowing its own three quick-pick buttons never closes that schema -- +/// a state outside the trio stays reachable through `git ents issue edit` +/// or a direct edit, same as any other schema-level custom field. +fn new_form(session: &Session, known_labels: &[String]) -> Markup { html! { form method="post" action="/issues" { (super::csrf_input(session)) label { "title" input type="text" name="title"; } - label { - "state" - input type="text" name="state" value="open" list="issue-states"; + div { + label { "state" } + (state_picker("open")) } - (state_datalist()) label { "assignees" input type="text" name="assignees" placeholder="alice, bob" list="members"; } - label { "labels" input type="text" name="labels" placeholder="bug, gate"; } + (label_picker(known_labels, &[])) label { "body" textarea name="body" {} } - button type="submit" { "Open Issue" } + div.composer-buttons { + a.composer-cancel href="/issues" { "Cancel" } + button type="submit" { "Open Issue" } + } } } } /// The edit-issue form (`POST /issues/{id}`), its fields pre-filled from -/// the current issue. Its `state` field carries the same [`state_datalist`] -/// as [`new_form`]'s, for the same open-vocabulary reason. -fn edit_form(session: &Session, issue: &ents_forge::Issue) -> Markup { +/// the current issue. Its `state` field carries the same +/// [`state_picker`] as [`new_form`]'s, for the same reason (see +/// [`new_form`]'s own doc). +fn edit_form(session: &Session, issue: &ents_forge::Issue, known_labels: &[String]) -> Markup { html! { form method="post" action="" { (super::csrf_input(session)) - label { - "state" - input type="text" name="state" value=(issue.state) list="issue-states"; + div { + label { "state" } + (state_picker(&issue.state)) } - (state_datalist()) label { "assignees" input type="text" name="assignees" value=(join_members(&issue.assignees)) list="members"; } - label { "labels" input type="text" name="labels" value=(issue.labels.join(", ")); } + (label_picker(known_labels, &issue.labels)) button type="submit" { "Save" } } } } -/// The `datalist` of conventional issue states both forms above attach to -/// their `state` input -- suggestions only, since `model.issue`'s state is -/// an open string vocabulary, not an enum a `select` could close over. -/// Rendered once per form; the two forms never share a page, so the id -/// never collides. -fn state_datalist() -> Markup { - html! { - datalist id="issue-states" { - option value="open" {} - option value="closed" {} - } - } -} - /// The comment-on-this-issue form (`POST /issues/{id}/comment`). fn comment_form(session: &Session, id: &str) -> Markup { html! { @@ -420,6 +461,162 @@ } } +/// The three conventional issue states the redesign's `StatePicker` closes +/// over (`model.issue`'s own field stays an open string; see [`new_form`]'s +/// doc for why the form narrows to these three anyway). +const ISSUE_STATES: [&str; 3] = ["open", "in-progress", "closed"]; + +/// The `.chip`/`.dot` color class for a known state, or `None` for a +/// custom one this form's [`state_picker`] does not enumerate -- shared by +/// [`state_chip`] (the detail card's big pill) and [`state_dot`] (the +/// sidebar row's small status dot), so a state's color is spelled in +/// exactly one place. +fn state_class(state: &str) -> Option<&'static str> { + match state { + "open" => Some("state-open"), + "in-progress" => Some("state-in-progress"), + "closed" => Some("state-closed"), + _ => None, + } +} + +/// The issue detail card's state `dd`: a `.chip.chip-pill` carrying a +/// leading `.dot` and the state's own color (see [`state_class`]), plain +/// neutral for a custom state outside the three [`ISSUE_STATES`]. +fn state_chip(state: &str) -> Markup { + let class = match state_class(state) { + Some(extra) => format!("chip chip-pill {extra}"), + None => "chip chip-pill".to_owned(), + }; + html! { + span class=(class) { + span.dot {} + (state) + } + } +} + +/// The sidebar row's status `.dot` (see [`issues_sidebar`]): green open, +/// amber in-progress, grey closed or custom (see [`state_class`]). +fn state_dot(state: &str) -> Markup { + let class = match state_class(state) { + Some(extra) => format!("dot {extra}"), + None => "dot".to_owned(), + }; + html! { span class=(class) {} } +} + +/// One [`state_picker`] option's class: `.opt`, plus `.active` and the +/// state's own color class (see [`state_class`]) when it is `current`'s +/// own value -- an inactive option stays the picker's plain neutral look +/// (mirrors the design handoff's own `statePicker`, which colors only the +/// selected option). +fn picker_opt_class(current: &str, state: &str) -> String { + if current == state { + match state_class(state) { + Some(extra) => format!("opt active {extra}"), + None => "opt active".to_owned(), + } + } else { + "opt".to_owned() + } +} + +/// The state `.picker` both [`new_form`] and [`edit_form`] render: three +/// `name="state"` radios, one per [`ISSUE_STATES`] entry, styled as +/// `.picker .opt` pills (a `hidden` native radio inside a `<label>` still +/// toggles on click -- label-click activation reaches a `hidden` control +/// same as any other -- so the pill shows no native radio glyph without +/// needing any stylesheet change). Exactly one radio is always checked, so +/// the field is never posted empty: `current` itself when it names one of +/// the three, or a fourth unlabeled hidden radio carrying `current` +/// verbatim when it does not (a custom state this form's picker does not +/// enumerate stays intact until the reader deliberately picks a different +/// one). +fn state_picker(current: &str) -> Markup { + html! { + div.picker { + @for state in ISSUE_STATES { + label class=(picker_opt_class(current, state)) { + input type="radio" name="state" value=(state) checked[state == current] hidden; + span.dot {} + (state) + } + } + @if !ISSUE_STATES.contains(&current) { + input type="radio" name="state" value=(current) checked hidden; + } + } + } +} + +/// Every label already used across every issue in this repository, deduped +/// and sorted -- [`label_picker`]'s "pick existing" set, derived from the +/// same `issue::list_all` read [`issues_sidebar`] renders from rather than +/// a second query of its own. +fn known_labels(rows: &[(String, ents_forge::Issue)]) -> Vec<String> { + let mut labels: Vec<String> = Vec::new(); + for (_, issue) in rows { + for label in &issue.labels { + if !labels.contains(label) { + labels.push(label.clone()); + } + } + } + labels.sort(); + labels +} + +/// The labels field both [`new_form`] and [`edit_form`] render: `known`'s +/// labels previewed as `.label-chip` (`.on` for one already in `current`), +/// then the actual control -- a single free-text `input[name=labels]` +/// pre-filled from `current` and completed by a [`label_datalist`] of +/// `known`'s own names. `model.issue`'s labels stay this one +/// comma/whitespace-separated string field end to end (`EditForm`/ +/// `NewForm`'s own `labels: String`, `parse_labels`), so unlike +/// [`state_picker`]'s single-choice radios, real independently-toggleable +/// checkboxes are not this control's shape here: a checkbox group needs +/// either a repeating-key field (breaking the scalar `labels` the create/ +/// edit handlers and `tests/router.rs`'s own `seed_issue` already commit +/// to) or client-side script to merge checkbox state into one text value +/// (excluded -- this crate works with no JS at all). The datalist gives +/// "pick existing" a real, working affordance; typing any other word is +/// "type-and-create". +fn label_picker(known: &[String], current: &[String]) -> Markup { + html! { + div { + label { "labels" } + @if !known.is_empty() { + div.picker { + @for label in known { + @let on = current.iter().any(|applied| applied == label); + span class={ "label-chip" (if on { " on" } else { "" }) } { (label) } + } + } + } + input + type="text" + name="labels" + value=(current.join(", ")) + placeholder="bug, gate" + list="issue-labels"; + (label_datalist(known)) + } + } +} + +/// The `datalist` of every already-used label [`label_picker`]'s free-text +/// input completes from. Rendered once per form; the two forms never +/// share a page, so the id never collides (same reasoning the +/// pre-redesign `state_datalist` carried). +fn label_datalist(known: &[String]) -> Markup { + html! { + datalist id="issue-labels" { + @for label in known { option value=(label) {} } + } + } +} + /// Render a member set for display, comma-joined (`join_members(&[])` is the /// empty string, so an unassigned issue shows a blank cell rather than a /// stray separator).
crates/cli/ents-web/src/pages/login.rs @@ -88,8 +88,8 @@ "Prove control of an enrolled member key. Run this " "on your own machine -- the key never leaves it:" } - pre.signin-cmd { - "git ents login https://" (realm.host) " " (display) + pre.login-code { + "git ents login https://" span.code { (realm.host) " " (display) } } p.muted { "This page refreshes on its own; the code is "
crates/cli/ents-web/src/pages/members.rs @@ -95,19 +95,21 @@ )) } -/// One member's identity card: the username prominent (a link on the list -/// page, plain on the member's own page), the key type as a badge, the -/// state and provenance as muted badges, and the key material truncated -/// through the middle ([`truncate_middle`]) with the full key line behind -/// a `<details>` toggle -- no digest dependency, so no fingerprint; the -/// truncated material plus the expandable full line is the identity a -/// reader compares. Also `crate::pages::account`'s signed-in-as card, so -/// "you" and "a member" render identically. +/// One member's identity card: [`super::avatar`] beside the username +/// prominent (a link on the list page, plain on the member's own page), +/// the key type as a badge, the state and provenance as muted badges, and +/// the key material truncated through the middle ([`truncate_middle`]) +/// with the full key line behind a `<details>` toggle -- no digest +/// dependency, so no fingerprint; the truncated material plus the +/// expandable full line is the identity a reader compares. Also +/// `crate::pages::account`'s signed-in-as card, so "you" and "a member" +/// render identically. pub(crate) fn member_card(username: &str, member: &Member, link: bool) -> Markup { let (key_type, material) = split_key(&member.key); html! { div.card.member-card { div.member-head { + (super::avatar(username)) @if link { a.member-name href={ "/members/" (username) } { (username) } } @else {
crates/cli/ents-web/src/pages/meta.rs @@ -1,8 +1,10 @@ //! `GET /meta`: the landing page for the `meta` tab -- a card listing //! every page family in `super::META_SECTIONS` with its blurb, so the //! tab resolves to something other than an arbitrary pick of its five -//! children. The rail those children render beside their own content -//! (`super::layout_meta`) is this same table; this page is its index. +//! children. Rendered through [`super::layout_meta`], the same governance +//! sub-rail every one of those five families renders beside its own +//! content -- this page is that rail's own index, not a sixth thing beside +//! it. use std::sync::Arc; @@ -17,14 +19,18 @@ where O: Find + Write + Send + 'static, { - super::layout( + super::layout_meta( &super::RepoHeader::from_state(&state), &super::identity_label(&state), - super::Tab::Meta, + "/meta", "Meta", html! { + p.muted { + "All project metadata -- members, effects, toolchains, " + "redactions, and the adoption inbox -- lives in this " + "repository as git objects." + } div.card { - div.card-header { "meta" } @for section in super::META_SECTIONS { div.card-row { a href=(section.href) { (section.name) }
crates/cli/ents-web/src/pages/mod.rs @@ -291,7 +291,7 @@ (crate::assets::sprite()) div.wb { aside.rail { - span.nav-mark { "✳" } + span.nav-mark { "ge" } (rail_link(active, Tab::Overview, "/", "Dashboard", "i-home")) (rail_link(active, Tab::Files, "/files", "Code", "i-files")) (rail_link(active, Tab::Commits, "/commits", "Review", "i-commit")) @@ -306,7 +306,7 @@ span.repo-path { span.here { (repo.name) } @if let Some(branch) = &repo.branch { - span.branch { (crate::assets::icon_use("i-commit")) (branch) } + span.branch { (crate::assets::icon_use("i-branch")) (branch) } } } form.palette method="get" action="/search" { @@ -314,7 +314,7 @@ input type="search" name="q" placeholder="Jump to file, commit, issue, member…" aria-label="Search"; kbd { "⌘K" } } - a.id-chip href="/account" { (identity) } + a.id-chip href="/account" { (avatar(identity)) span { (identity) } } } (content) } @@ -406,13 +406,19 @@ }; let root = std::fs::canonicalize(&state.path).unwrap_or_else(|_io| state.path.clone()); let abs = root.join(path); + let name = path.rsplit('/').next().unwrap_or(path); + let loc = match line { + Some(line) => format!("{name}:{line}"), + None => name.to_owned(), + }; html! { a.editor-open href=(editor.deep_link(&abs, line)) data-editor-base=(editor.deep_link(&abs, None)) title={ "Open in " (editor.label()) } { - (crate::assets::icon_use(editor.icon())) + (crate::assets::icon_use("i-editor")) + span.ed-loc { (loc) } } } } @@ -426,6 +432,19 @@ state.identity.label() } +/// The design's initials avatar (`.avatar`): the first two characters of +/// `label` on the shared indigo→teal gradient, the same mark the top bar's +/// `.id-chip`, every comment card's author line, and an issue's assignee +/// list all render beside a name (README: initials on a gradient, no image +/// assets). Two characters because that is what the mock's own avatars show +/// ("ada.lang" → "ad"); a shorter label renders however many it has. +pub(crate) fn avatar(label: &str) -> Markup { + let initials: String = label.chars().take(2).collect(); + html! { + span.avatar { (initials) } + } +} + /// A hidden CSRF input every form this crate renders carries /// (`roots.web-session`): the one place that field is spelled, so a form /// can never omit it by a typo. @@ -579,3 +598,29 @@ pub(crate) fn short_oid(oid: &ObjectId) -> String { gix_hash::Prefix::new(oid, 7).map_or_else(|_| oid.to_string(), |prefix| prefix.to_string()) } + +/// Split a Scoped-Commits subject (`<scope>: <description>`, +/// scopedcommits.com) into its scope and description -- `None` when the +/// subject carries no `^[a-z-]+:` prefix, in which case the whole subject +/// renders unchipped. Shared by [`super::dashboard`]'s history strip and +/// [`super::commits`]'s commit rows, the two places a commit subject chips +/// its scope, so both split it the same way. +pub(crate) fn split_scope(subject: &str) -> Option<(&str, &str)> { + let (scope, rest) = subject.split_once(':')?; + if scope.is_empty() || !scope.chars().all(|c| c.is_ascii_lowercase() || c == '-') { + return None; + } + Some((scope, rest.trim_start())) +} + +/// The `.scope-c{n}` color class for `scope`: a stable hash of the scope +/// name onto the stylesheet's six deterministic chip colors (README's +/// "deterministic-color, fixed 52px" [`ScopeChip`]), so the same scope +/// always chips the same color across pages and requests. Shared with +/// [`split_scope`] by every page that renders a commit subject. +pub(crate) fn scope_class(scope: &str) -> String { + let hash = scope.bytes().fold(0u32, |acc, byte| { + acc.wrapping_mul(31).wrapping_add(u32::from(byte)) + }); + format!("scope-c{}", hash.checked_rem(6).unwrap_or(0)) +}
crates/cli/ents-web/src/pages/toolchains.rs @@ -60,17 +60,7 @@ html! { "Import one with " code { "git ents toolchain import" } "." }, ) } else { - html! { - div.card { - ul.string-list { - @for name in &names { - li { - a href=(format!("/toolchains/{name}")) { (name) } - } - } - } - } - } + crate::render::string_list(&names, |name| format!("/toolchains/{name}")) }; Ok(super::layout_meta( &super::RepoHeader::from_state(&state), @@ -192,14 +182,35 @@ Ok((toolchain, recipe)) => { let log = toolchain::log(state.refs.as_ref(), &*objects, &name).unwrap_or_default(); html! { - dl { - dt { "name" } dd { (toolchain.name) } - dt { "recipe" } dd { (format!("{recipe:?}")) } + div.card { + dl.entity-view { + dt { "name" } dd { (toolchain.name) } + dt { "recipe" } dd { (format!("{recipe:?}")) } + } } h2 { "Import Log" } - ul { - @for oid in &log { - li { (oid.to_string()) } + @if log.is_empty() { + (super::blankslate( + "No import log", + html! { "This toolchain has no recorded import history." }, + )) + } @else { + div.card { + ul.string-list { + @for oid in &log { + li { + code { (super::short_oid(oid)) } + // Best-effort per entry: an unreadable + // commit in the chain (practically + // unreachable -- `toolchain::log` itself + // already errors on one) drops just its + // own authorship, not the whole log. + @if let Ok((author, seconds)) = super::commit_authorship(&*objects, *oid) { + span.muted { " · " (author) " · " (super::ago(seconds)) } + } + } + } + } } } }
crates/cli/ents-web/src/assets/fonts/LICENSE @@ -1,0 +1,93 @@ +Copyright © 2017 IBM Corp. with Reserved Font Name "Plex" + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE.
crates/cli/ents-web/src/assets/fonts/plex-mono-400.woff2 Binary file changed.
crates/cli/ents-web/src/assets/fonts/plex-mono-500.woff2 Binary file changed.
crates/cli/ents-web/src/assets/fonts/plex-mono-600.woff2 Binary file changed.
crates/cli/ents-web/src/assets/fonts/plex-sans-400.woff2 Binary file changed.
crates/cli/ents-web/src/assets/fonts/plex-sans-500.woff2 Binary file changed.
crates/cli/ents-web/src/assets/fonts/plex-sans-600.woff2 Binary file changed.
crates/cli/ents-web/src/assets/fonts/plex-sans-700.woff2 Binary file changed.