├── resources ├── repley.css └── public │ └── repley.css ├── tailwind-build.sh ├── tailwind-watch.sh ├── dev-src └── user.clj ├── .gitignore ├── .github └── workflows │ └── test.yml ├── tailwind.config.js ├── package.json ├── src └── repley │ ├── shadow │ ├── preload.cljs │ ├── build.clj │ └── remote.cljs │ ├── ui │ ├── tabs.clj │ ├── edn.clj │ ├── table.clj │ ├── icon.clj │ └── chart.clj │ ├── protocols.clj │ ├── config.cljc │ ├── browser.clj │ ├── visualizer │ ├── vega.clj │ └── file.clj │ ├── repl.clj │ ├── visualizers.clj │ └── main.clj ├── deps.edn ├── LICENSE ├── README.md └── test └── repley └── browser_test.clj /resources/repley.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | -------------------------------------------------------------------------------- /tailwind-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx tailwind -i resources/repley.css -o resources/public/repley.css -m 3 | -------------------------------------------------------------------------------- /tailwind-watch.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | npx tailwind -i resources/repley.css -o resources/public/repley.css -w 3 | -------------------------------------------------------------------------------- /dev-src/user.clj: -------------------------------------------------------------------------------- 1 | (ns user) 2 | 3 | (defn start [] 4 | ((requiring-resolve 'repley.main/start))) 5 | 6 | (start) 7 | (println "REPLey started, open browser to: http://localhost:3001/") 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | pom.xml 2 | pom.xml.asc 3 | *.jar 4 | *.class 5 | /lib/ 6 | /classes/ 7 | /target/ 8 | /checkouts/ 9 | .lein-deps-sum 10 | .lein-repl-history 11 | .lein-plugins/ 12 | .lein-failures 13 | .nrepl-port 14 | .cpcache/ 15 | /node_modules/ 16 | /.lsp/ 17 | /.clj-kondo/ 18 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | on: [push] 3 | jobs: 4 | build: 5 | runs-on: ubuntu-latest 6 | steps: 7 | - uses: actions/checkout@v2 8 | - name: setup clojure 9 | uses: DeLaGuardo/setup-clojure@master 10 | with: 11 | tools-deps: '1.10.1.763' 12 | - name: run REPLey unit tests 13 | run: clojure -M:dev:test 14 | -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | content: { 3 | enabled: true, 4 | content: ['./src/**/*.clj'], 5 | }, 6 | theme: { 7 | extend: { 8 | colors: { 9 | primary: "#146a8e" 10 | }, 11 | }, 12 | }, 13 | variants: { 14 | extend: {}, 15 | }, 16 | plugins: [require("daisyui")], 17 | }; 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "postcss-cli": "^10.1.0", 4 | "tailwindcss": "^3.3.2" 5 | }, 6 | "scripts": { 7 | "tailwind": "tailwindcss -i resources/repley.css -o resources/public/repley.css --watch", 8 | "tailwindprod": "tailwindcss -i resources/repley.css -o resources/public/repley.css -m" 9 | }, 10 | "devDependencies": { 11 | "daisyui": "^4.12.10" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/repley/shadow/preload.cljs: -------------------------------------------------------------------------------- 1 | (ns repley.shadow.preload 2 | "May be added to `shadow-cljs.edn` `:preloads` to add tap on start." 3 | (:require 4 | [repley.shadow.remote :as r] 5 | goog.object)) 6 | 7 | (def build-opts 8 | (-> (goog.object/get js/CLOSURE_DEFINES "repley.shadow.build.opts") 9 | (js->clj :keywordize-keys true) 10 | r/config->fetch-opts)) 11 | 12 | (add-tap (partial r/send (merge build-opts {:mode "no-cors"}))) 13 | -------------------------------------------------------------------------------- /src/repley/shadow/build.clj: -------------------------------------------------------------------------------- 1 | (ns repley.shadow.build 2 | (:require [repley.main :as r])) 3 | 4 | (defn start-and-listen 5 | "Starts the REPLey server and adds tap. 6 | 7 | May be added to `shadow-cljs.edn`: 8 | ```clojure 9 | {:builds 10 | {:build 11 | {:build-hooks [(repley.shadow.build/start-and-listen opts)]}}} 12 | ``` 13 | `opts` are passed to `repley.main/start`. 14 | " 15 | {:shadow.build/stage :compile-prepare} 16 | ([build-state] 17 | (start-and-listen build-state nil)) 18 | ([build-state options] 19 | (if (not= (:shadow.build/mode build-state) :dev) 20 | build-state 21 | (do (if options (r/start options) (r/start)) 22 | (r/listen-to-tap>) 23 | (assoc-in build-state [:compiler-options :closure-defines `opts] options))))) 24 | -------------------------------------------------------------------------------- /deps.edn: -------------------------------------------------------------------------------- 1 | {:paths ["src" "resources"] 2 | :deps {tatut/ripley {:git/url "https://github.com/tatut/ripley.git" 3 | :sha "fbebe426f1353217b5db2e2bc04f05a2281a5a59"} 4 | http-kit/http-kit {:mvn/version "2.6.0"} 5 | compliment {:mvn/version "0.4.0"}} 6 | :aliases {:dev {:extra-paths ["dev-src" "test"] 7 | :extra-deps {org.clojure/data.csv {:mvn/version "1.0.1"} 8 | io.github.pfeodrippe/wally {:mvn/version "0.0.4"}}} 9 | :test {:extra-deps {com.cognitect/test-runner 10 | {:git/url "https://github.com/cognitect-labs/test-runner.git" 11 | :sha "b6b3193fcc42659d7e46ecd1884a228993441182"}} 12 | :main-opts ["-m" "cognitect.test-runner"]}}} 13 | -------------------------------------------------------------------------------- /src/repley/ui/tabs.clj: -------------------------------------------------------------------------------- 1 | (ns xtdb-inspector.ui.tabs 2 | "Tabbed panel" 3 | (:require [ripley.html :as h] 4 | [ripley.live.source :as source])) 5 | 6 | (defn- tab-button [label select! selected?] 7 | (let [cls (source/computed 8 | #(str "tab tab-bordered" 9 | (when % 10 | " tab-active")) 11 | selected?)] 12 | (h/html 13 | [:a {:on-click select! 14 | :class [::h/live cls]} 15 | label]))) 16 | 17 | (defn tabs [& tabs] 18 | (let [[selected-idx set-selected-idx!] (source/use-state 0) 19 | tabs (remove nil? tabs) 20 | tab-count (count tabs)] 21 | (h/html 22 | [:div 23 | [:div.tabs 24 | (doseq [i (range tab-count) 25 | :let [{:keys [label]} (nth tabs i)]] 26 | (tab-button label 27 | #(set-selected-idx! i) 28 | (source/computed #(= i %) selected-idx)))] 29 | [::h/live selected-idx 30 | #(h/html 31 | [:div.tab-content 32 | ((:render (nth tabs %)))])]]))) 33 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Tatu Tarvainen 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/repley/shadow/remote.cljs: -------------------------------------------------------------------------------- 1 | (ns repley.shadow.remote 2 | (:require [repley.config :as config])) 3 | 4 | (defn config->fetch-opts [{:keys [http] :as config}] 5 | (cond-> config 6 | (contains? http :port) (assoc :port (:port http)) 7 | true (select-keys [:port :prefix :receive-endpoint]))) 8 | 9 | (comment 10 | (config->fetch-opts {:http {:port 1} :receive-endpoint nil :prefix "/foo"})) 11 | 12 | (def default-fetch-opts 13 | (-> config/default-config 14 | config->fetch-opts 15 | (assoc :mode "cors"))) 16 | 17 | (defn make-send [fetch] 18 | (fn submit 19 | ([value] (submit nil value)) 20 | ([opts value] 21 | (let [{:keys [port mode prefix receive-endpoint]} (merge default-fetch-opts opts)] 22 | (when (some? receive-endpoint) ;; nil disables the endpoint 23 | (fetch (str "http://localhost:" port prefix receive-endpoint) 24 | {:method "POST" 25 | :mode mode 26 | :headers {"content-type" "application/edn"} 27 | :body (binding [*print-meta* true] 28 | (pr-str value))})))))) 29 | 30 | (defn- fetch [url options] 31 | (js/fetch url (clj->js options))) 32 | 33 | (def send (make-send fetch)) 34 | 35 | 36 | (comment 37 | (send "Hello World") 38 | (send {:port 3002 :mode "no-cors"} "hello world") 39 | (tap> {:runtime :cljs :value "hello web"}) 40 | (add-tap send)) 41 | -------------------------------------------------------------------------------- /src/repley/protocols.clj: -------------------------------------------------------------------------------- 1 | (ns repley.protocols) 2 | 3 | (defprotocol Visualizer 4 | (label [this] "The label to use in the UI for this visualizer") 5 | (supports? [this data] 6 | "Check if the given data can be visualizer by this visualizer. 7 | Should not block or do any costly calculations as this is used when 8 | rendering the user interface.") 9 | (precedence [this] "Return the precedence score of this visualizer. Bigger 10 | number takes precedence over lower and default. Return 0 for a generic 11 | data visualizer and 100 for a custom visualizer of a type that should be 12 | shown by default.") 13 | (render [this data] 14 | "Render a Ripley component that visualizes the data.") 15 | (ring-handler [this] 16 | "Optional ring handler if the visualizer exposes HTTP endpoints") 17 | (assets [this] 18 | "Return optional map of frontend assets to include in the page when 19 | this visualizer is enabled. This makes it possible to integrate JS based 20 | visualizations. Must return either nil or a map containing keys: 21 | :js a sequence of JavaScript script source URLs 22 | :css a sequence of CSS sources")) 23 | 24 | (defprotocol DefaultVisualizer 25 | "Protocol for objects that provide their own default visualizer. 26 | For example if a component can render itself as a Ripley UI." 27 | (default-visualizer [this] 28 | "Return instance of Visualizer to use by default for this object.") 29 | (object [this] 30 | "Get the backing object, for other visualizers.")) 31 | -------------------------------------------------------------------------------- /src/repley/config.cljc: -------------------------------------------------------------------------------- 1 | (ns repley.config 2 | "REPLey configuration data. 3 | Contains the full example and default configuration.") 4 | 5 | (def default-config 6 | {;; Options to give http-kit 7 | :http {:port 3001} 8 | 9 | ;; URI prefix for ring handler, use this to serve REPLey from under 10 | ;; a different root path. 11 | :prefix "" 12 | 13 | ;; Receive endpoint, use nil to disable 14 | ;; The receive endpoint can take POSTed EDN values and add them 15 | ;; as results. This makes it possible to integrate REPLey to 16 | ;; ClojureScript by sending tap> values over HTTP. 17 | :receive-endpoint "/receive" 18 | 19 | ;; Timestamp format, if non-nil, a timestamp is shown for each result. 20 | :timestamp-format "yyyy-MM-dd HH:mm:ss.SSS" 21 | 22 | ;; Navbar component function, if any (must be a ripley component fn) 23 | :navbar nil 24 | 25 | ;; Configuration for visualizers 26 | :visualizers 27 | {:edn-visualizer {:enabled? true} 28 | :table-visualizer {:enabled? true} 29 | :file-visualizer {:enabled? true 30 | :allow-download? true} 31 | :throwable-visualizer {:enabled? true} 32 | :chart-visualizer {:enabled? true} 33 | :vega-visualizer {:enabled? true}}}) 34 | 35 | (defn config 36 | "Get the full configuration. Deeply merges given 37 | opts to the default configuration." 38 | [opts] 39 | (merge-with (fn merge-strategy [a b] 40 | (cond 41 | (and (map? a) (map? b)) 42 | (merge-with merge-strategy a b) 43 | 44 | (some? b) 45 | b 46 | 47 | :else 48 | a)) default-config opts)) 49 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # REPLey 2 | 3 | ![test workflow](https://github.com/tatut/repley/actions/workflows/test.yml/badge.svg) 4 | 5 | A web REPL made with [Ripley](https://github.com/tatut/ripley) 6 | 7 | Some features: 8 | - Nice and extensible visualization of data (including tables and graphs) 9 | - Clojure input with autocompletion (using compliment) 10 | - `tap>` listener (to use as a fancy logging mechanism) 11 | - easy to integrate into an existing app 12 | 13 | ## Usage 14 | 15 | Start with ```% clj -M:dev``` and open browser to localhost 16 | port 3001. Use Cmd-Enter to evaluate text in the editor. 17 | 18 | Another way to use is to include this as a library and use `repley.main/repley-handler` as a handler 19 | in a ring application. This code has **NO** authentication or sand boxing, so obviously don't use 20 | this in a production app. 21 | 22 | ## Demos 23 | 24 | Watch the [YouTube intro video](https://youtu.be/UiRq97HZctQ). 25 | 26 | ### Inspecting properties map as a table 27 | 28 | Clojure maps have a table visualization for convenient inspection. 29 | ![Props map as table](https://github.com/tatut/REPLey/assets/83725/6bdd7db7-6c30-4680-8bbf-e0ea9489eef2) 30 | 31 | 32 | ### File visualizer 33 | 34 | Instances of `java.io.File` class have a visualizer that shows basic info and allows 35 | downloading the file. This can be disabled in config. 36 | 37 | ![File visualizer](https://github.com/tatut/REPLey/assets/83725/135efabf-daf7-4ba6-9706-8b053bafea91) 38 | 39 | ### CSV support 40 | 41 | CSV read by `clojure.data.csv` is also supported in the table visualizer. 42 | 43 | ![CSV table](https://github.com/tatut/REPLey/assets/83725/84df15cc-bc93-4cac-89ea-4229eecffbc8) 44 | 45 | 46 | # Changes 47 | 48 | ### 2024-09-13 49 | - Add namespace browser feature 50 | - EDN visualizer also shows metadata, if any 51 | 52 | ### 2024-06-27 53 | - Add sub-resource integrity to external sources (Vega visualizer) 54 | 55 | ### 2024-05-08 56 | - Better navigating of Throwable cause and ex-data 57 | - Fix side-effects in `swap!` call causing possible multipe results 58 | 59 | ### 2024-03-01 60 | - Add `:navbar` component support, which must be ripley component fn 61 | 62 | ### 2024-02-12 63 | - Support objects that render themselves (DefaultVisualizer protocol) 64 | -------------------------------------------------------------------------------- /src/repley/browser.clj: -------------------------------------------------------------------------------- 1 | (ns repley.browser 2 | "Namespace browser UI" 3 | (:require [ripley.html :as h] 4 | [ripley.live.source :as source] 5 | [ripley.js :as js] 6 | [clojure.string :as str] 7 | [ripley.live.collection :as collection] 8 | [repley.ui.icon :as icon] 9 | [repley.repl :as repl] 10 | )) 11 | 12 | (defn- ns-component [ns] 13 | (let [n (ns-name ns) 14 | [open? _ update-open!] (source/use-state false) 15 | toggle-open! #(update-open! not)] 16 | (h/html 17 | [:tr 18 | [:td.align-top 19 | [:button.btn.btn-xs 20 | {:onclick toggle-open!} 21 | [::h/live open? #(if % 22 | (icon/caret-down) 23 | (icon/caret-right))]]] 24 | [:td n 25 | 26 | [::h/live (source/computed 27 | #(when % (vals (ns-publics ns))) 28 | open?) 29 | (fn [children] 30 | (h/html 31 | [:ul 32 | [::h/for [c children 33 | :let [var-ref (str c) 34 | val @c 35 | type (cond 36 | (fn? val) "fn" 37 | :else "v") 38 | doc (some-> c meta :doc str/split-lines first) 39 | [_ name] (str/split var-ref #"/")]] 40 | [:li 41 | [::h/when type 42 | [:div.badge.badge-neutral.badge-xs type]] 43 | " " 44 | [:span.cursor-pointer 45 | {:onclick #(repl/eval-input! var-ref)} 46 | [::h/if doc 47 | [:div.tooltip {:data-tip doc} name] 48 | name]] 49 | [::h/when (fn? val) 50 | ;; btn to copy to input 51 | [::button.btn.btn-xs 52 | {:onclick (str "_E.session.setValue('(" (subs var-ref 2) " )');" 53 | "_E.moveCursorTo(0," (count var-ref) ");" 54 | "_E.focus();")} 55 | (icon/pencil-2)]]]]]))]]]))) 56 | 57 | (defn browser [] 58 | (let [[state _ update-state!] (source/use-state {:filter "" 59 | :open #{}}) 60 | set-filter! #(update-state! assoc :filter %)] 61 | (h/html 62 | [:div.browser.border-solid.border-2.p-2 {:style "width: 40vw;"} 63 | [:input#ns-filter.input.input-bordered.w-full 64 | {:placeholder "Filter namespaces..." 65 | :oninput (js/js-debounced 300 set-filter! (js/input-value :ns-filter))}] 66 | [:div {:style "max-height: 70vh; overflow-y: auto;"} 67 | "Showing " 68 | [::h/live-let [f (source/computed :filter state)] 69 | [:span 70 | [::h/if (str/blank? f) 71 | [:span "all namespaces"] 72 | [:span "namespaces matching: " f]]]] 73 | 74 | [:table.table.table-xs 75 | [:thread [:tr 76 | [:td {:style "width: 30px;"}] 77 | [:td "Namespace"]]] 78 | (collection/live-collection 79 | {:key ns-name 80 | :container-element :tbody 81 | :source (source/computed (fn [{filter-str :filter}] 82 | (for [ns (all-ns) 83 | :let [n (ns-name ns)] 84 | :when (str/includes? n filter-str)] 85 | ns)) 86 | state) 87 | :render ns-component})]]]))) 88 | -------------------------------------------------------------------------------- /src/repley/visualizer/vega.clj: -------------------------------------------------------------------------------- 1 | (ns repley.visualizer.vega 2 | "Visualize data using Vega-Lite." 3 | (:require [ripley.html :as h] 4 | [repley.protocols :as p] 5 | [cheshire.core :as cheshire])) 6 | 7 | (def ^:private assets 8 | {:js [{:src "https://cdnjs.cloudflare.com/ajax/libs/vega/5.25.0/vega.min.js" 9 | :integrity "sha384-iY3zZAtrtgjJoD8rliThCLEeLUYo8aSNWYQkL+Jaa3KQEAACPnaw/lQIRrFbPCsj" 10 | :crossorigin "anonymous"} 11 | {:src "https://cdnjs.cloudflare.com/ajax/libs/vega-lite/5.13.0/vega-lite.min.js" 12 | :integrity "sha384-p6KYC7E/n1mGQ58+/RZp4aMe/eAJt3jkaZak4G0efYrc6X3101BXrpT7dOvYp+aC" 13 | :crossorigin "anonymous"} 14 | {:src "https://cdnjs.cloudflare.com/ajax/libs/vega-embed/6.22.1/vega-embed.min.js" 15 | :integrity "sha384-6U8AI3zeuX93P24mCh2fFtZitabRM/8UHBkdnUYSCtIDVlInxMeCw6N26Oeh66mi" 16 | :crossorigin "anonymous"}]}) 17 | 18 | (defn- strip-vega-ns [m] 19 | (into {} 20 | (map (fn [[k v]] 21 | (let [new-k (if (and (keyword? k) 22 | (= "vega" (namespace k))) 23 | (keyword (name k)) 24 | k) 25 | new-v (if (map? v) 26 | (strip-vega-ns v) 27 | v)] 28 | [new-k new-v]))) 29 | m)) 30 | 31 | (defn- as-vega [data] 32 | (cheshire/encode 33 | (strip-vega-ns 34 | (merge {"$schema" "https://vega.github.io/schema/vega-lite/v5.json"} 35 | data)))) 36 | 37 | (defn- render [_opts data] 38 | (let [id (gensym "vega")] 39 | (h/html 40 | [:div.m-4 41 | [:div {:id id}] 42 | [:script 43 | "vegaEmbed('#" id "', " 44 | (h/out! (as-vega data)) 45 | ", {renderer: 'svg'});"]]))) 46 | 47 | (defn vega-visualizer [_ {enabled? :enabled? :as opts}] 48 | (when enabled? 49 | (reify p/Visualizer 50 | (label [_] "Vega") 51 | (supports? [_ data] 52 | (or (:vega? (meta data)) 53 | (and (map? data) 54 | (some #(and (keyword? %) 55 | (= (namespace %) "vega")) (keys data))))) 56 | (precedence [_] 100) 57 | (render [_ data] 58 | (render opts data)) 59 | (ring-handler [_] nil) 60 | (assets [_] assets)))) 61 | 62 | 63 | (comment 64 | 65 | ^{:vega? true} 66 | {:data 67 | {:values [{:a "C" :b 2} 68 | {:a "C" :b 7} 69 | {:a "C" :b 4} 70 | {:a "D" :b 1} 71 | {:a "D" :b 2} 72 | {:a "D" :b 6} 73 | {:a "E" :b 8} 74 | {:a "E" :b 4} 75 | {:a "E" :b 7}]} 76 | :mark :bar 77 | :encoding {:y {:field :a :type :nominal} 78 | :x {:aggregate :average 79 | :field :b 80 | :type :quantitative 81 | :axis {:title "Average of b"}}}} 82 | 83 | 84 | #:vega 85 | {:data 86 | {:values [{:category "A" :group "x" :value 0.1} 87 | {:category "A" :group "y" :value 0.6} 88 | {:category "A" :group "z" :value 0.9} 89 | {:category "B" :group "x" :value 0.7} 90 | {:category "B" :group "y" :value 0.2} 91 | {:category "B" :group "z" :value 1.1} 92 | {:category "C" :group "x" :value 0.6} 93 | {:category "C" :group "y" :value 0.1} 94 | {:category "C" :group "z" :value 0.2}]} 95 | :mark :bar 96 | :encoding #:vega {:x {:field :category} 97 | :y {:field :value :type :quantitative} 98 | :xOffset {:field :group} 99 | :color {:field :group}}} 100 | 101 | 102 | ^{:vega? true} 103 | {:mark :bar 104 | :encoding {:x {:field :length :title "Length of name"} 105 | :y {:aggregate :count :title "# of public vars"}} 106 | :data {:values (mapv (fn [[public-name _]] 107 | {:length (count (name public-name))}) 108 | (ns-publics 'clojure.core))}} 109 | 110 | ) 111 | -------------------------------------------------------------------------------- /src/repley/visualizer/file.clj: -------------------------------------------------------------------------------- 1 | (ns repley.visualizer.file 2 | (:require [repley.protocols :as p] 3 | [ripley.live.source :as source] 4 | [ripley.html :as h] 5 | [repley.repl :as repl] 6 | [ring.util.io :as ring-io] 7 | [clojure.java.io :as io] 8 | [repley.ui.icon :as icon] 9 | [repley.ui.table :as table] 10 | [clojure.string :as str] 11 | [ripley.js :as js]) 12 | (:import (java.io File) 13 | (java.util Base64))) 14 | 15 | (defn file-size [f] 16 | (str (.format (java.text.NumberFormat/getIntegerInstance) (.length f)) " bytes")) 17 | 18 | (defn supports? [data] 19 | (instance? File data)) 20 | 21 | (defn image-type 22 | "A very limited extension based image type detection." 23 | [^File f] 24 | (let [ext (-> f .getName (str/split #"\.") last str/lower-case)] 25 | (case ext 26 | ("jpg" "jpeg") "image/jpeg" 27 | "png" "image/png" 28 | "svg" "image/svg+xml" 29 | 30 | ;; Not an image we care to show 31 | nil))) 32 | 33 | (defn- slurp-bytes [f] 34 | (with-open [in (io/input-stream f) 35 | out (java.io.ByteArrayOutputStream.)] 36 | (io/copy in out) 37 | (.toByteArray out))) 38 | 39 | (defn contents [file] 40 | (if-let [img (image-type file)] 41 | (let [src (str "data:" img ";base64," 42 | (.encodeToString (Base64/getEncoder) (slurp-bytes file)))] 43 | (h/html 44 | [:img {:src src}])) 45 | ;; Not an image, show as text in a textarea 46 | (h/html 47 | [:textarea.w-full {:rows 10} (h/dyn! (slurp file))]))) 48 | 49 | (defn render [{prefix :prefix :as _repley-config} {:keys [allow-download?] :as _opts} downloads data] 50 | (let [id repl/*result-id* 51 | download! #(swap! downloads assoc % (str (java.util.UUID/randomUUID))) 52 | [show-contents? set-show-contents!] (source/use-state false)] 53 | (h/html 54 | [:div 55 | [:p "File info"] 56 | [:div [:b "Name: "] (h/dyn! (.getName data))] 57 | [::h/if (.isFile data) 58 | ;; Show file size for regular files 59 | [:div [:b "Size: "] (h/dyn! (file-size data))] 60 | 61 | ;; Show listing of files for directories 62 | (table/table 63 | {:key #(.getAbsolutePath %) 64 | :columns [{:label "Name" :accessor #(.getName %)} 65 | {:label "Size" :accessor #(if (.isDirectory %) 66 | "[DIR]" 67 | (file-size %))}] 68 | :on-row-click #(repl/nav-by! id (fn [_] 69 | {:label (.getName %) 70 | :value %}))} 71 | (source/static (.listFiles data)))] 72 | 73 | [::h/when (and (.isFile data) (.canRead data)) 74 | [:details {:on-toggle (js/js set-show-contents! "window.event.target.open")} 75 | [:summary "Show contents"] 76 | [::h/live show-contents? 77 | (fn [show?] 78 | (h/html 79 | [:div 80 | [::h/when show? 81 | (contents data)]]))]]] 82 | [::h/when (and (.isFile data) (.canRead data) allow-download?) 83 | [:button.btn {:on-click #(download! data)} (icon/download) "Download"]] 84 | [::h/live (source/computed #(get % data) downloads) 85 | (fn [id] 86 | (let [url (str prefix "/file-visualizer/download?id=" id)] 87 | (h/html 88 | [:div [:a {:target :_blank :href url} "Download here"]])))]]))) 89 | 90 | (defn ring-handler [{:keys [prefix] :as _config} downloads] 91 | (fn [{uri :uri q :query-string}] 92 | (when (= uri (str prefix "/file-visualizer/download")) 93 | (let [id (subs q 3) 94 | file (some (fn [[file id*]] 95 | (when (= id* id) file)) @downloads)] 96 | (swap! downloads dissoc file) 97 | (when file 98 | {:status 200 99 | :headers {"Content-Type" "application/octet-stream" 100 | "Content-Disposition" (str "attachment; filename=" (.getName file))} 101 | :body (ring-io/piped-input-stream 102 | (fn [out] 103 | (with-open [in (io/input-stream file)] 104 | (io/copy in out))))}))))) 105 | 106 | (defn file-visualizer [config {:keys [enabled?] :as opts}] 107 | (let [downloads (atom {})] 108 | (when enabled? 109 | (reify p/Visualizer 110 | (label [_] "File") 111 | (supports? [_ data] (supports? data)) 112 | (precedence [_] 100) 113 | (render [_ data] (render config opts downloads data)) 114 | (ring-handler [_] (ring-handler config downloads)) 115 | (assets [_] nil))))) 116 | -------------------------------------------------------------------------------- /src/repley/repl.clj: -------------------------------------------------------------------------------- 1 | (ns repley.repl 2 | "REPL state and functions that modify it" 3 | (:require [clojure.datafy :as df] 4 | [ripley.live.source :as source])) 5 | 6 | (def ^:dynamic *result-id* 7 | "Current result id, bound when calling visualizer to render." 8 | nil) 9 | 10 | (defonce initial-repl-data {:id 0 11 | :results [] 12 | :ns (the-ns 'user) 13 | :tap-listener? false}) 14 | 15 | (defonce repl-data (atom initial-repl-data)) 16 | 17 | (defn- eval-result [ns code-str] 18 | (let [timestamp (java.util.Date.) 19 | start (System/currentTimeMillis) 20 | result (try (binding [*ns* ns] 21 | (load-string code-str)) 22 | (catch Throwable t 23 | t)) 24 | duration (- (System/currentTimeMillis) start)] 25 | {:ns ns 26 | :code-str code-str 27 | :result result 28 | :timestamp timestamp 29 | :duration duration})) 30 | 31 | (defn clear! 32 | "Clear all REPL evaluations." 33 | [] 34 | (swap! repl-data assoc :results [])) 35 | 36 | (defn- add-result [{:keys [id] :as repl} result] 37 | (-> repl 38 | (update :id inc) 39 | (update :results conj 40 | (merge result 41 | {:id id})))) 42 | 43 | (defn add-result! [result] 44 | (swap! repl-data add-result 45 | (merge {:timestamp (java.util.Date.)} 46 | result))) 47 | 48 | (defn eval-input! [input] 49 | (let [result (eval-result (:ns @repl-data) input)] 50 | (swap! repl-data add-result result))) 51 | 52 | (defn- update-result! [id function] 53 | (swap! repl-data update :results 54 | (fn [results] 55 | (mapv (fn [{id* :id :as r}] 56 | (if (= id* id) 57 | (function r) 58 | r)) results)))) 59 | 60 | (defn remove-result! [id] 61 | (swap! repl-data update :results 62 | (fn [results] 63 | (filterv #(not= (:id %) id) results)))) 64 | 65 | 66 | 67 | (defn nav-by! 68 | "Navigate down by giving a function to get the next object from the current on. 69 | Next fn must return a map containing `:label` and `:value` for the breadcrumb 70 | label and the next result respectively." 71 | [id next-fn] 72 | (update-result! 73 | id 74 | (fn [{:keys [result breadcrumbs] :as r}] 75 | (let [{:keys [value label]} (next-fn result) 76 | n (or (some-> breadcrumbs last :n inc) 1)] 77 | (assoc r 78 | :breadcrumbs (conj (or breadcrumbs 79 | [{:label :root :value result :n 0}]) 80 | {:label label 81 | :value value 82 | :n n}) 83 | :result value))))) 84 | 85 | (defn nav! 86 | "Navigate down from current result id to a sub item denoted by k." 87 | [id k] 88 | (nav-by! id (fn [result] 89 | {:label (pr-str k) 90 | :value (df/nav result k (get result k))}))) 91 | 92 | (defn nav-to-crumb! 93 | "Navigate given result to the nth breadcrumb." 94 | [id n] 95 | (update-result! 96 | id 97 | (fn [{:keys [breadcrumbs] :as r}] 98 | (let [next (get-in breadcrumbs [n :value])] 99 | (if (zero? n) 100 | (-> r 101 | (dissoc :breadcrumbs) 102 | (assoc :result next)) 103 | (-> r 104 | (update :breadcrumbs subvec 0 (inc n)) 105 | (assoc :result next))))))) 106 | 107 | (defn retry! [id-to-retry] 108 | (update-result! id-to-retry 109 | (fn [{:keys [ns code-str] :as old-result}] 110 | (merge old-result 111 | (eval-result ns code-str))))) 112 | 113 | (defn current-repl-ns [] 114 | (-> repl-data deref :ns)) 115 | 116 | (defn field-source [& path] 117 | (let [p (vec path) 118 | getv #(get-in % p)] 119 | (source/computed getv repl-data))) 120 | 121 | (defn disable-tap-listener! [] 122 | (swap! repl-data 123 | (fn [{tl :tap-listener :as r}] 124 | (when tl 125 | (remove-tap tl)) 126 | (-> r 127 | (assoc :tap-listener? false) 128 | (dissoc :tap-listener))))) 129 | 130 | (defn enable-tap-listener! [] 131 | (when-not (:tap-listener? @repl-data) 132 | (let [f #(add-result! {:code-str (str ";; tap> value received " 133 | (pr-str (java.util.Date.))) 134 | :result %})] 135 | 136 | (swap! repl-data assoc 137 | :tap-listener? true 138 | :tap-listener f) 139 | (add-tap f) 140 | disable-tap-listener!))) 141 | 142 | 143 | (defn toggle-tap-listener! [] 144 | (if (:tap-listener? @repl-data) 145 | (disable-tap-listener!) 146 | (enable-tap-listener!))) 147 | -------------------------------------------------------------------------------- /test/repley/browser_test.clj: -------------------------------------------------------------------------------- 1 | (ns repley.browser-test 2 | (:require [wally.main :as w] 3 | [repley.main :as main] 4 | [repley.repl :as repl] 5 | [clojure.test :as t :refer [deftest is testing]] 6 | [clojure.string :as str])) 7 | 8 | (defn with-repl [f] 9 | (let [stop-server (main/start {:http {:port 4444}})] 10 | (w/with-page (w/make-page {:headless true}) 11 | (try 12 | (w/navigate "http://localhost:4444/") 13 | (f) 14 | (finally 15 | (repl/clear!) 16 | (repl/disable-tap-listener!) 17 | (stop-server)))))) 18 | 19 | (t/use-fixtures :each with-repl) 20 | 21 | (defn eval-in-repl [& code] 22 | (let [js (str/replace (apply str code) "'" "\\'")] 23 | (.evaluate (w/get-page) (str "() => _eval('" js "')")) 24 | (Thread/sleep 10))) 25 | 26 | (comment 27 | ;; FIXME: disabled this test for now, as the drive URL doesn't seem to be accessible from 28 | ;; github actions runner 29 | 30 | ;; See https://github.com/datablist/sample-csv-files for URL to customers-100.csv 31 | (def sample-csv-url "https://drive.google.com/uc?id=13a2WyLoGxQKXbN_AIjrOogIlQKNe9uPm&export=download") 32 | 33 | (deftest csv-table-test 34 | (eval-in-repl "(def url \"" sample-csv-url "\")") 35 | (is (= (-> 'user ns-publics (get 'url) deref) sample-csv-url)) 36 | (eval-in-repl "(require '[clojure.data.csv :as csv]) (def customers (csv/read-csv (clojure.java.io/reader url)))") 37 | (Thread/sleep 1000) ;; wait for HTTP load and CSV parsing to take place 38 | (let [customers (-> 'user ns-publics (get 'customers) deref)] 39 | (is (= 101 (count customers)))) 40 | (w/click (w/find-one-by-text :.tab "Table")) 41 | (w/wait "table.table") 42 | (is (= 20 (w/count* (w/query "table.table tbody tr")))) 43 | (w/fill [:evaluation "input"] "Fiji") 44 | (Thread/sleep 500) 45 | (is (= 1 (w/count* (w/query "table.table tbody tr")))))) 46 | 47 | (defn breadcrumbs [] 48 | (Thread/sleep 200) 49 | (.evaluate (w/get-page) "() => { var s = ''; document.querySelectorAll('div.breadcrumbs li').forEach(e=>s+=e.innerText+';'); return s; }")) 50 | 51 | (deftest breadcrumbs-test 52 | ;; evaluate a nested map structure 53 | (eval-in-repl "{:hello {:there {:my [\"friend\" {:name \"Rich\"} \"!\"]}}}") 54 | 55 | ;; descend deeper into result 56 | (w/click (w/find-one-by-text "td" ":hello")) 57 | (is (= (breadcrumbs) ";:hello;")) 58 | (w/click (w/find-one-by-text "td" ":there")) 59 | (is (= (breadcrumbs) ";:hello;:there;")) 60 | (w/click (w/find-one-by-text "td" ":my")) 61 | (is (= (breadcrumbs) ";:hello;:there;:my;")) 62 | ;;(w/click "") ;; drill down from table 63 | (w/click (w/find-one-by-text "span" "\"friend\"")) 64 | (is (= (breadcrumbs) ";:hello;:there;:my;0;")) 65 | 66 | ;; drill back to :hello 67 | (w/click (w/find-one-by-text "li" ":hello")) 68 | (is (= (breadcrumbs) ";:hello;")) 69 | 70 | ;; drill back to home, breadcrumbs disappear 71 | (w/click "div.breadcrumbs li:nth-child(1)") 72 | (is (= (breadcrumbs) ""))) 73 | 74 | (deftest table-data-escaped 75 | (eval-in-repl "{:foo 1 :bar \"\"}") 76 | (w/click (w/find-one-by-text :.tab "Table")) 77 | ;;(.pause (w/get-page)) 78 | (is (w/find-one-by-text "td" ""))) 79 | 80 | (defn evaluation-count [] 81 | (w/count* (w/-query "div.evaluation"))) 82 | 83 | (deftest tap-listen-option 84 | (w/click ".options") 85 | (is (not (.isChecked (w/-query ".tap-listener")))) 86 | (testing "tap> does not send result to REPL" 87 | (tap> 1) 88 | (Thread/sleep 50) 89 | (is (zero? (evaluation-count)))) 90 | (testing "enabling tap> listener" 91 | (w/click ".tap-listener") 92 | (Thread/sleep 50) 93 | (tap> 2) 94 | (Thread/sleep 50) 95 | (is (= 1 (evaluation-count))) 96 | (is (= "2" (w/text-content "div.evaluation div.edn")))) 97 | (testing "disabling again" 98 | (w/click ".tap-listener") 99 | (Thread/sleep 50) 100 | (tap> 3) 101 | (Thread/sleep 50) 102 | (is (= 1 (evaluation-count))))) 103 | 104 | (deftest clear-results 105 | (eval-in-repl "1") 106 | (eval-in-repl "2") 107 | (eval-in-repl "3") 108 | (is (= 3 (evaluation-count))) 109 | (w/click ".options") 110 | (w/click ".clear-results") 111 | (Thread/sleep 10) 112 | (is (zero? (evaluation-count)))) 113 | 114 | 115 | (deftest retry-remembers-visualization 116 | (def things [{:foo 1} {:foo 2}]) 117 | (eval-in-repl "repley.browser-test/things") 118 | (is (= 1 (evaluation-count))) 119 | (w/click (w/find-one-by-text :.tab "Table")) 120 | (w/wait ".evaluation table.table") 121 | (is (= 2 (w/count* (w/query ".evaluation table.table tbody tr")))) 122 | 123 | ;; Re-evaluate things and retry, table should remain 124 | (def things [{:foo 1} {:foo 2} {:foo 3}]) 125 | (w/click "button.retry") 126 | (is (= 3 (w/count* (w/query ".evaluation table.table tbody tr"))))) 127 | -------------------------------------------------------------------------------- /src/repley/visualizers.clj: -------------------------------------------------------------------------------- 1 | (ns repley.visualizers 2 | "Default visualizer implementation." 3 | (:require [repley.ui.chart :as chart] 4 | [repley.ui.edn :as edn] 5 | [repley.ui.table :as table] 6 | [repley.protocols :as p] 7 | [ripley.live.source :as source] 8 | [ripley.html :as h] 9 | [repley.repl :as repl] 10 | [repley.visualizer.file :as file] 11 | [repley.visualizer.vega :as vega])) 12 | 13 | (def sample-size 14 | "How many items to check when testing collection items." 15 | 10) 16 | 17 | (defn- map-columns-and-data [data] 18 | (let [id repl/*result-id*] 19 | (when (map? data) 20 | {:columns [{:label "Key" :accessor key} {:label "Value" :accessor val}] 21 | :data data 22 | :on-row-click #(repl/nav! id (key %))}))) 23 | 24 | (defn- seq-of-maps-columns-and-data [data] 25 | (let [id repl/*result-id*] 26 | (when (and (sequential? data) 27 | (every? map? (take 10 data))) 28 | {:columns (sort-by :label 29 | (for [k (into #{} 30 | (mapcat keys) 31 | data)] 32 | {:label (str k) :accessor #(get % k)})) 33 | :data (map-indexed (fn [i obj] 34 | (with-meta obj {::index i})) data) 35 | :on-row-click #(repl/nav! id (::index (meta %)))}))) 36 | 37 | (defn- csv-columns-and-data [data] 38 | (when (and (seq? data) 39 | (every? vector? (take 10 data))) 40 | (let [columns (first data) 41 | data (rest data)] 42 | {:columns (map-indexed (fn [i label] 43 | {:label label :accessor #(nth % i)}) columns) 44 | :data data}))) 45 | 46 | (def columns-and-data (some-fn map-columns-and-data 47 | seq-of-maps-columns-and-data 48 | csv-columns-and-data)) 49 | 50 | (def supported-data? (comp boolean columns-and-data)) 51 | 52 | (defn table-visualizer [_repley-opts {:keys [enabled? precedence] 53 | :or {precedence 0}}] 54 | (when enabled? 55 | (reify p/Visualizer 56 | (label [_] "Table") 57 | (supports? [_ data] 58 | (supported-data? data)) 59 | (precedence [_] precedence) 60 | (render [_ data] 61 | (let [{:keys [columns data on-row-click]} (columns-and-data data) 62 | [data-source _] (source/use-state data)] 63 | (table/table 64 | {:columns columns 65 | :on-row-click on-row-click} 66 | data-source))) 67 | (ring-handler [_] nil) 68 | (assets [_] nil)))) 69 | 70 | (defmulti render-meta-field (fn [k _v] k)) 71 | 72 | (defmethod render-meta-field :default 73 | [_ v] 74 | (edn/edn v)) 75 | 76 | (defmethod render-meta-field :doc 77 | [_ v] 78 | (h/html 79 | [:pre v])) 80 | 81 | (defn edn-visualizer [_ {:keys [enabled? precedence] 82 | :or {precedence 0}}] 83 | (when enabled? 84 | (reify p/Visualizer 85 | (label [_] "Result") 86 | (supports? [_ _] true) 87 | (precedence [_] precedence) 88 | (render [_ data] 89 | (let [metadata (meta data)] 90 | (h/html 91 | [:div 92 | (edn/edn data) 93 | [::h/when metadata [:div.divider]] 94 | [::h/when metadata 95 | [:details {:open true} 96 | [:summary [:b "Metadata:"]] 97 | [:table.table.table-xs 98 | [:thead [:tr [:td "Name"] [:td "Value"]]] 99 | [:tbody 100 | [::h/for [[k v] (seq metadata)] 101 | [:tr 102 | [:td [:b k]] 103 | [:td (render-meta-field k v)]]]]]]]]))) 104 | 105 | (ring-handler [_] nil) 106 | (assets [_] nil)))) 107 | 108 | (defn- render-throwable [ex] 109 | (let [id repl/*result-id* 110 | type-label (.getName (type ex)) 111 | msg (ex-message ex) 112 | data (ex-data ex) 113 | trace (.getStackTrace ex) 114 | cause (ex-cause ex) 115 | cause-label (when cause 116 | (str (.getName (type cause)) ": " 117 | (.getMessage cause))) 118 | nav-by! (fn [label value] (repl/nav-by! id (constantly {:label label :value value})))] 119 | (h/html 120 | [:div 121 | [:div [:b "Type: "] type-label] 122 | [:div [:b "Message:​ "] msg] 123 | [::h/when cause-label 124 | [:div [:b "Cause: "] 125 | [:a.link {:on-click #(nav-by! (.getName (type cause)) cause)} 126 | cause-label]]] 127 | [::h/when (seq data) 128 | [:div [:b "Data​ "] 129 | (edn/edn {:nav (fn [key] 130 | #(nav-by! (str key) (get data key)))} data)]] 131 | [:div [:b "Stack trace​ "] 132 | [:details 133 | [:summary (h/out! (count trace) " stack trace lines")] 134 | [:ul 135 | [::h/for [st trace 136 | :let [cls (.getClassName st) 137 | method (.getMethodName st) 138 | file (.getFileName st) 139 | line (.getLineNumber st)]] 140 | [:li cls "." method " (" file ":" line ")"]]]]]]))) 141 | 142 | (defn throwable-visualizer [_ {:keys [enabled? precedence] 143 | :or {precedence 100}}] 144 | (when enabled? 145 | (reify p/Visualizer 146 | (label [_] "Throwable") 147 | (supports? [_ ex] (instance? java.lang.Throwable ex)) 148 | (precedence [_] precedence) 149 | (render [_ ex] (render-throwable ex)) 150 | (ring-handler [_] nil) 151 | (assets [_] nil)))) 152 | 153 | (defn- chart-supports? [_opts x] 154 | (and (map? x) 155 | (every? number? (take sample-size (vals x))))) 156 | 157 | (defn- chart-render [_opts data] 158 | (h/html 159 | [:div.chart 160 | (chart/bar-chart 161 | {:label-accessor (comp str key) 162 | :value-accessor val} 163 | (source/static data))])) 164 | 165 | (defn chart-visualizer [_ {enabled? :enabled? :as opts}] 166 | (when enabled? 167 | (reify p/Visualizer 168 | (label [_] "Chart") 169 | (supports? [_ data] (chart-supports? opts data)) 170 | (precedence [_] 0) 171 | (render [_ data] (chart-render opts data)) 172 | (ring-handler [_] nil) 173 | (assets [_] nil)))) 174 | 175 | (defn default-visualizers 176 | [opts] 177 | (let [{v :visualizers :as opts} opts] 178 | (remove nil? 179 | [(edn-visualizer opts (:edn-visualizer v)) 180 | (table-visualizer opts (:table-visualizer v)) 181 | (file/file-visualizer opts (:file-visualizer v)) 182 | (throwable-visualizer opts (:throwable-visualizer v)) 183 | (chart-visualizer opts (:chart-visualizer v)) 184 | (vega/vega-visualizer opts (:vega-visualizer v))]))) 185 | 186 | (defn default-visualizer 187 | "Utility to create a default visualizer for the given object." 188 | [label object render-fn] 189 | (reify p/DefaultVisualizer 190 | (default-visualizer [_] 191 | (reify p/Visualizer 192 | (label [_] label) 193 | (supports? [_ data] (= data object)) 194 | (precedence [_] Long/MAX_VALUE) 195 | (render [_ _] (render-fn object)) 196 | (ring-handler [_] nil) 197 | (assets [_] nil))) 198 | (object [_] object))) 199 | -------------------------------------------------------------------------------- /src/repley/ui/edn.clj: -------------------------------------------------------------------------------- 1 | (ns repley.ui.edn 2 | "Pretty HTML rendering of arbitrary EDN." 3 | (:require [ripley.html :as h] 4 | [ripley.live.source :as source] 5 | [repley.ui.edn :as edn] 6 | [repley.repl :as repl] 7 | [clojure.string :as str])) 8 | 9 | ;; Keep track of how much visible (non-markup) output has been 10 | ;; written so far... when we reach max-output, stop rendering 11 | ;; more and show links to expand 12 | (def ^{:private true :dynamic true} *truncate* nil) 13 | 14 | ;; When *top* is true, set on-click handlers that navigate 15 | ;; deeper into collections 16 | (def ^{:private true :dynamic true} *top* false) 17 | 18 | (defn- truncate-state [max-output] 19 | (atom {:output 0 20 | :max-output max-output 21 | 22 | ;; If true, don't output anything more 23 | ;; except the link to render more 24 | :truncated? false})) 25 | 26 | (defn- truncated? [] 27 | (let [{:keys [truncated? max-output]} @*truncate*] 28 | (if (zero? max-output) 29 | ;; Render all, if max output is zero 30 | false 31 | truncated?))) 32 | 33 | (defn- visible [& things] 34 | (when-not (truncated?) 35 | (let [string (apply str things) 36 | len (count string) 37 | {:keys [output max-output]} @*truncate*] 38 | (if (and (not= 0 max-output) (> (+ len output) max-output)) 39 | (do 40 | (h/dyn! (subs string 0 (- max-output output))) 41 | (swap! *truncate* assoc 42 | :output max-output 43 | :truncated? true)) 44 | (do 45 | (h/dyn! string) 46 | (swap! *truncate* update :output + len)))))) 47 | 48 | 49 | (defmulti render (fn [_ctx item] (type item))) 50 | 51 | (defn- render-top [ctx item] 52 | (binding [*top* true] (render ctx item))) 53 | 54 | (defn- render-nested [ctx item] 55 | (binding [*top* false] (render ctx item))) 56 | 57 | (defmulti summary (fn [_ctx item] (type item))) 58 | (defmulti summarize? (fn [item] (type item))) 59 | 60 | (defmethod render :default [_ item] 61 | (h/html [:span (visible (pr-str item))])) 62 | 63 | (defmethod render java.lang.String [_ctx str] 64 | (h/html [:span.text-lime-500 (visible "\"" str "\"")])) 65 | 66 | (defmethod render java.lang.Number [_ctx num] 67 | (h/html [:span.text-red-300 (visible (pr-str num))])) 68 | 69 | (defmethod render clojure.lang.Keyword [_ctx kw] 70 | (h/html [:span.text-emerald-700 (visible (pr-str kw))])) 71 | 72 | (defn- nav [{:keys [nav]} key] 73 | (when *top* 74 | (if nav 75 | (nav key) 76 | (str "_nav(" repl/*result-id* ",'" 77 | (-> key pr-str 78 | (str/replace "\\" "\\\\") 79 | (str/replace "'" "\\'")) 80 | "')")))) 81 | 82 | (defn- collection [{render-item :render-item :as ctx 83 | :or {render-item render-nested}} 84 | before after items] 85 | (let [cls (str "inline-flex space-x-2 flex-wrap " 86 | (if (every? map? items) 87 | "flex-col" 88 | "flex-row"))] 89 | (h/html 90 | [:div.flex 91 | (visible before) 92 | [:div {:class cls} 93 | [::h/for [[i v] (map-indexed vector items) 94 | :when (not (truncated?)) 95 | :let [cls (when *top* "hover:bg-primary")]] 96 | [:div.inline-block {:class cls :on-click (nav ctx i)} 97 | (render-item (dissoc ctx :render-item) v)]]] 98 | (visible after)]))) 99 | 100 | (defmethod render clojure.lang.PersistentVector [ctx vec] 101 | (collection ctx "[" "]" vec)) 102 | 103 | (defmethod render clojure.lang.PersistentList [ctx vec] 104 | (collection ctx "(" ")" vec)) 105 | 106 | (defmethod render clojure.lang.LazySeq [ctx vec] 107 | (collection ctx "(" ")" vec)) 108 | 109 | (defmethod render clojure.lang.IPersistentMap [ctx m] 110 | (if (empty? m) 111 | (h/html [:div.inline-block (visible "{}")]) 112 | (let [entries (seq m) 113 | normal-entries (butlast entries) 114 | last-entry (last entries) 115 | hover (when *top* 116 | "hover:bg-primary")] 117 | (h/html 118 | [:div.inline-block.flex 119 | (visible "{") 120 | [:table 121 | [::h/for [[key val] normal-entries 122 | :when (not (truncated?))] 123 | [:tr.whitespace-pre {:class hover :on-click (nav ctx key)} 124 | [:td.align-top.py-0.pl-0.pr-2 125 | (render-nested ctx key)] 126 | [:td.align-top.p-0 127 | (render-nested ctx val)]]] 128 | [:tr.whitespace-pre {:class hover :on-click (nav ctx (key last-entry))} 129 | [:td.align-top.py-0.pl-0.pr-2 (render-nested ctx (key last-entry))] 130 | [:td.align-top.p-0 [:div.inline-block (render-nested ctx (val last-entry))] 131 | (visible "}")]]]])))) 132 | 133 | (defmethod render clojure.lang.Var [ctx v] 134 | (render ctx (deref v))) 135 | 136 | (defmethod summary clojure.lang.LazySeq [ctx thing] 137 | (h/out! "Lazy sequence with " (count thing) " elements")) 138 | 139 | (defmethod summary clojure.lang.PersistentList [ctx thing] 140 | (h/out! "List with " (count thing) " elements")) 141 | 142 | (defmethod summary clojure.lang.PersistentVector [ctx thing] 143 | (h/out! "Vector with " (count thing) " elements")) 144 | 145 | (defmethod summary clojure.lang.IPersistentMap [ctx m] 146 | (h/out! "Map with " (count m) " entries")) 147 | 148 | (defmethod summary clojure.lang.Var [ctx v] 149 | (h/out! "Var " (str v))) 150 | 151 | (defmethod summary java.lang.Throwable [ctx ex] 152 | (if (= clojure.lang.Compiler$CompilerException (type ex)) 153 | ;; Special handling for compiler exceptions 154 | (let [msg (.getMessage (.getCause ex)) 155 | {:clojure.error/keys [phase line column]} (ex-data ex) 156 | phase (name phase)] 157 | (h/html 158 | [:div.text-red-500.inline msg [:span.font-xs " (" phase " at line " line ", column " column ")"]])) 159 | (let [ex-type (.getName (type ex)) 160 | ex-msg (.getMessage ex)] 161 | (h/html 162 | [:div.text-red-500.inline ex-type ": " ex-msg])))) 163 | 164 | (defmethod summary :default [ctx thing] 165 | (h/out! (str (type thing)) " instance")) 166 | 167 | (defmethod summarize? java.lang.String [s] false) 168 | (defmethod summarize? java.lang.Number [_] false) 169 | (defmethod summarize? clojure.lang.Keyword [_] false) 170 | (defmethod summarize? :default [thing] true) 171 | 172 | (def ^:private initial-max-output 1024) 173 | 174 | (defn- expand-buttons [max-output set-max-output!] 175 | (h/html 176 | [::h/when (truncated?) 177 | [:div.flex 178 | [:div.inline.mx-2.text-accent "output truncated"] 179 | [:button.btn.btn-accent.btn-xs.mx-2 {:on-click #(set-max-output! (* 2 max-output))} 180 | "more"] 181 | [:button.btn.btn-accent.btn-xs.mx-2 {:on-click #(set-max-output! 0)} 182 | "full"]]])) 183 | 184 | (defn edn 185 | ([thing] (edn {} thing)) 186 | ([ctx thing] 187 | (let [[max-output-source set-max-output!] (source/use-state initial-max-output) 188 | id repl/*result-id*] 189 | (h/html 190 | [:div.edn 191 | [::h/if (nil? thing) 192 | [:span.nil "nil"] 193 | [::h/live max-output-source 194 | (fn [max-output] 195 | (binding [*truncate* (truncate-state max-output) 196 | repl/*result-id* id] 197 | (let [expand (partial expand-buttons max-output set-max-output!)] 198 | (h/html 199 | [:div 200 | [::h/if (not (summarize? thing)) 201 | [:div.my-2 202 | (render-top ctx thing) 203 | (expand)] 204 | [:details {:open true} 205 | [:summary (summary ctx thing)] 206 | (render-top ctx thing) 207 | (expand)]]]))))]]])))) 208 | -------------------------------------------------------------------------------- /src/repley/ui/table.clj: -------------------------------------------------------------------------------- 1 | (ns repley.ui.table 2 | "A table component with filtering." 3 | (:require [ripley.html :as h] 4 | [ripley.live.source :as source] 5 | [ripley.live.collection :as collection] 6 | [clojure.string :as str] 7 | [ripley.js :as js])) 8 | 9 | (defn- default-filter-fn 10 | "A very simple text filter, just checks if printed representation 11 | includes the string (ignoring case)." 12 | [item text] 13 | (str/includes? (str/lower-case (pr-str item)) 14 | (str/lower-case text))) 15 | 16 | (def generic-comparator 17 | (reify java.util.Comparator 18 | (compare [_ o1 o2] 19 | (if (and (instance? java.lang.Comparable o1) 20 | (= (type o1) (type o2))) 21 | ;; Compare comparables of the same type 22 | (.compareTo o1 o2) 23 | 24 | ;; Fallback to comparing string representations 25 | (let [s1 (pr-str o1) 26 | s2 (pr-str o2)] 27 | (.compareTo s1 s2)))))) 28 | 29 | (defn- filter-items [ordered-source? filter-fn items 30 | {text :filter 31 | [order-by order-direction] :order}] 32 | 33 | (let [filtered-items (into [] 34 | (filter #(filter-fn % text)) 35 | items) 36 | items (if (and order-by (not ordered-source?)) 37 | ((case order-direction 38 | :asc identity 39 | :desc reverse) 40 | (sort-by order-by generic-comparator filtered-items)) 41 | filtered-items)] 42 | {:items items 43 | :count (count items)})) 44 | 45 | (defn- render-row [{:keys [columns row-class on-row-click]} row] 46 | (let [cls (str row-class 47 | (when on-row-click 48 | " cursor-pointer"))] 49 | (h/html 50 | [:tr {:class cls :on-click (when on-row-click 51 | #(on-row-click row))} 52 | [::h/for [{:keys [accessor render render-full]} columns 53 | :let [data (accessor row)]] 54 | [:td.align-top.px-2 55 | (cond 56 | render-full (render-full row) 57 | render (render data) 58 | :else (h/dyn! data))]]]))) 59 | 60 | (defn- filter-input [set-filter! count-source] 61 | (let [id (str (gensym "table-filter"))] 62 | (h/html 63 | [:div.my-2.join 64 | [:div.block.relative.join-item 65 | [:span.h-full.absolute.inset-y-0.left-0.flex.items-center.pl-2 66 | [:svg.h-4.w-4.fill-current.text-gray-500 {:viewBox "0 0 24 24"} 67 | [:path {:d "M10 4a6 6 0 100 12 6 6 0 000-12zm-8 6a8 8 0 1114.32 4.906l5.387 5.387a1 1 0 01-1.414 1.414l-5.387-5.387A8 8 0 012 10z"}]]] 68 | [:input {:id id 69 | :class "appearance-none rounded-r rounded-l sm:rounded-l-none border border-gray-400 border-b block pl-8 pr-6 py-2 w-full bg-white text-sm placeholder-gray-400 text-gray-700 focus:bg-white focus:placeholder-gray-600 focus:text-gray-700 focus:outline-none" 70 | :placeholder "Filter..." 71 | :on-input (js/js-debounced 300 set-filter! 72 | (js/input-value id))}]] 73 | [::h/live count-source 74 | #(h/html [:div.join-item.text-sm.mx-2.py-2 " " % " items"])]]))) 75 | 76 | (defn- header [{:keys [columns]} set-order! [order-by order-direction]] 77 | (h/html 78 | [:thead 79 | [:tr 80 | [::h/for [{:keys [label accessor order-by?] 81 | :or {order-by? true}} columns] 82 | [:th.text-left {:on-click #(when order-by? 83 | (set-order! [accessor (case order-direction 84 | :asc :desc 85 | :desc :asc)]))} 86 | label 87 | (when (and (= order-by accessor)) 88 | (h/out! (case order-direction 89 | :asc " \u2303" 90 | :desc " \u2304")))]]]])) 91 | 92 | (defn- pagination [set-page! {:keys [row-count page page-size]}] 93 | (h/html 94 | [:div.join 95 | [::h/for [p (range 0 (Math/ceil (/ row-count page-size))) 96 | :let [pg (str (inc p)) 97 | cls (when (= p page) "btn-active")]] 98 | [:button.join-item.btn.btn-sm {:class cls 99 | :on-click #(set-page! p)} pg]]])) 100 | 101 | (defn table 102 | "A data table that allows ordering by columns and filtering. 103 | 104 | Takes two arguments: an options map and the live source for items. 105 | 106 | Options: 107 | 108 | :columns collection of columns for the table. Each column is a map 109 | containing at least :label and :accessor. 110 | 111 | Column may contain :render which is called to render the value. 112 | Default render just stringifies the value. 113 | 114 | The :render function is called with just the value. 115 | To pass the whole row to the render function, use :render-full 116 | instead. 117 | 118 | 119 | If :order-by? is false, then this column can't be ordered by. 120 | 121 | Example: [{:label \"Name\" :accessor :name} 122 | {:label \"Email\" :accessor :email}] 123 | 124 | :filter-fn predicate that is called with item and current filter text 125 | default implementation just checks the printed representation 126 | of the item for a substring match. 127 | 128 | :order the initial order [accessor direction] (eg. [:name :asc]) 129 | 130 | :set-order! if specified, ordering will be done at the source level 131 | and not by the table. If the source is an XTDB query, 132 | it should handle the ordering in the query. 133 | If not set, the items are ordered by using clojure builtin 134 | `sort-by` function. 135 | 136 | :class Class to apply to the main table. 137 | Defaults to \"table table-compact table-zebra\". 138 | 139 | :row-class Class to apply to rows. 140 | defaults to slightly striped coloring of alternate rows 141 | 142 | :on-row-click 143 | Add optional callback to when the row is clicked. 144 | The function is called with the full row data. 145 | 146 | :page-size How many items to show per page (default: 20). 147 | " 148 | [{:keys [key filter-fn order set-order! render-after empty-message page-size class] 149 | :or {filter-fn default-filter-fn 150 | key identity 151 | order [nil :asc] 152 | page-size 20 153 | class "table table-compact table-zebra"} :as table-def} data-source] 154 | (let [[state-source _ update-state!] (source/use-state {:filter "" :order order :page 0}) 155 | set-filter! #(update-state! assoc :filter % :page 0) 156 | set-table-order! #(update-state! assoc :order %) 157 | items-source (source/computed 158 | (partial filter-items (some? set-order!) filter-fn) 159 | data-source state-source) 160 | rows-source (source/computed (fn [{items :items} {page :page}] 161 | (->> items 162 | (drop (* page page-size)) 163 | (take page-size))) 164 | items-source state-source) 165 | pagination-source (source/computed (fn [{c :count} {p :page}] 166 | (when (> c page-size) 167 | {:row-count c 168 | :page p 169 | :page-size page-size})) 170 | items-source state-source)] 171 | (h/html 172 | [:div.mx-2.font-mono 173 | (filter-input set-filter! (source/computed :count items-source)) 174 | [:table {:class class} 175 | [::h/live (source/computed :order state-source) 176 | (partial header table-def 177 | #(do 178 | (when set-order! 179 | (set-order! %)) 180 | (set-table-order! %)))] 181 | [::h/when empty-message 182 | [::h/live (source/computed empty? rows-source) 183 | #(let [cols (count (:columns table-def))] 184 | (h/html 185 | [:tbody 186 | [::h/when % 187 | [:tr 188 | [:td {:colspan cols} 189 | empty-message]]]]))]] 190 | 191 | (collection/live-collection 192 | {:render (partial render-row table-def) 193 | :key key 194 | :container-element :tbody 195 | :source rows-source}) 196 | 197 | (when render-after 198 | (render-after))] 199 | [::h/live pagination-source (partial pagination #(update-state! assoc :page %))]]))) 200 | -------------------------------------------------------------------------------- /src/repley/ui/icon.clj: -------------------------------------------------------------------------------- 1 | (ns repley.ui.icon 2 | (:require [ripley.html :as h])) 3 | 4 | ;; Icons from https://icons.radix-ui.com 5 | 6 | (defmacro icon [path] 7 | `(h/html 8 | [:svg {:width 15 :height 15 :viewBox "0 0 15 15" :fill "none"} 9 | ~(if (string? path) 10 | [:path {:d path 11 | :fill "currentColor" 12 | :fill-rule "evenodd" 13 | :clip-rule "evenodd"}] 14 | path)])) 15 | 16 | (defmacro define-icons [& names-and-paths] 17 | `(do 18 | ~@(for [[name-sym path] (partition 2 names-and-paths)] 19 | `(defn ~name-sym [] (icon ~path))))) 20 | 21 | (define-icons 22 | reload 23 | "M1.84998 7.49998C1.84998 4.66458 4.05979 1.84998 7.49998 1.84998C10.2783 1.84998 11.6515 3.9064 12.2367 5H10.5C10.2239 5 10 5.22386 10 5.5C10 5.77614 10.2239 6 10.5 6H13.5C13.7761 6 14 5.77614 14 5.5V2.5C14 2.22386 13.7761 2 13.5 2C13.2239 2 13 2.22386 13 2.5V4.31318C12.2955 3.07126 10.6659 0.849976 7.49998 0.849976C3.43716 0.849976 0.849976 4.18537 0.849976 7.49998C0.849976 10.8146 3.43716 14.15 7.49998 14.15C9.44382 14.15 11.0622 13.3808 12.2145 12.2084C12.8315 11.5806 13.3133 10.839 13.6418 10.0407C13.7469 9.78536 13.6251 9.49315 13.3698 9.38806C13.1144 9.28296 12.8222 9.40478 12.7171 9.66014C12.4363 10.3425 12.0251 10.9745 11.5013 11.5074C10.5295 12.4963 9.16504 13.15 7.49998 13.15C4.05979 13.15 1.84998 10.3354 1.84998 7.49998Z" 24 | 25 | trashcan 26 | "M5.5 1C5.22386 1 5 1.22386 5 1.5C5 1.77614 5.22386 2 5.5 2H9.5C9.77614 2 10 1.77614 10 1.5C10 1.22386 9.77614 1 9.5 1H5.5ZM3 3.5C3 3.22386 3.22386 3 3.5 3H5H10H11.5C11.7761 3 12 3.22386 12 3.5C12 3.77614 11.7761 4 11.5 4H11V12C11 12.5523 10.5523 13 10 13H5C4.44772 13 4 12.5523 4 12V4L3.5 4C3.22386 4 3 3.77614 3 3.5ZM5 4H10V12H5V4Z" 27 | 28 | download 29 | "M7.50005 1.04999C7.74858 1.04999 7.95005 1.25146 7.95005 1.49999V8.41359L10.1819 6.18179C10.3576 6.00605 10.6425 6.00605 10.8182 6.18179C10.994 6.35753 10.994 6.64245 10.8182 6.81819L7.81825 9.81819C7.64251 9.99392 7.35759 9.99392 7.18185 9.81819L4.18185 6.81819C4.00611 6.64245 4.00611 6.35753 4.18185 6.18179C4.35759 6.00605 4.64251 6.00605 4.81825 6.18179L7.05005 8.41359V1.49999C7.05005 1.25146 7.25152 1.04999 7.50005 1.04999ZM2.5 10C2.77614 10 3 10.2239 3 10.5V12C3 12.5539 3.44565 13 3.99635 13H11.0012C11.5529 13 12 12.5528 12 12V10.5C12 10.2239 12.2239 10 12.5 10C12.7761 10 13 10.2239 13 10.5V12C13 13.1041 12.1062 14 11.0012 14H3.99635C2.89019 14 2 13.103 2 12V10.5C2 10.2239 2.22386 10 2.5 10Z" 30 | 31 | home 32 | "M7.07926 0.222253C7.31275 -0.007434 7.6873 -0.007434 7.92079 0.222253L14.6708 6.86227C14.907 7.09465 14.9101 7.47453 14.6778 7.71076C14.4454 7.947 14.0655 7.95012 13.8293 7.71773L13 6.90201V12.5C13 12.7761 12.7762 13 12.5 13H2.50002C2.22388 13 2.00002 12.7761 2.00002 12.5V6.90201L1.17079 7.71773C0.934558 7.95012 0.554672 7.947 0.32229 7.71076C0.0899079 7.47453 0.0930283 7.09465 0.32926 6.86227L7.07926 0.222253ZM7.50002 1.49163L12 5.91831V12H10V8.49999C10 8.22385 9.77617 7.99999 9.50002 7.99999H6.50002C6.22388 7.99999 6.00002 8.22385 6.00002 8.49999V12H3.00002V5.91831L7.50002 1.49163ZM7.00002 12H9.00002V8.99999H7.00002V12Z" 33 | 34 | gear 35 | "M7.07095 0.650238C6.67391 0.650238 6.32977 0.925096 6.24198 1.31231L6.0039 2.36247C5.6249 2.47269 5.26335 2.62363 4.92436 2.81013L4.01335 2.23585C3.67748 2.02413 3.23978 2.07312 2.95903 2.35386L2.35294 2.95996C2.0722 3.2407 2.0232 3.6784 2.23493 4.01427L2.80942 4.92561C2.62307 5.2645 2.47227 5.62594 2.36216 6.00481L1.31209 6.24287C0.924883 6.33065 0.650024 6.6748 0.650024 7.07183V7.92897C0.650024 8.32601 0.924883 8.67015 1.31209 8.75794L2.36228 8.99603C2.47246 9.375 2.62335 9.73652 2.80979 10.0755L2.2354 10.9867C2.02367 11.3225 2.07267 11.7602 2.35341 12.041L2.95951 12.6471C3.24025 12.9278 3.67795 12.9768 4.01382 12.7651L4.92506 12.1907C5.26384 12.377 5.62516 12.5278 6.0039 12.6379L6.24198 13.6881C6.32977 14.0753 6.67391 14.3502 7.07095 14.3502H7.92809C8.32512 14.3502 8.66927 14.0753 8.75705 13.6881L8.99505 12.6383C9.37411 12.5282 9.73573 12.3773 10.0748 12.1909L10.986 12.7653C11.3218 12.977 11.7595 12.928 12.0403 12.6473L12.6464 12.0412C12.9271 11.7604 12.9761 11.3227 12.7644 10.9869L12.1902 10.076C12.3768 9.73688 12.5278 9.37515 12.638 8.99596L13.6879 8.75794C14.0751 8.67015 14.35 8.32601 14.35 7.92897V7.07183C14.35 6.6748 14.0751 6.33065 13.6879 6.24287L12.6381 6.00488C12.528 5.62578 12.3771 5.26414 12.1906 4.92507L12.7648 4.01407C12.9766 3.6782 12.9276 3.2405 12.6468 2.95975L12.0407 2.35366C11.76 2.07292 11.3223 2.02392 10.9864 2.23565L10.0755 2.80989C9.73622 2.62328 9.37437 2.47229 8.99505 2.36209L8.75705 1.31231C8.66927 0.925096 8.32512 0.650238 7.92809 0.650238H7.07095ZM4.92053 3.81251C5.44724 3.44339 6.05665 3.18424 6.71543 3.06839L7.07095 1.50024H7.92809L8.28355 3.06816C8.94267 3.18387 9.5524 3.44302 10.0794 3.81224L11.4397 2.9547L12.0458 3.56079L11.1882 4.92117C11.5573 5.44798 11.8164 6.0575 11.9321 6.71638L13.5 7.07183V7.92897L11.932 8.28444C11.8162 8.94342 11.557 9.55301 11.1878 10.0798L12.0453 11.4402L11.4392 12.0462L10.0787 11.1886C9.55192 11.5576 8.94241 11.8166 8.28355 11.9323L7.92809 13.5002H7.07095L6.71543 11.932C6.0569 11.8162 5.44772 11.5572 4.92116 11.1883L3.56055 12.046L2.95445 11.4399L3.81213 10.0794C3.4431 9.55266 3.18403 8.94326 3.06825 8.2845L1.50002 7.92897V7.07183L3.06818 6.71632C3.18388 6.05765 3.44283 5.44833 3.81171 4.92165L2.95398 3.561L3.56008 2.95491L4.92053 3.81251ZM9.02496 7.50008C9.02496 8.34226 8.34223 9.02499 7.50005 9.02499C6.65786 9.02499 5.97513 8.34226 5.97513 7.50008C5.97513 6.65789 6.65786 5.97516 7.50005 5.97516C8.34223 5.97516 9.02496 6.65789 9.02496 7.50008ZM9.92496 7.50008C9.92496 8.83932 8.83929 9.92499 7.50005 9.92499C6.1608 9.92499 5.07513 8.83932 5.07513 7.50008C5.07513 6.16084 6.1608 5.07516 7.50005 5.07516C8.83929 5.07516 9.92496 6.16084 9.92496 7.50008Z" 36 | 37 | triangle-right 38 | [:path {:d "M6 11L6 4L10.5 7.5L6 11Z" :fill "currentColor"}] 39 | 40 | archive 41 | [:path {:d "M3.30902 1C2.93025 1 2.58398 1.214 2.41459 1.55279L1.05279 4.27639C1.01807 4.34582 1 4.42238 1 4.5V13C1 13.5523 1.44772 14 2 14H13C13.5523 14 14 13.5523 14 13V4.5C14 4.42238 13.9819 4.34582 13.9472 4.27639L12.5854 1.55281C12.416 1.21403 12.0698 1.00003 11.691 1.00003L7.5 1.00001L3.30902 1ZM3.30902 2L7 2.00001V4H2.30902L3.30902 2ZM8 4V2.00002L11.691 2.00003L12.691 4H8ZM7.5 5H13V13H2V5H7.5ZM5.5 7C5.22386 7 5 7.22386 5 7.5C5 7.77614 5.22386 8 5.5 8H9.5C9.77614 8 10 7.77614 10 7.5C10 7.22386 9.77614 7 9.5 7H5.5Z", :fill "currentColor", :fill-rule "evenodd", :clip-rule "evenodd"}] 42 | 43 | caret-right 44 | [:path {:d "M6.18194 4.18185C6.35767 4.00611 6.6426 4.00611 6.81833 4.18185L9.81833 7.18185C9.90272 7.26624 9.95013 7.3807 9.95013 7.50005C9.95013 7.6194 9.90272 7.73386 9.81833 7.81825L6.81833 10.8182C6.6426 10.994 6.35767 10.994 6.18194 10.8182C6.0062 10.6425 6.0062 10.3576 6.18194 10.1819L8.86374 7.50005L6.18194 4.81825C6.0062 4.64251 6.0062 4.35759 6.18194 4.18185Z", :fill "currentColor", :fill-rule "evenodd", :clip-rule "evenodd"}] 45 | 46 | caret-down 47 | [:path {:d "M4.18179 6.18181C4.35753 6.00608 4.64245 6.00608 4.81819 6.18181L7.49999 8.86362L10.1818 6.18181C10.3575 6.00608 10.6424 6.00608 10.8182 6.18181C10.9939 6.35755 10.9939 6.64247 10.8182 6.81821L7.81819 9.81821C7.73379 9.9026 7.61934 9.95001 7.49999 9.95001C7.38064 9.95001 7.26618 9.9026 7.18179 9.81821L4.18179 6.81821C4.00605 6.64247 4.00605 6.35755 4.18179 6.18181Z", :fill "currentColor", :fill-rule "evenodd", :clip-rule "evenodd"}] 48 | 49 | pencil-2 50 | [:path {:d "M12.1464 1.14645C12.3417 0.951184 12.6583 0.951184 12.8535 1.14645L14.8535 3.14645C15.0488 3.34171 15.0488 3.65829 14.8535 3.85355L10.9109 7.79618C10.8349 7.87218 10.7471 7.93543 10.651 7.9835L6.72359 9.94721C6.53109 10.0435 6.29861 10.0057 6.14643 9.85355C5.99425 9.70137 5.95652 9.46889 6.05277 9.27639L8.01648 5.34897C8.06455 5.25283 8.1278 5.16507 8.2038 5.08907L12.1464 1.14645ZM12.5 2.20711L8.91091 5.79618L7.87266 7.87267L8.12731 8.12732L10.2038 7.08907L13.7929 3.5L12.5 2.20711ZM9.99998 2L8.99998 3H4.9C4.47171 3 4.18056 3.00039 3.95552 3.01877C3.73631 3.03668 3.62421 3.06915 3.54601 3.10899C3.35785 3.20487 3.20487 3.35785 3.10899 3.54601C3.06915 3.62421 3.03669 3.73631 3.01878 3.95552C3.00039 4.18056 3 4.47171 3 4.9V11.1C3 11.5283 3.00039 11.8194 3.01878 12.0445C3.03669 12.2637 3.06915 12.3758 3.10899 12.454C3.20487 12.6422 3.35785 12.7951 3.54601 12.891C3.62421 12.9309 3.73631 12.9633 3.95552 12.9812C4.18056 12.9996 4.47171 13 4.9 13H11.1C11.5283 13 11.8194 12.9996 12.0445 12.9812C12.2637 12.9633 12.3758 12.9309 12.454 12.891C12.6422 12.7951 12.7951 12.6422 12.891 12.454C12.9309 12.3758 12.9633 12.2637 12.9812 12.0445C12.9996 11.8194 13 11.5283 13 11.1V6.99998L14 5.99998V11.1V11.1207C14 11.5231 14 11.8553 13.9779 12.1259C13.9549 12.407 13.9057 12.6653 13.782 12.908C13.5903 13.2843 13.2843 13.5903 12.908 13.782C12.6653 13.9057 12.407 13.9549 12.1259 13.9779C11.8553 14 11.5231 14 11.1207 14H11.1H4.9H4.87934C4.47686 14 4.14468 14 3.87409 13.9779C3.59304 13.9549 3.33469 13.9057 3.09202 13.782C2.7157 13.5903 2.40973 13.2843 2.21799 12.908C2.09434 12.6653 2.04506 12.407 2.0221 12.1259C1.99999 11.8553 1.99999 11.5231 2 11.1207V11.1206V11.1V4.9V4.87935V4.87932V4.87931C1.99999 4.47685 1.99999 4.14468 2.0221 3.87409C2.04506 3.59304 2.09434 3.33469 2.21799 3.09202C2.40973 2.71569 2.7157 2.40973 3.09202 2.21799C3.33469 2.09434 3.59304 2.04506 3.87409 2.0221C4.14468 1.99999 4.47685 1.99999 4.87932 2H4.87935H4.9H9.99998Z", :fill "currentColor", :fill-rule "evenodd", :clip-rule "evenodd"}] 51 | ) 52 | -------------------------------------------------------------------------------- /src/repley/ui/chart.clj: -------------------------------------------------------------------------------- 1 | (ns repley.ui.chart 2 | (:require [ripley.html :as h] 3 | [ripley.live.source :as source] 4 | [ripley.live.collection :as collection] 5 | [ripley.template :as template])) 6 | 7 | 8 | (defn bar-chart 9 | "Simple top to bottom bar chart for showing relative 10 | counts of different items. 11 | 12 | Options: 13 | :width Width of the SVG image. Defaults to \"100%\". 14 | :bar-height Height of a single bar. Defaults to 30. 15 | The SVG height will be :bar-height * (count bars). 16 | 17 | :label-accessor 18 | Accessor to get the label for an item. 19 | Defaults to :label. 20 | :value-accessor 21 | Accessor to get the value for an item. 22 | Defaults to :value. 23 | 24 | Bars-source must be a source that provides a collection 25 | of bars, which are maps. 26 | Each item must be a map contain a value and a label obtained 27 | by calling value-accessor and label-accessor respectively. 28 | 29 | eg. 30 | (bar-chart 31 | {:width 600 :bar-height 20} 32 | [{:label \"Yes\" :value 420} 33 | {:label \"No\" :value 67} 34 | {:label \"Undecided\" :value 10}]) 35 | " 36 | [{:keys [width bar-height 37 | value-accessor label-accessor] 38 | :or {width "100%" 39 | bar-height 30 40 | value-accessor :value 41 | label-accessor :label}} 42 | bars-source] 43 | (let [max-source (source/computed 44 | #(reduce max 1 (map value-accessor %)) 45 | bars-source) 46 | top 30 47 | height (source/computed #(+ top (* bar-height (count %))) bars-source) 48 | viewbox (source/computed #(str "0 0 600 " (+ top (* bar-height (count %)))) bars-source) 49 | ;; Add indexes to our bars so that we can calculate y position 50 | bars-source (source/computed 51 | (fn [bars] 52 | (let [max (reduce max 1 (map value-accessor bars))] 53 | (into [] 54 | (map-indexed 55 | (fn [i item] 56 | (let [value (value-accessor item) 57 | label (label-accessor item) 58 | y (double (+ top (* bar-height (+ i 0.1)))) 59 | w (double (* 300 (/ value max))) 60 | value-and-label (str value " " label)] 61 | {:i i 62 | :y y 63 | :w w 64 | :text-y (+ y (/ bar-height 2)) 65 | :value-and-label value-and-label}))) 66 | bars))) 67 | bars-source) 68 | tick-source (fn [pct] 69 | (source/computed 70 | (fn [max height] 71 | {:value (Math/round (* pct max)) 72 | :max max 73 | :height height}) 74 | max-source height)) 75 | tick (fn [{:keys [value max height]}] 76 | (let [x (double (* 300 (/ value max)))] 77 | (h/html 78 | [:g 79 | [:text {:text-anchor "middle" 80 | :x x :y 15 81 | :font-size "0.5em"} 82 | value] 83 | [:line {:x1 x :x2 x 84 | :y1 25 :y2 height 85 | :stroke-dasharray "3 3" 86 | :stroke-width "3" 87 | :stroke "black"}]]))) 88 | id (gensym "barchart")] 89 | (h/html 90 | [:span 91 | (template/use-template 92 | (fn [{:keys [y w text-y value-and-label]}] 93 | (h/html 94 | [:g 95 | [:rect.text-primary {:y y 96 | :width w 97 | :height (* 0.8 bar-height)}] 98 | [:text.text-info {:x 310 99 | :y text-y} 100 | value-and-label]])) 101 | (str "#" id) 102 | bars-source) 103 | [:svg {:width width 104 | :viewBox [::h/live viewbox] 105 | :fill "currentColor" 106 | :preserveAspectRatio "xMinYMin"} 107 | [:g {:id id}] 108 | [:g.ticks 109 | ;; add 25%, 50% and 75% ticks 110 | [::h/live (tick-source 0.25) tick] 111 | [::h/live (tick-source 0.50) tick] 112 | [::h/live (tick-source 0.75) tick]]]]))) 113 | 114 | 115 | (defn- pie-items 116 | [{:keys [value-accessor label-accessor max-items other-label] 117 | :or {value-accessor :value 118 | label-accessor :label 119 | max-items 4 120 | other-label "Other"}} items] 121 | (let [items (reverse 122 | (sort-by second (mapv (juxt label-accessor value-accessor) items)))] 123 | (if (> (count items) (inc max-items)) 124 | ;; If too many items, summarize rest 125 | (conj (vec (take max-items items)) 126 | [other-label 127 | (reduce + (map second (drop max-items items)))]) 128 | 129 | ;; Return items as is 130 | items))) 131 | 132 | (defn pie 133 | "Render a pie chart. 134 | 135 | Options: 136 | :width Width in CSS (defaults to 100%) 137 | :height Height in CSS (defaults to 100%) 138 | :label-accessor 139 | Function to get label from item (defaults to :label) 140 | :value-accessor 141 | Function to get value from item (defaults to :value) 142 | :max-items How many items to render (defaults to 4). 143 | Shows top N items as own slices and summarizes 144 | extra items as the \"other\" slice. 145 | :other-label 146 | Label to show for items that are summed (defaults to \"Other\"). 147 | :colors Optional vector of colors to use. 148 | If there are less colors than max-items+1 149 | the same color will be repeated. 150 | :format-value 151 | Function to format value to show after legend. 152 | Defaults to (constantly \"\") (eg. not showing it) 153 | 154 | " 155 | [{:keys [width height colors format-value] :as config 156 | :or {width "100%" height "100%" 157 | colors ["#6050DC" "#D52DB7" "#FF2E7E" "#FF6B45" "#FFAB05"] 158 | format-value (constantly "")}} 159 | slices-source] 160 | (let [x (fn x 161 | ([ang] (x 1.0 ang)) 162 | ([r ang] (* r (Math/cos ang)))) 163 | y (fn y 164 | ([ang] (y 1.0 ang)) 165 | ([r ang] (* r (Math/sin ang)))) 166 | pos (fn pos 167 | ([ang] (pos 1.0 ang)) 168 | ([r ang] 169 | (str (x r ang) "," (y r ang)))) 170 | slices (source/computed 171 | (fn [items] 172 | (let [items (pie-items config items) 173 | sum (reduce + 0 (map second items)) 174 | rads #(- (* 2 Math/PI (/ % sum)) (/ Math/PI 2))] 175 | (loop [[s & items] items 176 | acc [] 177 | total 0 178 | [c & colors] (cycle colors)] 179 | (if-not s 180 | acc 181 | (let [[label value] s 182 | start (rads total) 183 | end (rads (+ total value)) 184 | large (if (< (- end start) Math/PI) "0" "1") 185 | half-ang (+ start (/ (- end start) 2)) 186 | tx (x 0.7 half-ang) 187 | ty (y 0.7 half-ang)] 188 | (recur items 189 | (conj acc {:value (format-value value) 190 | :label label 191 | :start start 192 | :end end 193 | :d (str "M " (pos start) 194 | " A 1 1 0 " large " 1 " 195 | (pos end) 196 | " L 0,0") 197 | :color c 198 | :text-x tx :text-y ty 199 | :percentage (format "%.1f%%" 200 | (* 100.0 (/ value sum))) 201 | :legend-style (str "background-color:" c ";")}) 202 | (+ total value) 203 | colors)))))) 204 | slices-source) 205 | slices-id (gensym "pie") 206 | legend-id (gensym "lg")] 207 | 208 | ;; Render SVG pie slices using template 209 | (template/use-template 210 | (fn [{:keys [d color text-x text-y percentage]}] 211 | (h/html 212 | [:g.slice 213 | [:path {:d d 214 | :fill color 215 | :stroke "black" 216 | :stroke-width 0.01}] 217 | [:text {:x text-x :y text-y 218 | :text-anchor "middle" 219 | :alignment-baseline "middle" 220 | :font-size "0.1"} percentage]])) 221 | (str "#" slices-id) 222 | slices) 223 | 224 | ;; Render legend using template 225 | (template/use-template 226 | (fn [{:keys [legend-style label value]}] 227 | (h/html 228 | [:div.whitespace-nowrap 229 | [:div.inline-block.w-4.h-4 {:style legend-style}] 230 | [:span.mx-2 label " " value]])) 231 | (str "#" legend-id) 232 | slices) 233 | 234 | (h/html 235 | [:div.flex.items-center 236 | [:svg {:viewBox "-1.1 -1.1 2.2 2.2"} 237 | [:g.pie.text-primary {:id slices-id}]] 238 | [:div.flex.flex-col {:id legend-id}]]))) 239 | -------------------------------------------------------------------------------- /src/repley/main.clj: -------------------------------------------------------------------------------- 1 | (ns repley.main 2 | "Main start namespace for REPLey web." 3 | (:require [ripley.html :as h] 4 | [ripley.live.context :as context] 5 | [org.httpkit.server :as server] 6 | [clojure.string :as str] 7 | [clojure.java.io :as io] 8 | [repley.protocols :as p] 9 | [ripley.live.collection :as collection] 10 | [ripley.live.source :as source] 11 | [ripley.js :as js] 12 | [repley.visualizers :as visualizers] 13 | [repley.config :as config] 14 | [repley.ui.icon :as icon] 15 | [clojure.datafy :as df] 16 | [repley.repl :as repl] 17 | [clojure.edn :as edn] 18 | [compliment.core :as compliment] 19 | [repley.browser :as browser])) 20 | 21 | (defn listen-to-tap> 22 | "Install Clojure tap> listener. All values sent via tap> are 23 | added as results to the REPL output. 24 | 25 | Returns a 0 argument function that will remote the tap listener 26 | when called." 27 | [] 28 | (repl/enable-tap-listener!)) 29 | 30 | (defn- display 31 | "Prepare value for display." 32 | [value] 33 | (cond 34 | (satisfies? p/DefaultVisualizer value) 35 | value 36 | 37 | (instance? Throwable value) 38 | value 39 | 40 | ;; We don't want atoms to be datafied, we may want 41 | ;; to use them as Ripley sources (so UI autoupdates 42 | ;; if it changes) 43 | (instance? clojure.lang.IDeref value) 44 | value 45 | 46 | :else 47 | (df/datafy value))) 48 | 49 | (defn select-visualizer [vs] 50 | (:v (reduce (fn [{:keys [v p] :as acc} vis] 51 | (let [prec (p/precedence vis)] 52 | (if (or (nil? p) (> prec p)) 53 | {:v vis :p prec} 54 | acc))) 55 | {} vs))) 56 | 57 | (defonce selected-visualizer (atom {})) 58 | 59 | (defn evaluation 60 | "Ripley component that renders one evaluated result" 61 | [{:keys [timestamp-format] :as _opts} visualizers {:keys [id code-str ns result breadcrumbs timestamp duration]}] 62 | (let [value (display result) 63 | [value all-visualizers] 64 | (if (satisfies? p/DefaultVisualizer value) 65 | (let [def-vis (p/default-visualizer value)] 66 | [(p/object value) 67 | (conj visualizers def-vis)]) 68 | [value visualizers]) 69 | supported-visualizers (filter #(p/supports? % value) all-visualizers) 70 | tabs (into [[:code "Code"]] 71 | (for [v supported-visualizers] 72 | [v (p/label v)])) 73 | ;; If there is an existing visualizer selected for this 74 | ;; value and it still supports it, use that... otherwise 75 | ;; select visualizer that supports the data 76 | _ (swap! selected-visualizer update id 77 | (fn [old-visualizer] 78 | (if (and old-visualizer (or (keyword? old-visualizer) 79 | (p/supports? old-visualizer value))) 80 | old-visualizer 81 | (select-visualizer supported-visualizers)))) 82 | tab-source (source/computed #(get % id) selected-visualizer) 83 | set-tab! #(swap! selected-visualizer assoc id %) 84 | remove-result! #(do 85 | (repl/remove-result! id) 86 | (swap! selected-visualizer dissoc id))] 87 | (h/html 88 | [:div.evaluation 89 | [:div.actions.mr-2 {:style "float: right;"} 90 | [:button.btn.btn-outline.btn-xs.m-1.remove {:on-click remove-result!} 91 | (icon/trashcan)] 92 | [:button.btn.btn-outline.btn-xs.m-1.retry {:on-click #(repl/retry! id)} 93 | (icon/reload)]] 94 | [::h/live tab-source 95 | (fn [tab] 96 | (h/html 97 | [:div 98 | [::h/when timestamp-format 99 | [:div.badge.badge-ghost.badge-xs 100 | (h/dyn! (.format (java.text.SimpleDateFormat. timestamp-format) 101 | timestamp)) 102 | (when duration (h/dyn! " (took " duration " ms)"))]] 103 | [::h/when (seq breadcrumbs) 104 | [:div.text-sm.breadcrumbs.ml-4 105 | [:ul 106 | [::h/for [{:keys [label value n]} breadcrumbs] 107 | [:li [:a {:on-click (format "_crumb(%d,%d)" id n)} 108 | [::h/if (= label :root) 109 | (icon/home) 110 | label]]]]]]] 111 | [:div.tabs.tabs-lifted.tabs-sm.block {:role "tablist"} 112 | [::h/for [[tab-name tab-label] tabs 113 | :let [cls (when (= tab-name tab) "tab-active")]] 114 | [:a.tab.tab-xs {:class cls 115 | :on-click #(set-tab! tab-name)} tab-label]]] 116 | [:div.card.ml-4 117 | (if (= tab :code) 118 | (h/html 119 | [:div 120 | [:div.badge.badge-primary.badge-xs (h/out! (str ns))] 121 | [:pre [:code code-str]]]) 122 | ;; Tab is the visualizer impl, call it 123 | (binding [repl/*result-id* id] 124 | (p/render tab value)))]]))] 125 | [:div.divider]]))) 126 | 127 | (defn- complete 128 | "Get completions for prefix and output them in format suitable for Ace9 editor." 129 | [prefix] 130 | (for [{:keys [candidate type]} (compliment/completions prefix 131 | {:ns (repl/current-repl-ns)})] 132 | {:name candidate 133 | :value candidate 134 | :score 100 135 | :meta (name type)})) 136 | 137 | (defn- repl-page [opts visualizers prefix] 138 | (h/out! "\n") 139 | (let [css (str prefix "/repley.css")] 140 | (h/html 141 | [:html {:data-theme "garden"} 142 | [:head 143 | [:meta {:charset "UTF-8"}] 144 | [:link {:rel "stylesheet" :href css}] 145 | [:script {:src "https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.1/ace.min.js"}] 146 | [:script {:src "https://cdnjs.cloudflare.com/ajax/libs/ace/1.23.1/ext-language_tools.min.js"}] 147 | (doseq [v visualizers 148 | :let [{:keys [js css]} (p/assets v)]] 149 | (doseq [script js] 150 | (if (map? script) 151 | (let [{:keys [src integrity crossorigin]} script] 152 | (h/html [:script {:src src 153 | :integrity integrity 154 | :crossorigin crossorigin}])) 155 | (h/html [:script {:src script}]))) 156 | (doseq [style css] 157 | (if (map? style) 158 | (let [{:keys [href integrity crossorigin]} style] 159 | (h/html [:link {:rel "stylesheet" 160 | :href href 161 | :integrity integrity 162 | :crossorigin crossorigin}])) 163 | (h/html [:link {:rel "stylesheet" :href style}])))) 164 | (js/export-callbacks 165 | {:_eval repl/eval-input! 166 | :_crumb repl/nav-to-crumb! 167 | :_nav (fn [id k] 168 | (repl/nav! id (read-string k))) 169 | :_complete (-> (js/js complete) 170 | (js/on-success "cs=>_COMPLETIONS(cs)"))}) 171 | [:script 172 | "function initREPL() {" 173 | "let lt = ace.require('ace/ext/language_tools');" 174 | "lt.addCompleter({getCompletions: function(editor, session, pos, prefix, callback) {" 175 | " if (prefix.length === 0) { callback(null, []); } " 176 | " else { window._COMPLETIONS= cs => callback(null, cs); _complete(prefix); }" 177 | "}," 178 | ;; FIXME: is this enough? 179 | ;; colon, dot, word, digit, slash, dash, underscore, dollar, question mark, asterisk 180 | " identifierRegexps: [ /[\\:\\.\\w\\d\\/\\-\\_\\$\\?\\*]+/ ]" 181 | "});" 182 | "let editor = ace.edit('repl'); " 183 | "editor.commands.addCommand({" 184 | " name: 'eval'," 185 | " bindKey: {" 186 | " win: 'Ctrl-Enter'," 187 | " mac: 'Command-Enter'" 188 | " }," 189 | " exec: function(editor) {" 190 | " _eval(editor.session.getValue());" 191 | " }," 192 | " readOnly: true});" 193 | "editor.session.setMode('ace/mode/clojure');" 194 | "editor.setTheme('ace/theme/tomorrow');" 195 | "editor.setOptions({" 196 | " enableBasicAutocompletion: true," 197 | " enableSnippets: true," 198 | " enableLiveAutocompletion: true, " 199 | " liveAutocompletionDelay: 300, " 200 | " liveAutocompletionThreshold: 3" 201 | "});" 202 | "window._E = editor;" 203 | 204 | ;; Add mutation observer to scroll new evaluations into view 205 | ;; (not changed ones) 206 | "let mo = new MutationObserver(ms => { ms.forEach(m => { " 207 | " let ids = new Set(); " 208 | " m.removedNodes.forEach(n => ids.add(n.getAttribute('data-rl'))); " 209 | " m.addedNodes.forEach(n => { if(!ids.has(n.getAttribute('data-rl'))) n.scrollIntoView(false) });})});" 210 | "mo.observe(document.querySelector('span.repl-output'), {childList: true});" 211 | " }"] 212 | (h/live-client-script (str prefix "/_ws"))] 213 | [:body {:on-load "initREPL()"} 214 | [:div 215 | [:div.navbar.bg-base-100 216 | [:div.flex-1 "REPLey" 217 | (when-let [navbar (:navbar opts)] 218 | (navbar))] 219 | [:div.flex-none 220 | [:details.dropdown.dropdown-end.ns-browser 221 | [:summary.m-1.btn.btn-xs (icon/archive)] 222 | [:div.dropdown-content.ns-browser.z-10.bg-slate-100 223 | (browser/browser)]] 224 | [:details.dropdown.dropdown-end.options 225 | [:summary.m-1.btn.btn-xs (icon/gear)] 226 | [:ul {:class "z-[1] p-1 shadow menu menu-sm dropdown-content bg-base-100 rounded-box w-48"} 227 | [:li.m-2 [:button.btn.btn-warning.btn-xs.text-xs.clear-results 228 | {:on-click repl/clear!} 229 | (icon/trashcan) "clear results"]] 230 | [:li [::h/live (repl/field-source :tap-listener?) 231 | (fn [tl] 232 | (h/html 233 | [:div.form-control 234 | [:label.label.cursor-pointer 235 | [:input.checkbox.tap-listener 236 | {:type :checkbox :checked tl 237 | :onchange repl/toggle-tap-listener!}] 238 | [:span.mx-2.label-text "listen to tap>"]]]))]]]]]] 239 | [:div.flex.flex-col 240 | [:div {:style "height: 80vh; overflow-y: auto;"} 241 | (collection/live-collection 242 | {:source (source/computed :results repl/repl-data) 243 | :render (partial evaluation opts visualizers) 244 | :key :id 245 | :container-element :span.repl-output}) 246 | 247 | ;; Add filler element so we always have scroll 248 | ;; and navigating doesn't make results jump around. 249 | [:div {:style "height: 0vh;"}]] 250 | 251 | [:div.m-2.border {:style "height: 15vh;"} 252 | [:pre#repl.w-full.h-full ""]]]]]]))) 253 | 254 | (defn repley-handler 255 | "Return a Reply ring handler. 256 | 257 | See `#'repley.config/default-config` for a description of 258 | all the configuration options available." 259 | [config] 260 | (let [{:keys [prefix receive-endpoint edn-readers] :as opts} (config/config config) 261 | ws-handler (context/connection-handler (str prefix "/_ws")) 262 | c (count prefix) 263 | ->path (fn [uri] (subs uri c)) 264 | visualizers (visualizers/default-visualizers opts) 265 | visualizer-handlers (apply some-fn (for [v visualizers 266 | :let [handler (p/ring-handler v)] 267 | :when handler] handler))] 268 | (fn [{uri :uri :as req}] 269 | (when (str/starts-with? uri prefix) 270 | (condp = (->path uri) 271 | "/_ws" (ws-handler req) 272 | 273 | "/repley.css" 274 | {:status 200 :headers {"Content-Type" "text/css"} 275 | :body (slurp (io/resource "public/repley.css"))} 276 | 277 | "/" 278 | (h/render-response #(repl-page opts visualizers prefix)) 279 | 280 | receive-endpoint 281 | (when (= :post (:request-method req)) 282 | (binding [*read-eval* false] 283 | (repl/add-result! {:timestamp (java.util.Date.) 284 | :code-str ";; received via HTTP" 285 | :result (edn/read-string {:readers edn-readers} 286 | (slurp (:body req)))}) 287 | {:status 204})) 288 | 289 | ;; Try visualizer handlers 290 | (visualizer-handlers req)))))) 291 | 292 | (def ^{:private true :doc "The currently running server"} server nil) 293 | 294 | (defn start 295 | "Start REPLey HTTP server. 296 | Any options under `:http` will be passed to http-kit. 297 | See [[repley-handler]] for other options." 298 | ([] (start {:http {:port 3001}})) 299 | ([opts] 300 | (alter-var-root 301 | #'server 302 | (fn [old-server] 303 | (when old-server 304 | (old-server)) 305 | (server/run-server (repley-handler opts) (:http opts)))))) 306 | -------------------------------------------------------------------------------- /resources/public/repley.css: -------------------------------------------------------------------------------- 1 | /*! tailwindcss v3.3.2 | MIT License | https://tailwindcss.com*/*,:after,:before{box-sizing:border-box;border:0 solid #e5e7eb}:after,:before{--tw-content:""}html{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial,Noto Sans,sans-serif,Apple Color Emoji,Segoe UI Emoji,Segoe UI Symbol,Noto Color Emoji;font-feature-settings:normal;font-variation-settings:normal}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,pre,samp{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:initial}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-size:100%;font-weight:inherit;line-height:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}[type=button],[type=reset],[type=submit],button{-webkit-appearance:button;background-color:initial;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:initial}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dd,dl,figure,h1,h2,h3,h4,h5,h6,hr,p,pre{margin:0}fieldset{margin:0}fieldset,legend{padding:0}menu,ol,ul{list-style:none;margin:0;padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}[role=button],button{cursor:pointer}:disabled{cursor:default}audio,canvas,embed,iframe,img,object,svg,video{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]{display:none}:root,[data-theme]{background-color:var(--fallback-b1,oklch(var(--b1)/1));color:var(--fallback-bc,oklch(var(--bc)/1))}@supports not (color:oklch(0% 0 0)){:root{color-scheme:light;--fallback-p:#491eff;--fallback-pc:#d4dbff;--fallback-s:#ff41c7;--fallback-sc:#fff9fc;--fallback-a:#00cfbd;--fallback-ac:#00100d;--fallback-n:#2b3440;--fallback-nc:#d7dde4;--fallback-b1:#fff;--fallback-b2:#e5e6e6;--fallback-b3:#e5e6e6;--fallback-bc:#1f2937;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--fallback-p:#7582ff;--fallback-pc:#050617;--fallback-s:#ff71cf;--fallback-sc:#190211;--fallback-a:#00c7b5;--fallback-ac:#000e0c;--fallback-n:#2a323c;--fallback-nc:#a6adbb;--fallback-b1:#1d232a;--fallback-b2:#191e24;--fallback-b3:#15191e;--fallback-bc:#a6adbb;--fallback-in:#00b3f0;--fallback-inc:#000;--fallback-su:#00ca92;--fallback-suc:#000;--fallback-wa:#ffc22d;--fallback-wac:#000;--fallback-er:#ff6f70;--fallback-erc:#000}}}html{-webkit-tap-highlight-color:transparent}*{scrollbar-color:color-mix(in oklch,currentColor 35%,#0000) #0000}:hover{scrollbar-color:color-mix(in oklch,currentColor 60%,#0000) #0000}:root{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}@media (prefers-color-scheme:dark){:root{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}}[data-theme=light]{color-scheme:light;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:89.824% 0.06192 275.75;--ac:15.352% 0.0368 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:49.12% 0.3096 275.75;--s:69.71% 0.329 342.55;--sc:98.71% 0.0106 342.55;--a:76.76% 0.184 183.61;--n:32.1785% 0.02476 255.701624;--nc:89.4994% 0.011585 252.096176;--b1:100% 0 0;--b2:96.1151% 0 0;--b3:92.4169% 0.00108 197.137559;--bc:27.8078% 0.029596 256.847952}[data-theme=dark]{color-scheme:dark;--in:72.06% 0.191 231.6;--su:64.8% 0.150 160;--wa:84.71% 0.199 83.87;--er:71.76% 0.221 22.18;--pc:13.138% 0.0392 275.75;--sc:14.96% 0.052 342.55;--ac:14.902% 0.0334 183.61;--inc:0% 0 0;--suc:0% 0 0;--wac:0% 0 0;--erc:0% 0 0;--rounded-box:1rem;--rounded-btn:0.5rem;--rounded-badge:1.9rem;--animation-btn:0.25s;--animation-input:.2s;--btn-focus-scale:0.95;--border-btn:1px;--tab-border:1px;--tab-radius:0.5rem;--p:65.69% 0.196 275.75;--s:74.8% 0.26 342.55;--a:74.51% 0.167 183.61;--n:31.3815% 0.021108 254.139175;--nc:74.6477% 0.0216 264.435964;--b1:25.3267% 0.015896 252.417568;--b2:23.2607% 0.013807 253.100675;--b3:21.1484% 0.01165 254.087939;--bc:74.6477% 0.0216 264.435964}*,::backdrop,:after,:before{--tw-border-spacing-x:0;--tw-border-spacing-y:0;--tw-translate-x:0;--tw-translate-y:0;--tw-rotate:0;--tw-skew-x:0;--tw-skew-y:0;--tw-scale-x:1;--tw-scale-y:1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness:proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-color:#3b82f680;--tw-ring-offset-shadow:0 0 #0000;--tw-ring-shadow:0 0 #0000;--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: }.avatar.placeholder>div{display:flex;align-items:center;justify-content:center}.badge{display:inline-flex;align-items:center;justify-content:center;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;height:1.25rem;font-size:.875rem;line-height:1.25rem;width:-moz-fit-content;width:fit-content;padding-left:.563rem;padding-right:.563rem;border-radius:var(--rounded-badge,1.9rem);border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.breadcrumbs{max-width:100%;overflow-x:auto;padding-top:.5rem;padding-bottom:.5rem}.breadcrumbs>ol,.breadcrumbs>ul{display:flex;align-items:center;white-space:nowrap;min-height:-moz-min-content;min-height:min-content}.breadcrumbs>ol>li,.breadcrumbs>ul>li{display:flex;align-items:center}.breadcrumbs>ol>li>a,.breadcrumbs>ul>li>a{display:flex;cursor:pointer;align-items:center}@media (hover:hover){.breadcrumbs>ol>li>a:hover,.breadcrumbs>ul>li>a:hover{text-decoration-line:underline}.label a:hover{--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.tab:hover{--tw-text-opacity:1}.tabs-boxed :is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):hover,.tabs-boxed :is(input:checked):hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table tr.hover:hover,.table tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.hover:hover,.table-zebra tr.hover:nth-child(2n):hover{--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}}.btn{display:inline-flex;height:3rem;min-height:3rem;flex-shrink:0;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;flex-wrap:wrap;align-items:center;justify-content:center;border-radius:var(--rounded-btn,.5rem);border-color:#0000;border-color:oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity));padding-left:1rem;padding-right:1rem;text-align:center;font-size:.875rem;line-height:1em;gap:.5rem;font-weight:600;text-decoration-line:none;transition-duration:.2s;transition-timing-function:cubic-bezier(0,0,.2,1);border-width:var(--border-btn,1px);transition-property:color,background-color,border-color,opacity,box-shadow,transform;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 1px 2px 0 #0000000d;--tw-shadow-colored:0 1px 2px 0 var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:var(--fallback-bc,oklch(var(--bc)/1));background-color:oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity));--tw-bg-opacity:1;--tw-border-opacity:1}.btn-disabled,.btn:disabled,.btn[disabled]{pointer-events:none}:where(.btn:is(input[type=checkbox])),:where(.btn:is(input[type=radio])){width:auto;-webkit-appearance:none;-moz-appearance:none;appearance:none}.btn:is(input[type=checkbox]):after,.btn:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.card{position:relative;display:flex;flex-direction:column;border-radius:var(--rounded-box,1rem)}.card:focus{outline:2px solid #0000;outline-offset:2px}.card figure{display:flex;align-items:center;justify-content:center}.card.image-full{display:grid}.card.image-full:before{position:relative;content:"";z-index:10;border-radius:var(--rounded-box,1rem);--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));opacity:.75}.card.image-full:before,.card.image-full>*{grid-column-start:1;grid-row-start:1}.card.image-full>figure img{height:100%;-o-object-fit:cover;object-fit:cover}.card.image-full>.card-body{position:relative;z-index:20;--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.checkbox{flex-shrink:0;--chkbg:var(--fallback-bc,oklch(var(--bc)/1));--chkfg:var(--fallback-b1,oklch(var(--b1)/1));height:1.5rem;width:1.5rem;cursor:pointer;-webkit-appearance:none;-moz-appearance:none;appearance:none;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2}.divider{display:flex;flex-direction:row;align-items:center;align-self:stretch;margin-top:1rem;margin-bottom:1rem;height:1rem;white-space:nowrap}.divider:after,.divider:before{height:.125rem;width:100%;flex-grow:1;--tw-content:"";content:var(--tw-content);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.dropdown{position:relative;display:inline-block}.dropdown>:not(summary):focus{outline:2px solid #0000;outline-offset:2px}.dropdown .dropdown-content{position:absolute}.dropdown:is(:not(details)) .dropdown-content{visibility:hidden;opacity:0;transform-origin:top;--tw-scale-x:.95;--tw-scale-y:.95;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s}.dropdown-end .dropdown-content{inset-inline-end:0}.dropdown-left .dropdown-content{bottom:auto;inset-inline-end:100%;top:0;transform-origin:right}.dropdown-right .dropdown-content{bottom:auto;inset-inline-start:100%;top:0;transform-origin:left}.dropdown-bottom .dropdown-content{bottom:auto;top:100%;transform-origin:top}.dropdown-top .dropdown-content{bottom:100%;top:auto;transform-origin:bottom}.dropdown-end.dropdown-left .dropdown-content,.dropdown-end.dropdown-right .dropdown-content{bottom:0;top:auto}.dropdown.dropdown-open .dropdown-content,.dropdown:focus-within .dropdown-content,.dropdown:not(.dropdown-hover):focus .dropdown-content{visibility:visible;opacity:1}@media (hover:hover){.dropdown.dropdown-hover:hover .dropdown-content{visibility:visible;opacity:1}.btn:hover{--tw-border-opacity:1;border-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn:hover{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b2))/var(--tw-border-opacity,1)) 90%,#000)}}@supports not (color:oklch(0% 0 0)){.btn:hover{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}}.btn.glass:hover{--glass-opacity:25%;--glass-border-opacity:15%}.btn-outline:hover{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary:hover{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-primary:hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.btn-outline.btn-secondary:hover{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-secondary:hover{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}}.btn-outline.btn-accent:hover{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-accent:hover{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}}.btn-outline.btn-success:hover{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-success:hover{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}}.btn-outline.btn-info:hover{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-info:hover{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}}.btn-outline.btn-warning:hover{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-warning:hover{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}}.btn-outline.btn-error:hover{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}@supports (color:color-mix(in oklab,black,black)){.btn-outline.btn-error:hover{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn-disabled:hover,.btn:disabled:hover,.btn[disabled]:hover{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}@supports (color:color-mix(in oklab,black,black)){.btn:is(input[type=checkbox]:checked):hover,.btn:is(input[type=radio]:checked):hover{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}}.dropdown.dropdown-hover:hover .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{cursor:pointer;outline:2px solid #0000;outline-offset:2px}@supports (color:oklch(0% 0 0)){:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(.active,.btn):hover,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(.active,.btn):hover{background-color:var(--fallback-bc,oklch(var(--bc)/.1))}}.tab[disabled],.tab[disabled]:hover{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}}.dropdown:is(details) summary::-webkit-details-marker{display:none}.form-control{flex-direction:column}.form-control,.label{display:flex}.label{-webkit-user-select:none;-moz-user-select:none;user-select:none;align-items:center;justify-content:space-between;padding:.5rem .25rem}.input{flex-shrink:1;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;padding-left:1rem;padding-right:1rem;font-size:1rem;line-height:2;line-height:1.5rem;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:#0000;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.input-md[type=number]::-webkit-inner-spin-button,.input[type=number]::-webkit-inner-spin-button{margin-top:-1rem;margin-bottom:-1rem;-webkit-margin-end:-1rem;margin-inline-end:-1rem}.join{display:inline-flex;align-items:stretch;border-radius:var(--rounded-btn,.5rem)}.join :where(.join-item){border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:not(:first-child):not(:last-child),.join :not(:first-child):not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0;border-end-start-radius:0;border-start-start-radius:0}.join .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .join-item{border-start-end-radius:0;border-end-end-radius:0}.join .dropdown .join-item:first-child:not(:last-child),.join :first-child:not(:last-child) .dropdown .join-item{border-start-end-radius:inherit;border-end-end-radius:inherit}.join :where(.join-item:first-child:not(:last-child)),.join :where(:first-child:not(:last-child) .join-item){border-end-start-radius:inherit;border-start-start-radius:inherit}.join .join-item:last-child:not(:first-child),.join :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0}.join :where(.join-item:last-child:not(:first-child)),.join :where(:last-child:not(:first-child) .join-item){border-start-end-radius:inherit;border-end-end-radius:inherit}@supports not selector(:has(*)){:where(.join *){border-radius:inherit}}@supports selector(:has(*)){:where(.join :has(.join-item)){border-radius:inherit}}.link{cursor:pointer;text-decoration-line:underline}.menu{display:flex;flex-direction:column;flex-wrap:wrap;font-size:.875rem;line-height:1.25rem;padding:.5rem}.menu :where(li ul){position:relative;white-space:nowrap;-webkit-margin-start:1rem;margin-inline-start:1rem;-webkit-padding-start:.5rem;padding-inline-start:.5rem}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){display:grid;grid-auto-flow:column;align-content:flex-start;align-items:center;gap:.5rem;grid-auto-columns:minmax(auto,max-content) auto max-content;-webkit-user-select:none;-moz-user-select:none;user-select:none}.menu li.disabled{cursor:not-allowed;-webkit-user-select:none;-moz-user-select:none;user-select:none;color:var(--fallback-bc,oklch(var(--bc)/.3))}.menu :where(li>.menu-dropdown:not(.menu-dropdown-show)){display:none}:where(.menu li){position:relative;display:flex;flex-shrink:0;flex-direction:column;flex-wrap:wrap;align-items:stretch}:where(.menu li) .badge{justify-self:end}.navbar{display:flex;align-items:center;padding:var(--navbar-padding,.5rem);min-height:4rem;width:100%}:where(.navbar>:not(script,style)){display:inline-flex;align-items:center}.range{height:1.5rem;width:100%;cursor:pointer;-moz-appearance:none;appearance:none;-webkit-appearance:none;--range-shdw:var(--fallback-bc,oklch(var(--bc)/1));overflow:hidden;border-radius:var(--rounded-box,1rem);background-color:initial}.range:focus{outline:none}.select{display:inline-flex;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;height:3rem;min-height:3rem;-webkit-padding-start:1rem;padding-inline-start:1rem;-webkit-padding-end:2.5rem;padding-inline-end:2.5rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:#0000;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));background-image:linear-gradient(45deg,#0000 50%,currentColor 0),linear-gradient(135deg,currentColor 50%,#0000 0);background-position:calc(100% - 20px) calc(1px + 50%),calc(100% - 16.1px) calc(1px + 50%);background-size:4px 4px,4px 4px;background-repeat:no-repeat}.select[multiple]{height:auto}.stack{display:inline-grid;place-items:center;align-items:flex-end}.stack>*{grid-column-start:1;grid-row-start:1;transform:translateY(10%) scale(.9);z-index:1;width:100%;opacity:.6}.stack>:nth-child(2){transform:translateY(5%) scale(.95);z-index:2;opacity:.8}.stack>:first-child{transform:translateY(0) scale(1);z-index:3;opacity:1}.swap{position:relative;display:inline-grid;-webkit-user-select:none;-moz-user-select:none;user-select:none;place-content:center;cursor:pointer}.swap>*{grid-column-start:1;grid-row-start:1;transition-duration:.3s;transition-timing-function:cubic-bezier(0,0,.2,1);transition-property:transform,opacity}.swap input{-webkit-appearance:none;-moz-appearance:none;appearance:none}.swap .swap-indeterminate,.swap .swap-on,.swap input:checked~.swap-off,.swap input:indeterminate~.swap-off,.swap input:indeterminate~.swap-on,.swap-active .swap-off{opacity:0}.swap input:checked~.swap-on,.swap input:indeterminate~.swap-indeterminate,.swap-active .swap-on{opacity:1}.tabs{display:grid;align-items:flex-end}.tabs-lifted:has(.tab-content[class*=" rounded-"]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])),.tabs-lifted:has(.tab-content[class^=rounded-]) .tab:first-child:not(:is(.tab-active,[aria-selected=true])){border-bottom-color:#0000}.tab{position:relative;grid-row-start:1;display:inline-flex;height:2rem;cursor:pointer;-webkit-user-select:none;-moz-user-select:none;user-select:none;-webkit-appearance:none;-moz-appearance:none;appearance:none;flex-wrap:wrap;align-items:center;justify-content:center;text-align:center;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem;--tw-text-opacity:0.5;--tab-color:var(--fallback-bc,oklch(var(--bc)/1));--tab-bg:var(--fallback-b1,oklch(var(--b1)/1));--tab-border-color:var(--fallback-b3,oklch(var(--b3)/1));color:var(--tab-color);-webkit-padding-start:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);-webkit-padding-end:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem)}.tab:is(input[type=radio]){width:auto;border-bottom-right-radius:0;border-bottom-left-radius:0}.tab:is(input[type=radio]):after{--tw-content:attr(aria-label);content:var(--tw-content)}.tab:not(input):empty{cursor:default;grid-column-start:span 9999}.tab-content{grid-column-start:1;grid-column-end:span 9999;grid-row-start:2;margin-top:calc(var(--tab-border)*-1);display:none;border-color:#0000;border-width:var(--tab-border,0)}:checked+.tab-content:nth-child(2),:is(.tab-active,[aria-selected=true])+.tab-content:nth-child(2){border-start-start-radius:0}:is(.tab-active,[aria-selected=true])+.tab-content,input.tab:checked+.tab-content{display:block}.table{position:relative;width:100%;border-radius:var(--rounded-box,1rem);text-align:left;font-size:.875rem;line-height:1.25rem}.table :where(.table-pin-rows thead tr){position:sticky;top:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-rows tfoot tr){position:sticky;bottom:0;z-index:1;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table :where(.table-pin-cols tr th){position:sticky;left:0;right:0;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.table-zebra tbody tr:nth-child(2n) :where(.table-pin-cols tr th){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.textarea{min-height:3rem;flex-shrink:1;padding:.5rem 1rem;font-size:.875rem;line-height:1.25rem;line-height:2;border-radius:var(--rounded-btn,.5rem);border-width:1px;border-color:#0000;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.badge-neutral{border-color:var(--fallback-n,oklch(var(--n)/var(--tw-border-opacity)));background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.badge-neutral,.badge-primary{--tw-border-opacity:1;--tw-bg-opacity:1;--tw-text-opacity:1}.badge-primary{border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.badge-ghost{--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.badge-outline.badge-neutral{--tw-text-opacity:1;color:var(--fallback-n,oklch(var(--n)/var(--tw-text-opacity)))}.badge-outline.badge-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btm-nav>* .label{font-size:1rem;line-height:1.5rem}.breadcrumbs>ol>li>a:focus,.breadcrumbs>ul>li>a:focus{outline:2px solid #0000;outline-offset:2px}.breadcrumbs>ol>li>a:focus-visible,.breadcrumbs>ul>li>a:focus-visible{outline:2px solid currentColor;outline-offset:2px}.breadcrumbs>ol>li+:before,.breadcrumbs>ul>li+:before{content:"";margin-left:.5rem;margin-right:.75rem;display:block;height:.375rem;width:.375rem;--tw-rotate:45deg;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));opacity:.4;border-top:1px solid;border-right:1px solid;background-color:initial}[dir=rtl] .breadcrumbs>ol>li+:before,[dir=rtl] .breadcrumbs>ul>li+:before{--tw-rotate:-135deg}@media (prefers-reduced-motion:no-preference){.btn{animation:button-pop var(--animation-btn,.25s) ease-out}}.btn:active:focus,.btn:active:hover{animation:button-pop 0s ease-out;transform:scale(var(--btn-focus-scale,.97))}@supports not (color:oklch(0% 0 0)){.btn{background-color:var(--btn-color,var(--fallback-b2));border-color:var(--btn-color,var(--fallback-b2))}.btn-accent{--btn-color:var(--fallback-a)}.btn-warning{--btn-color:var(--fallback-wa)}}@supports (color:color-mix(in oklab,black,black)){.btn-active{background-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-bg-opacity,1)) 90%,#000);border-color:color-mix(in oklab,oklch(var(--btn-color,var(--b3))/var(--tw-border-opacity,1)) 90%,#000)}.btn-outline.btn-primary.btn-active{background-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-p,oklch(var(--p)/1)) 90%,#000)}.btn-outline.btn-secondary.btn-active{background-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-s,oklch(var(--s)/1)) 90%,#000)}.btn-outline.btn-accent.btn-active{background-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-a,oklch(var(--a)/1)) 90%,#000)}.btn-outline.btn-success.btn-active{background-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-su,oklch(var(--su)/1)) 90%,#000)}.btn-outline.btn-info.btn-active{background-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-in,oklch(var(--in)/1)) 90%,#000)}.btn-outline.btn-warning.btn-active{background-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-wa,oklch(var(--wa)/1)) 90%,#000)}.btn-outline.btn-error.btn-active{background-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000);border-color:color-mix(in oklab,var(--fallback-er,oklch(var(--er)/1)) 90%,#000)}}.btn:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px}@supports (color:oklch(0% 0 0)){.btn-accent{--btn-color:var(--a)}.btn-warning{--btn-color:var(--wa)}}.btn-accent{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)));outline-color:var(--fallback-a,oklch(var(--a)/1))}.btn-warning{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)));outline-color:var(--fallback-wa,oklch(var(--wa)/1))}.btn.glass{--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow);outline-color:currentColor}.btn.glass.btn-active{--glass-opacity:25%;--glass-border-opacity:15%}.btn-ghost.btn-active{border-color:#0000;background-color:var(--fallback-bc,oklch(var(--bc)/.2))}.btn-link.btn-active{border-color:#0000;background-color:initial;text-decoration-line:underline}.btn-outline{border-color:currentColor;background-color:initial;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-shadow:0 0 #0000;--tw-shadow-colored:0 0 #0000;box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.btn-outline.btn-active{--tw-border-opacity:1;border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-b1,oklch(var(--b1)/var(--tw-text-opacity)))}.btn-outline.btn-primary{--tw-text-opacity:1;color:var(--fallback-p,oklch(var(--p)/var(--tw-text-opacity)))}.btn-outline.btn-primary.btn-active{--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn-outline.btn-secondary{--tw-text-opacity:1;color:var(--fallback-s,oklch(var(--s)/var(--tw-text-opacity)))}.btn-outline.btn-secondary.btn-active{--tw-text-opacity:1;color:var(--fallback-sc,oklch(var(--sc)/var(--tw-text-opacity)))}.btn-outline.btn-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.btn-outline.btn-accent.btn-active{--tw-text-opacity:1;color:var(--fallback-ac,oklch(var(--ac)/var(--tw-text-opacity)))}.btn-outline.btn-success{--tw-text-opacity:1;color:var(--fallback-su,oklch(var(--su)/var(--tw-text-opacity)))}.btn-outline.btn-success.btn-active{--tw-text-opacity:1;color:var(--fallback-suc,oklch(var(--suc)/var(--tw-text-opacity)))}.btn-outline.btn-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.btn-outline.btn-info.btn-active{--tw-text-opacity:1;color:var(--fallback-inc,oklch(var(--inc)/var(--tw-text-opacity)))}.btn-outline.btn-warning{--tw-text-opacity:1;color:var(--fallback-wa,oklch(var(--wa)/var(--tw-text-opacity)))}.btn-outline.btn-warning.btn-active{--tw-text-opacity:1;color:var(--fallback-wac,oklch(var(--wac)/var(--tw-text-opacity)))}.btn-outline.btn-error{--tw-text-opacity:1;color:var(--fallback-er,oklch(var(--er)/var(--tw-text-opacity)))}.btn-outline.btn-error.btn-active{--tw-text-opacity:1;color:var(--fallback-erc,oklch(var(--erc)/var(--tw-text-opacity)))}.btn.btn-disabled,.btn:disabled,.btn[disabled]{--tw-border-opacity:0;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-bg-opacity:0.2;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.btn:is(input[type=checkbox]:checked),.btn:is(input[type=radio]:checked){--tw-border-opacity:1;border-color:var(--fallback-p,oklch(var(--p)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.btn:is(input[type=checkbox]:checked):focus-visible,.btn:is(input[type=radio]:checked):focus-visible{outline-color:var(--fallback-p,oklch(var(--p)/1))}@keyframes button-pop{0%{transform:scale(var(--btn-focus-scale,.98))}40%{transform:scale(1.02)}to{transform:scale(1)}}.card :where(figure:first-child){overflow:hidden;border-start-start-radius:inherit;border-start-end-radius:inherit;border-end-start-radius:unset;border-end-end-radius:unset}.card :where(figure:last-child){overflow:hidden;border-start-start-radius:unset;border-start-end-radius:unset;border-end-start-radius:inherit;border-end-end-radius:inherit}.card:focus-visible{outline:2px solid currentColor;outline-offset:2px}.card.bordered{border-width:1px;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.card.compact .card-body{padding:1rem;font-size:.875rem;line-height:1.25rem}.card.image-full :where(figure){overflow:hidden;border-radius:inherit}.checkbox:focus{box-shadow:none}.checkbox:focus-visible{outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/1))}.checkbox:disabled{border-width:0;cursor:not-allowed;border-color:#0000;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.2}.checkbox:checked,.checkbox[aria-checked=true]{background-color:var(--chkbg);background-image:linear-gradient(-45deg,#0000 65%,var(--chkbg) 65.99%),linear-gradient(45deg,#0000 75%,var(--chkbg) 75.99%),linear-gradient(-45deg,var(--chkbg) 40%,#0000 40.99%),linear-gradient(45deg,var(--chkbg) 30%,var(--chkfg) 30.99%,var(--chkfg) 40%,#0000 40.99%),linear-gradient(-45deg,var(--chkfg) 50%,var(--chkbg) 50.99%)}.checkbox:checked,.checkbox:indeterminate,.checkbox[aria-checked=true]{background-repeat:no-repeat;animation:checkmark var(--animation-input,.2s) ease-out}.checkbox:indeterminate{--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));background-image:linear-gradient(90deg,#0000 80%,var(--chkbg) 80%),linear-gradient(-90deg,#0000 80%,var(--chkbg) 80%),linear-gradient(0deg,var(--chkbg) 43%,var(--chkfg) 43%,var(--chkfg) 57%,var(--chkbg) 57%)}@keyframes checkmark{0%{background-position-y:5px}50%{background-position-y:-2px}to{background-position-y:0}}.divider:not(:empty){gap:1rem}.dropdown.dropdown-open .dropdown-content,.dropdown:focus .dropdown-content,.dropdown:focus-within .dropdown-content{--tw-scale-x:1;--tw-scale-y:1;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y))}.label-text{font-size:.875rem;line-height:1.25rem;--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)))}.input input{--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));background-color:initial}.input input:focus{outline:2px solid #0000;outline-offset:2px}.input[list]::-webkit-calendar-picker-indicator{line-height:1em}.input-bordered,.input:focus,.input:focus-within{border-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input:focus,.input:focus-within{box-shadow:none;outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.input-disabled,.input:disabled,.input:has(>input[disabled]),.input[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.input-disabled::-moz-placeholder,.input:disabled::-moz-placeholder,.input:has(>input[disabled])::-moz-placeholder,.input[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input-disabled::placeholder,.input:disabled::placeholder,.input:has(>input[disabled])::placeholder,.input[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.input:has(>input[disabled])>input[disabled]{cursor:not-allowed}.input::-webkit-date-and-time-value{text-align:inherit}.join>:where(:not(:first-child)){margin-top:0;margin-bottom:0;-webkit-margin-start:-1px;margin-inline-start:-1px}.join>:where(:not(:first-child)):is(.btn){-webkit-margin-start:calc(var(--border-btn)*-1);margin-inline-start:calc(var(--border-btn)*-1)}.join-item:focus{isolation:isolate}.link:focus{outline:2px solid #0000;outline-offset:2px}.link:focus-visible{outline:2px solid currentColor;outline-offset:2px}:where(.menu li:empty){--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;margin:.5rem 1rem;height:1px}.menu :where(li ul):before{position:absolute;bottom:.75rem;inset-inline-start:0;top:.75rem;width:1px;--tw-bg-opacity:1;background-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-bg-opacity)));opacity:.1;content:""}.menu :where(li:not(.menu-title)>:not(ul,details,.menu-title,.btn)),.menu :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.5rem 1rem;text-align:start;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-timing-function:cubic-bezier(.4,0,.2,1);transition-timing-function:cubic-bezier(0,0,.2,1);transition-duration:.2s;text-wrap:balance}:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>:not(ul,details,.menu-title)):not(summary,.active,.btn):focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):is(summary):not(.active,.btn):focus-visible,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn).focus,:where(.menu li:not(.menu-title,.disabled)>details>summary:not(.menu-title)):not(summary,.active,.btn):focus{cursor:pointer;background-color:var(--fallback-bc,oklch(var(--bc)/.1));--tw-text-opacity:1;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));outline:2px solid #0000;outline-offset:2px}.menu li>:not(ul,.menu-title,details,.btn).active,.menu li>:not(ul,.menu-title,details,.btn):active,.menu li>details>summary:active{--tw-bg-opacity:1;background-color:var(--fallback-n,oklch(var(--n)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-nc,oklch(var(--nc)/var(--tw-text-opacity)))}.menu :where(li>details>summary)::-webkit-details-marker{display:none}.menu :where(li>.menu-dropdown-toggle):after,.menu :where(li>details>summary):after{justify-self:end;display:block;margin-top:-.5rem;height:.5rem;width:.5rem;transform:rotate(45deg);transition-property:transform,margin-top;transition-duration:.3s;transition-timing-function:cubic-bezier(.4,0,.2,1);content:"";transform-origin:75% 75%;box-shadow:2px 2px;pointer-events:none}.menu :where(li>.menu-dropdown-toggle.menu-dropdown-show):after,.menu :where(li>details[open]>summary):after{transform:rotate(225deg);margin-top:0}.mockup-phone .display{overflow:hidden;border-radius:40px;margin-top:-25px}.mockup-browser .mockup-browser-toolbar .input{position:relative;margin-left:auto;margin-right:auto;display:block;height:1.75rem;width:24rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));padding-left:2rem;direction:ltr}.mockup-browser .mockup-browser-toolbar .input:before{left:.5rem;aspect-ratio:1/1;height:.75rem;--tw-translate-y:-50%;border-radius:9999px;border-width:2px;border-color:currentColor}.mockup-browser .mockup-browser-toolbar .input:after,.mockup-browser .mockup-browser-toolbar .input:before{content:"";position:absolute;top:50%;transform:translate(var(--tw-translate-x),var(--tw-translate-y)) rotate(var(--tw-rotate)) skewX(var(--tw-skew-x)) skewY(var(--tw-skew-y)) scaleX(var(--tw-scale-x)) scaleY(var(--tw-scale-y));opacity:.6}.mockup-browser .mockup-browser-toolbar .input:after{left:1.25rem;height:.5rem;--tw-translate-y:25%;--tw-rotate:-45deg;border-radius:9999px;border-width:1px;border-color:currentColor}@keyframes modal-pop{0%{opacity:0}}@keyframes progress-loading{50%{background-position-x:-115%}}@keyframes radiomark{0%{box-shadow:0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 12px var(--fallback-b1,oklch(var(--b1)/1)) inset}50%{box-shadow:0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 3px var(--fallback-b1,oklch(var(--b1)/1)) inset}to{box-shadow:0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 4px var(--fallback-b1,oklch(var(--b1)/1)) inset}}.range:focus-visible::-webkit-slider-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range:focus-visible::-moz-range-thumb{--focus-shadow:0 0 0 6px var(--fallback-b1,oklch(var(--b1)/1)) inset,0 0 0 2rem var(--range-shdw) inset}.range::-webkit-slider-runnable-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.range::-moz-range-track{height:.5rem;width:100%;border-radius:var(--rounded-box,1rem);background-color:var(--fallback-bc,oklch(var(--bc)/.1))}.range::-webkit-slider-thumb{position:relative;height:1.5rem;width:1.5rem;border-radius:var(--rounded-box,1rem);border-style:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));appearance:none;-webkit-appearance:none;top:50%;color:var(--range-shdw);transform:translateY(-50%);--filler-size:100rem;--filler-offset:0.6rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}.range::-moz-range-thumb{position:relative;height:1.5rem;width:1.5rem;border-radius:var(--rounded-box,1rem);border-style:none;--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)));top:50%;color:var(--range-shdw);--filler-size:100rem;--filler-offset:0.5rem;box-shadow:0 0 0 3px var(--range-shdw) inset,var(--focus-shadow,0 0),calc(var(--filler-size)*-1 - var(--filler-offset)) 0 0 var(--filler-size)}@keyframes rating-pop{0%{transform:translateY(-.125em)}40%{transform:translateY(-.125em)}to{transform:translateY(0)}}.select:focus{box-shadow:none;border-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.select-disabled,.select:disabled,.select[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.select-disabled::-moz-placeholder,.select:disabled::-moz-placeholder,.select[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-disabled::placeholder,.select:disabled::placeholder,.select[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.select-multiple,.select[multiple],.select[size].select:not([size="1"]){background-image:none;padding-right:1rem}[dir=rtl] .select{background-position:12px calc(1px + 50%),16px calc(1px + 50%)}@keyframes skeleton{0%{background-position:150%}to{background-position:-50%}}.tabs-lifted>.tab:focus-visible{border-end-end-radius:0;border-end-start-radius:0}.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tab:is(input:checked){border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:1;--tw-text-opacity:1}.tab:focus{outline:2px solid #0000;outline-offset:2px}.tab:focus-visible{outline:2px solid currentColor;outline-offset:-5px}.tab-disabled,.tab[disabled]{cursor:not-allowed;color:var(--fallback-bc,oklch(var(--bc)/var(--tw-text-opacity)));--tw-text-opacity:0.2}.tabs-bordered>.tab{border-color:var(--fallback-bc,oklch(var(--bc)/var(--tw-border-opacity)));--tw-border-opacity:0.2;border-style:solid;border-bottom-width:calc(var(--tab-border, 1px) + 1px)}.tabs-lifted>.tab{border:var(--tab-border,1px) solid #0000;border-width:0 0 var(--tab-border,1px) 0;border-start-start-radius:var(--tab-radius,.5rem);border-start-end-radius:var(--tab-radius,.5rem);border-bottom-color:var(--tab-border-color);-webkit-padding-start:var(--tab-padding,1rem);padding-inline-start:var(--tab-padding,1rem);-webkit-padding-end:var(--tab-padding,1rem);padding-inline-end:var(--tab-padding,1rem);padding-top:var(--tab-border,1px)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tabs-lifted>.tab:is(input:checked){background-color:var(--tab-bg);border-width:var(--tab-border,1px) var(--tab-border,1px) 0 var(--tab-border,1px);border-inline-start-color:var(--tab-border-color);border-inline-end-color:var(--tab-border-color);border-top-color:var(--tab-border-color);-webkit-padding-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-inline-start:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));-webkit-padding-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-inline-end:calc(var(--tab-padding, 1rem) - var(--tab-border, 1px));padding-bottom:var(--tab-border,1px);padding-top:0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before,.tabs-lifted>.tab:is(input:checked):before{z-index:1;content:"";display:block;position:absolute;width:calc(100% + var(--tab-radius, .5rem)*2);height:var(--tab-radius,.5rem);bottom:0;background-size:var(--tab-radius,.5rem);background-position:0 0,100% 0;background-repeat:no-repeat;--tab-grad:calc(69% - var(--tab-border, 1px));--radius-start:radial-gradient(circle at top left,#0000 var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));--radius-end:radial-gradient(circle at top right,#0000 var(--tab-grad),var(--tab-border-color) calc(var(--tab-grad) + 0.25px),var(--tab-border-color) calc(var(--tab-grad) + var(--tab-border, 1px)),var(--tab-bg) calc(var(--tab-grad) + var(--tab-border, 1px) + 0.25px));background-image:var(--radius-start),var(--radius-end)}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,.tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,.tabs-lifted>.tab:is(input:checked):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):first-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):first-child:before{background-image:var(--radius-start);background-position:0 0}.tabs-lifted>.tab:is(input:checked)+.tabs-lifted .tab:is(input:checked):before,.tabs-lifted>:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled])+.tabs-lifted :is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):before,[dir=rtl] .tabs-lifted>.tab:is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]):last-child:before,[dir=rtl] .tabs-lifted>.tab:is(input:checked):last-child:before{background-image:var(--radius-end);background-position:100% 0}.tabs-boxed .tab{border-radius:var(--rounded-btn,.5rem)}.tabs-boxed :is(.tab-active,[aria-selected=true]):not(.tab-disabled):not([disabled]),.tabs-boxed :is(input:checked){--tw-bg-opacity:1;background-color:var(--fallback-p,oklch(var(--p)/var(--tw-bg-opacity)));--tw-text-opacity:1;color:var(--fallback-pc,oklch(var(--pc)/var(--tw-text-opacity)))}.table:where([dir=rtl],[dir=rtl] *){text-align:right}.table :where(th,td){padding:.75rem 1rem;vertical-align:middle}.table tr.active,.table tr.active:nth-child(2n),.table-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)))}.table-zebra tr.active,.table-zebra tr.active:nth-child(2n),.table-zebra-zebra tbody tr:nth-child(2n){--tw-bg-opacity:1;background-color:var(--fallback-b3,oklch(var(--b3)/var(--tw-bg-opacity)))}.table :where(thead tr,tbody tr:not(:last-child),tbody tr:first-child:last-child){border-bottom-width:1px;--tw-border-opacity:1;border-bottom-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.table :where(thead,tfoot){white-space:nowrap;font-size:.75rem;line-height:1rem;font-weight:700;color:var(--fallback-bc,oklch(var(--bc)/.6))}.table :where(tfoot){border-top-width:1px;--tw-border-opacity:1;border-top-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)))}.textarea:focus{box-shadow:none;border-color:var(--fallback-bc,oklch(var(--bc)/.2));outline-style:solid;outline-width:2px;outline-offset:2px;outline-color:var(--fallback-bc,oklch(var(--bc)/.2))}.textarea-disabled,.textarea:disabled,.textarea[disabled]{cursor:not-allowed;--tw-border-opacity:1;border-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-border-opacity)));--tw-bg-opacity:1;background-color:var(--fallback-b2,oklch(var(--b2)/var(--tw-bg-opacity)));color:var(--fallback-bc,oklch(var(--bc)/.4))}.textarea-disabled::-moz-placeholder,.textarea:disabled::-moz-placeholder,.textarea[disabled]::-moz-placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}.textarea-disabled::placeholder,.textarea:disabled::placeholder,.textarea[disabled]::placeholder{color:var(--fallback-bc,oklch(var(--bc)/var(--tw-placeholder-opacity)));--tw-placeholder-opacity:0.2}@keyframes toast-pop{0%{transform:scale(.9);opacity:0}to{transform:scale(1);opacity:1}}.glass,.glass.btn-active{border:none;-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:initial;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,#0000 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,#0000 25%);box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px #0000000d;text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}@media (hover:hover){.glass.btn-active{border:none;-webkit-backdrop-filter:blur(var(--glass-blur,40px));backdrop-filter:blur(var(--glass-blur,40px));background-color:initial;background-image:linear-gradient(135deg,rgb(255 255 255/var(--glass-opacity,30%)) 0,#0000 100%),linear-gradient(var(--glass-reflex-degree,100deg),rgb(255 255 255/var(--glass-reflex-opacity,10%)) 25%,#0000 25%);box-shadow:0 0 0 1px rgb(255 255 255/var(--glass-border-opacity,10%)) inset,0 0 0 2px #0000000d;text-shadow:0 1px rgb(0 0 0/var(--glass-text-shadow-opacity,5%))}}.badge-xs{height:.75rem;font-size:.75rem;line-height:.75rem;padding-left:.313rem;padding-right:.313rem}.btn-xs{height:1.5rem;min-height:1.5rem;padding-left:.5rem;padding-right:.5rem;font-size:.75rem}.btn-sm{height:2rem;min-height:2rem;padding-left:.75rem;padding-right:.75rem;font-size:.875rem}.btn-square:where(.btn-xs){height:1.5rem;width:1.5rem;padding:0}.btn-square:where(.btn-sm){height:2rem;width:2rem;padding:0}.btn-circle:where(.btn-xs){height:1.5rem;width:1.5rem;border-radius:9999px;padding:0}.btn-circle:where(.btn-sm){height:2rem;width:2rem;border-radius:9999px;padding:0}.join.join-vertical{flex-direction:column}.join.join-vertical .join-item:first-child:not(:last-child),.join.join-vertical :first-child:not(:last-child) .join-item{border-end-start-radius:0;border-end-end-radius:0;border-start-start-radius:inherit;border-start-end-radius:inherit}.join.join-vertical .join-item:last-child:not(:first-child),.join.join-vertical :last-child:not(:first-child) .join-item{border-start-start-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-end-end-radius:inherit}.join.join-horizontal{flex-direction:row}.join.join-horizontal .join-item:first-child:not(:last-child),.join.join-horizontal :first-child:not(:last-child) .join-item{border-end-end-radius:0;border-start-end-radius:0;border-end-start-radius:inherit;border-start-start-radius:inherit}.join.join-horizontal .join-item:last-child:not(:first-child),.join.join-horizontal :last-child:not(:first-child) .join-item{border-end-start-radius:0;border-start-start-radius:0;border-end-end-radius:inherit;border-start-end-radius:inherit}.tabs-md :where(.tab){height:2rem;font-size:.875rem;line-height:1.25rem;line-height:2;--tab-padding:1rem}.tabs-lg :where(.tab){height:3rem;font-size:1.125rem;line-height:1.75rem;line-height:2;--tab-padding:1.25rem}.tabs-sm :where(.tab){height:1.5rem;font-size:.875rem;line-height:.75rem;--tab-padding:0.75rem}.tabs-xs :where(.tab){height:1.25rem;font-size:.75rem;line-height:.75rem;--tab-padding:0.5rem}.tooltip{--tooltip-offset:calc(100% + 1px + var(--tooltip-tail, 0px))}.tooltip:before{position:absolute;pointer-events:none;z-index:1;content:var(--tw-content);--tw-content:attr(data-tip)}.tooltip-top:before,.tooltip:before{transform:translateX(-50%);top:auto;left:50%;right:auto;bottom:var(--tooltip-offset)}.join.join-vertical>:where(:not(:first-child)){margin-left:0;margin-right:0;margin-top:-1px}.join.join-vertical>:where(:not(:first-child)):is(.btn){margin-top:calc(var(--border-btn)*-1)}.join.join-horizontal>:where(:not(:first-child)){margin-top:0;margin-bottom:0;-webkit-margin-start:-1px;margin-inline-start:-1px}.join.join-horizontal>:where(:not(:first-child)):is(.btn){-webkit-margin-start:calc(var(--border-btn)*-1);margin-inline-start:calc(var(--border-btn)*-1)}.menu-sm :where(li:not(.menu-title)>:not(ul,details,.menu-title)),.menu-sm :where(li:not(.menu-title)>details>summary:not(.menu-title)){border-radius:var(--rounded-btn,.5rem);padding:.25rem .75rem;font-size:.875rem;line-height:1.25rem}.menu-sm .menu-title{padding:.5rem .75rem}.table-xs :not(thead):not(tfoot) tr{font-size:.75rem;line-height:1rem}.table-xs :where(th,td){padding:.25rem .5rem}.tooltip{position:relative;display:inline-block;text-align:center;--tooltip-tail:0.1875rem;--tooltip-color:var(--fallback-n,oklch(var(--n)/1));--tooltip-text-color:var(--fallback-nc,oklch(var(--nc)/1));--tooltip-tail-offset:calc(100% + 0.0625rem - var(--tooltip-tail))}.tooltip:after,.tooltip:before{opacity:0;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,-webkit-backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter;transition-property:color,background-color,border-color,text-decoration-color,fill,stroke,opacity,box-shadow,transform,filter,backdrop-filter,-webkit-backdrop-filter;transition-delay:.1s;transition-duration:.2s;transition-timing-function:cubic-bezier(.4,0,.2,1)}.tooltip:after{position:absolute;content:"";border-style:solid;border-width:var(--tooltip-tail,0);width:0;height:0;display:block}.tooltip:before{max-width:20rem;white-space:normal;border-radius:.25rem;padding:.25rem .5rem;font-size:.875rem;line-height:1.25rem;background-color:var(--tooltip-color);color:var(--tooltip-text-color);width:-moz-max-content;width:max-content}.tooltip.tooltip-open:after,.tooltip.tooltip-open:before,.tooltip:hover:after,.tooltip:hover:before{opacity:1;transition-delay:75ms}.tooltip:has(:focus-visible):after,.tooltip:has(:focus-visible):before{opacity:1;transition-delay:75ms}.tooltip:not([data-tip]):hover:after,.tooltip:not([data-tip]):hover:before{visibility:hidden;opacity:0}.tooltip-top:after,.tooltip:after{transform:translateX(-50%);border-color:var(--tooltip-color) #0000 #0000 #0000;top:auto;left:50%;right:auto;bottom:var(--tooltip-tail-offset)}.visible{visibility:visible}.absolute{position:absolute}.relative{position:relative}.inset-y-0{top:0;bottom:0}.left-0{left:0}.z-10{z-index:10}.z-\[1\]{z-index:1}.m-1{margin:.25rem}.m-2{margin:.5rem}.m-4{margin:1rem}.mx-2{margin-left:.5rem;margin-right:.5rem}.my-2{margin-top:.5rem;margin-bottom:.5rem}.ml-4{margin-left:1rem}.mr-2{margin-right:.5rem}.block{display:block}.inline-block{display:inline-block}.inline{display:inline}.flex{display:flex}.inline-flex{display:inline-flex}.table{display:table}.contents{display:contents}.h-4{height:1rem}.h-full{height:100%}.w-4{width:1rem}.w-48{width:12rem}.w-full{width:100%}.flex-1{flex:1 1 0%}.flex-none{flex:none}.cursor-pointer{cursor:pointer}.appearance-none{-webkit-appearance:none;-moz-appearance:none;appearance:none}.flex-row{flex-direction:row}.flex-col{flex-direction:column}.flex-wrap{flex-wrap:wrap}.items-center{align-items:center}.space-x-2>:not([hidden])~:not([hidden]){--tw-space-x-reverse:0;margin-right:calc(.5rem*var(--tw-space-x-reverse));margin-left:calc(.5rem*(1 - var(--tw-space-x-reverse)))}.truncate{overflow:hidden;text-overflow:ellipsis}.truncate,.whitespace-nowrap{white-space:nowrap}.whitespace-pre{white-space:pre}.rounded-box{border-radius:var(--rounded-box,1rem)}.rounded-l{border-top-left-radius:.25rem;border-bottom-left-radius:.25rem}.rounded-r{border-top-right-radius:.25rem;border-bottom-right-radius:.25rem}.border{border-width:1px}.border-2{border-width:2px}.border-b{border-bottom-width:1px}.border-solid{border-style:solid}.border-gray-400{--tw-border-opacity:1;border-color:rgb(156 163 175/var(--tw-border-opacity))}.bg-base-100{--tw-bg-opacity:1;background-color:var(--fallback-b1,oklch(var(--b1)/var(--tw-bg-opacity)))}.bg-slate-100{--tw-bg-opacity:1;background-color:rgb(241 245 249/var(--tw-bg-opacity))}.bg-white{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.fill-current{fill:currentColor}.p-0{padding:0}.p-1{padding:.25rem}.p-2{padding:.5rem}.px-2{padding-left:.5rem;padding-right:.5rem}.py-0{padding-top:0;padding-bottom:0}.py-2{padding-top:.5rem;padding-bottom:.5rem}.pl-0{padding-left:0}.pl-2{padding-left:.5rem}.pl-8{padding-left:2rem}.pr-2{padding-right:.5rem}.pr-6{padding-right:1.5rem}.text-left{text-align:left}.align-top{vertical-align:top}.font-mono{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace}.text-sm{font-size:.875rem;line-height:1.25rem}.text-xs{font-size:.75rem;line-height:1rem}.text-accent{--tw-text-opacity:1;color:var(--fallback-a,oklch(var(--a)/var(--tw-text-opacity)))}.text-emerald-700{--tw-text-opacity:1;color:rgb(4 120 87/var(--tw-text-opacity))}.text-gray-500{--tw-text-opacity:1;color:rgb(107 114 128/var(--tw-text-opacity))}.text-gray-700{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.text-info{--tw-text-opacity:1;color:var(--fallback-in,oklch(var(--in)/var(--tw-text-opacity)))}.text-lime-500{--tw-text-opacity:1;color:rgb(132 204 22/var(--tw-text-opacity))}.text-primary{--tw-text-opacity:1;color:rgb(20 106 142/var(--tw-text-opacity))}.text-red-300{--tw-text-opacity:1;color:rgb(252 165 165/var(--tw-text-opacity))}.text-red-500{--tw-text-opacity:1;color:rgb(239 68 68/var(--tw-text-opacity))}.placeholder-gray-400::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.placeholder-gray-400::placeholder{--tw-placeholder-opacity:1;color:rgb(156 163 175/var(--tw-placeholder-opacity))}.shadow{--tw-shadow:0 1px 3px 0 #0000001a,0 1px 2px -1px #0000001a;--tw-shadow-colored:0 1px 3px 0 var(--tw-shadow-color),0 1px 2px -1px var(--tw-shadow-color);box-shadow:var(--tw-ring-offset-shadow,0 0 #0000),var(--tw-ring-shadow,0 0 #0000),var(--tw-shadow)}.ring{--tw-ring-offset-shadow:var(--tw-ring-inset) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color);--tw-ring-shadow:var(--tw-ring-inset) 0 0 0 calc(3px + var(--tw-ring-offset-width)) var(--tw-ring-color);box-shadow:var(--tw-ring-offset-shadow),var(--tw-ring-shadow),var(--tw-shadow,0 0 #0000)}.filter{filter:var(--tw-blur) var(--tw-brightness) var(--tw-contrast) var(--tw-grayscale) var(--tw-hue-rotate) var(--tw-invert) var(--tw-saturate) var(--tw-sepia) var(--tw-drop-shadow)}.hover\:bg-primary:hover{--tw-bg-opacity:1;background-color:rgb(20 106 142/var(--tw-bg-opacity))}.focus\:bg-white:focus{--tw-bg-opacity:1;background-color:rgb(255 255 255/var(--tw-bg-opacity))}.focus\:text-gray-700:focus{--tw-text-opacity:1;color:rgb(55 65 81/var(--tw-text-opacity))}.focus\:placeholder-gray-600:focus::-moz-placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity))}.focus\:placeholder-gray-600:focus::placeholder{--tw-placeholder-opacity:1;color:rgb(75 85 99/var(--tw-placeholder-opacity))}.focus\:outline-none:focus{outline:2px solid #0000;outline-offset:2px}@media (min-width:640px){.sm\:rounded-l-none{border-top-left-radius:0;border-bottom-left-radius:0}} --------------------------------------------------------------------------------