├── 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 | 
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 | 
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 | 
38 |
39 | ### CSV support
40 |
41 | CSV read by `clojure.data.csv` is also supported in the table visualizer.
42 |
43 | 
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}}
--------------------------------------------------------------------------------