├── .prettierrc ├── server ├── tsconfig.json └── api │ └── yjs │ └── [slug].ts ├── tsconfig.json ├── .gitignore ├── nuxt.config.ts ├── wrangler.toml ├── app.vue ├── .github └── workflows │ └── deploy.yml ├── utils └── index.ts ├── package.json ├── README.md ├── components └── TiptapEditor.vue └── assets ├── style.css └── theme.css /.prettierrc: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../.nuxt/tsconfig.server.json" 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // https://nuxt.com/docs/guide/concepts/typescript 3 | "extends": "./.nuxt/tsconfig.json" 4 | } 5 | -------------------------------------------------------------------------------- /server/api/yjs/[slug].ts: -------------------------------------------------------------------------------- 1 | import { createHandler } from "y-crossws"; 2 | 3 | export default defineWebSocketHandler(createHandler().hooks); 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Nuxt dev/build outputs 2 | .output 3 | .data 4 | .nuxt 5 | .nitro 6 | .cache 7 | dist 8 | 9 | # Node dependencies 10 | node_modules 11 | 12 | # Logs 13 | logs 14 | *.log 15 | 16 | # Misc 17 | .DS_Store 18 | .fleet 19 | .idea 20 | 21 | # Local env files 22 | .env 23 | .env.* 24 | !.env.example 25 | -------------------------------------------------------------------------------- /nuxt.config.ts: -------------------------------------------------------------------------------- 1 | // https://nuxt.com/docs/api/configuration/nuxt-config 2 | export default defineNuxtConfig({ 3 | compatibilityDate: "2024-09-19", 4 | devtools: { enabled: true }, 5 | 6 | nitro: { 7 | experimental: { 8 | websocket: true, 9 | }, 10 | }, 11 | 12 | modules: ["@nuxtjs/tailwindcss"], 13 | }); 14 | -------------------------------------------------------------------------------- /wrangler.toml: -------------------------------------------------------------------------------- 1 | name = "nuxt-tiptap" 2 | compatibility_date = "2024-09-19" 3 | main = "./.output/server/index.mjs" 4 | assets = { directory = "./.output/public/" } 5 | durable_objects.bindings = [{ name = "$DurableObject", class_name = "$DurableObject" }] 6 | migrations = [{ tag = "v1", new_classes = ["$DurableObject"] }] 7 | observability = { enabled = true, head_sampling_rate = 1 } 8 | -------------------------------------------------------------------------------- /app.vue: -------------------------------------------------------------------------------- 1 | 11 | 12 | 16 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: deploy playground 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | deploy: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v4 13 | - run: corepack enable 14 | - uses: actions/setup-node@v4 15 | with: 16 | node-version: 22 17 | cache: "pnpm" 18 | - run: pnpm install 19 | - run: pnpm build --preset cloudflare-durable 20 | - uses: cloudflare/wrangler-action@v3 21 | with: 22 | workingDirectory: "." 23 | apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} 24 | accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} 25 | -------------------------------------------------------------------------------- /utils/index.ts: -------------------------------------------------------------------------------- 1 | import { faker } from "@faker-js/faker"; 2 | 3 | // prettier-ignore 4 | const colors = ["#958DF1", "#F98181", "#FBBC88", "#FAF594", "#70CFF8", "#94FADB", "#B9F18D", "#C3E2C2", "#EAECCC", "#AFC8AD", "#EEC759", "#9BB8CD", "#FF90BC", "#FFC0D9", "#DC8686", "#7ED7C1", "#F3EEEA", "#89B9AD", "#D0BFFF", "#FFF8C9", "#CBFFA9", "#9BABB8", "#E3F4F4"]; 5 | 6 | export function getDefaultContent() { 7 | return /* html */ ` 8 |

Welcome to Nuxt + Tiptap Demo 👋

9 |

10 | This is a collaborative document. Feel free to edit and collaborate in real time! 11 |

12 | これは共同作業用のドキュメントです。自由に編集し、リアルタイムで共同作業してください! 13 |

14 |

15 |
16 |
17 |

18 | `; 19 | } 20 | 21 | export function getRandomUser() { 22 | return { 23 | name: faker.person.fullName(), 24 | color: colors[Math.floor(Math.random() * colors.length)], 25 | }; 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "nuxt-crossws", 3 | "private": true, 4 | "type": "module", 5 | "scripts": { 6 | "build": "nuxt build", 7 | "dev": "nuxt dev", 8 | "generate": "nuxt generate", 9 | "postinstall": "nuxt prepare", 10 | "preview": "nuxt preview" 11 | }, 12 | "dependencies": { 13 | "@faker-js/faker": "^9.1.0", 14 | "@nuxtjs/tailwindcss": "^6.12.2", 15 | "@tiptap/extension-character-count": "^2.9.1", 16 | "@tiptap/extension-collaboration": "^2.9.1", 17 | "@tiptap/extension-collaboration-cursor": "^2.9.1", 18 | "@tiptap/extension-highlight": "^2.9.1", 19 | "@tiptap/extension-task-item": "^2.9.1", 20 | "@tiptap/extension-task-list": "^2.9.1", 21 | "@tiptap/pm": "^2.9.1", 22 | "@tiptap/starter-kit": "^2.9.1", 23 | "@tiptap/vue-3": "^2.9.1", 24 | "nuxt": "latest", 25 | "vue": "latest", 26 | "vue-router": "latest", 27 | "y-crossws": "^0.0.2", 28 | "y-websocket": "^2.0.4", 29 | "yjs": "^13.6.20" 30 | }, 31 | "devDependencies": { 32 | "prettier": "^3.3.3" 33 | }, 34 | "packageManager": "pnpm@9.12.0" 35 | } 36 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Nuxt + TipTap 2 | 3 | [Tiptap][tiptap] editor demo made with [Nuxt][nuxt], [Nitro](nitro) and [y-crossws][y-crossws] for live collaboration. 4 | 5 | Zero-config deploys to everywhere! 6 | 7 | ## How it works? 8 | 9 | **Client:** 10 | 11 | We have integrated [Tiptap][tiptap] editor with [Vue.js][vue] with a WebSocket provider for live collaboration. Check [components/TiptapEditor.vue](./components/TiptapEditor.vue) for code. 12 | 13 | **Server:** 14 | 15 | We add [y-crossws][y-crossws] in a [server route](https://nitro.unjs.io/guide/routing). That's all needed! 16 | 17 | [server/api/yjs/[slug].ts](./server/api/yjs/[slug].ts): 18 | 19 | ```ts 20 | import { createHandler } from "y-crossws"; 21 | 22 | export default defineWebSocketHandler(createHandler().hooks); 23 | ``` 24 | 25 | ## Stack 26 | 27 | **Client:** 28 | 29 | - **[Nuxt][nuxt]**: Fullstack [Vue.js][Vue] based framework that makes web development intuitive and powerful. 30 | - **[Tiptap][tiptap]**: Headless editor framework with various extensions, such as collaboration. 31 | - **[Prosemirror][porsemirror]**: A toolkit for building rich-text editors. [Tiptap][tiptap] uses it as its underlying editor engine. 32 | 33 | **Server:** 34 | 35 | - **[Nitro][nitro]**: Server toolkit used by [Nuxt][nuxt] for runtime agnostic server development and deployments. 36 | - **[Crossws][crossws]**: Cross-platform WebSocket server integrated into [Nitro][nitro]. 37 | - **[yjs][yjs]**: A [CRDT](https://github.com/yjs/yjs/blob/master/README.md#Yjs-CRDT-Algorithm) library for live collaborative editing. 38 | - **[y-crossws][y-crossws]**: Framework- and platform-agnostic WebSocket server for [yjs][yjs] made with [crossws][crossws]. 39 | 40 | 41 | 42 | [nuxt]: https://nuxt.com/ 43 | [vue]: https://vuejs.org/ 44 | [nitro]: https://nitro.unjs.io/ 45 | [crossws]: https://crossws.unjs.io 46 | [yjs]: https://yjs.dev/#features 47 | [y-crossws]: https://github.com/pi0/y-crossws 48 | [tiptap]: https://tiptap.dev/ 49 | [porsemirror]: https://prosemirror.net/ 50 | 51 | ## Development 52 | 53 | Make sure to install the dependencies: 54 | 55 | ```bash 56 | pnpm install 57 | ``` 58 | 59 | Start the development server on `http://localhost:3000`: 60 | 61 | ```bash 62 | pnpm run dev 63 | ``` 64 | 65 | Build the application for production: 66 | 67 | ```bash 68 | pnpm run build # --preset 69 | ``` 70 | 71 | Check out the [deployment docs](https://nitro.unjs.io/deploy) for more information. 72 | 73 | ## Credits 74 | 75 | Demo made by [pi0](https://github.com/pi0) based on [Tiptap demo](https://github.com/ueberdosis/tiptap/blob/main/demos/src/Demos/CollaborationSplitPane/React). 76 | -------------------------------------------------------------------------------- /components/TiptapEditor.vue: -------------------------------------------------------------------------------- 1 | 83 | 84 | 135 | -------------------------------------------------------------------------------- /assets/style.css: -------------------------------------------------------------------------------- 1 | /* Source: https://github.com/ueberdosis/tiptap/blob/main/demos/src/Demos/CollaborationSplitPane/React/styles.scss */ 2 | 3 | /* Basic editor styles */ 4 | .tiptap { 5 | /* List styles */ 6 | /* Heading styles */ 7 | /* Code and preformatted text styles */ 8 | /* Highlight specific styles */ 9 | /* Task list specific styles */ 10 | /* Give a remote user a caret */ 11 | /* Render the username above the caret */ 12 | } 13 | .tiptap :first-child { 14 | margin-top: 0; 15 | } 16 | .tiptap ul, 17 | .tiptap ol { 18 | padding: 0 1rem; 19 | margin: 1.25rem 1rem 1.25rem 0.4rem; 20 | } 21 | .tiptap ul li p, 22 | .tiptap ol li p { 23 | margin-top: 0.25em; 24 | margin-bottom: 0.25em; 25 | } 26 | .tiptap h1, 27 | .tiptap h2, 28 | .tiptap h3, 29 | .tiptap h4, 30 | .tiptap h5, 31 | .tiptap h6 { 32 | line-height: 1.1; 33 | margin-top: 2.5rem; 34 | text-wrap: pretty; 35 | } 36 | .tiptap h1, 37 | .tiptap h2 { 38 | margin-top: 3.5rem; 39 | margin-bottom: 1.5rem; 40 | } 41 | .tiptap h1 { 42 | font-size: 1.4rem; 43 | } 44 | .tiptap h2 { 45 | font-size: 1.2rem; 46 | } 47 | .tiptap h3 { 48 | font-size: 1.1rem; 49 | } 50 | .tiptap h4, 51 | .tiptap h5, 52 | .tiptap h6 { 53 | font-size: 1rem; 54 | } 55 | .tiptap code { 56 | background-color: var(--purple-light); 57 | border-radius: 0.4rem; 58 | color: var(--black); 59 | font-size: 0.85rem; 60 | padding: 0.25em 0.3em; 61 | } 62 | .tiptap pre { 63 | background: var(--black); 64 | border-radius: 0.5rem; 65 | color: var(--white); 66 | font-family: "JetBrainsMono", monospace; 67 | margin: 1.5rem 0; 68 | padding: 0.75rem 1rem; 69 | } 70 | .tiptap pre code { 71 | background: none; 72 | color: inherit; 73 | font-size: 0.8rem; 74 | padding: 0; 75 | } 76 | .tiptap blockquote { 77 | border-left: 3px solid var(--gray-3); 78 | margin: 1.5rem 0; 79 | padding-left: 1rem; 80 | } 81 | .tiptap hr { 82 | border: none; 83 | border-top: 1px solid var(--gray-2); 84 | margin: 2rem 0; 85 | } 86 | .tiptap mark { 87 | background-color: #faf594; 88 | border-radius: 0.4rem; 89 | box-decoration-break: clone; 90 | padding: 0.1rem 0.3rem; 91 | } 92 | .tiptap ul[data-type="taskList"] { 93 | list-style: none; 94 | margin-left: 0; 95 | padding: 0; 96 | } 97 | .tiptap ul[data-type="taskList"] li { 98 | align-items: flex-start; 99 | display: flex; 100 | } 101 | .tiptap ul[data-type="taskList"] li > label { 102 | flex: 0 0 auto; 103 | margin-right: 0.5rem; 104 | user-select: none; 105 | } 106 | .tiptap ul[data-type="taskList"] li > div { 107 | flex: 1 1 auto; 108 | } 109 | .tiptap ul[data-type="taskList"] input[type="checkbox"] { 110 | cursor: pointer; 111 | } 112 | .tiptap ul[data-type="taskList"] ul[data-type="taskList"] { 113 | margin: 0; 114 | } 115 | .tiptap p { 116 | word-break: break-all; 117 | } 118 | .tiptap .collaboration-cursor__caret { 119 | border-left: 1px solid #0d0d0d; 120 | border-right: 1px solid #0d0d0d; 121 | margin-left: -1px; 122 | margin-right: -1px; 123 | pointer-events: none; 124 | position: relative; 125 | word-break: normal; 126 | } 127 | .tiptap .collaboration-cursor__label { 128 | border-radius: 3px 3px 3px 0; 129 | opacity: 0.5; 130 | color: #0d0d0d; 131 | font-size: 12px; 132 | font-style: normal; 133 | font-weight: 600; 134 | left: -1px; 135 | line-height: normal; 136 | padding: 0.1rem 0.3rem; 137 | position: absolute; 138 | top: -1.4em; 139 | user-select: none; 140 | white-space: nowrap; 141 | } 142 | .col-group { 143 | display: flex; 144 | flex-direction: row; 145 | height: 100vh; 146 | } 147 | @media (max-width: 540px) { 148 | .col-group { 149 | flex-direction: column; 150 | } 151 | } 152 | /* Column-half */ 153 | body { 154 | overflow: hidden; 155 | } 156 | .column-half { 157 | display: flex; 158 | flex-direction: column; 159 | flex: 1; 160 | overflow: auto; 161 | } 162 | .column-half:last-child { 163 | border-left: 1px solid var(--gray-3); 164 | } 165 | @media (max-width: 540px) { 166 | .column-half:last-child { 167 | border-left: none; 168 | border-top: 1px solid var(--gray-3); 169 | } 170 | } 171 | .column-half > .main-group { 172 | flex-grow: 1; 173 | } 174 | /* Collaboration status */ 175 | .collab-status-group { 176 | align-items: center; 177 | background-color: var(--white); 178 | border-top: 1px solid var(--gray-3); 179 | bottom: 0; 180 | color: var(--gray-5); 181 | display: flex; 182 | flex-direction: row; 183 | font-size: 0.75rem; 184 | font-weight: 400; 185 | gap: 1rem; 186 | justify-content: space-between; 187 | padding: 0.375rem 0.5rem 0.375rem 1rem; 188 | position: sticky; 189 | width: 100%; 190 | z-index: 100; 191 | } 192 | .collab-status-group button { 193 | -webkit-box-orient: vertical; 194 | -webkit-line-clamp: 1; 195 | align-self: stretch; 196 | background: none; 197 | display: -webkit-box; 198 | flex-shrink: 1; 199 | font-size: 0.75rem; 200 | max-width: 100%; 201 | padding: 0.25rem 0.375rem; 202 | overflow: hidden; 203 | position: relative; 204 | text-overflow: ellipsis; 205 | white-space: nowrap; 206 | } 207 | .collab-status-group button::before { 208 | background-color: var(--color); 209 | border-radius: 0.375rem; 210 | content: ""; 211 | height: 100%; 212 | left: 0; 213 | opacity: 0.5; 214 | position: absolute; 215 | top: 0; 216 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 217 | width: 100%; 218 | z-index: -1; 219 | } 220 | .collab-status-group button:hover::before { 221 | opacity: 1; 222 | } 223 | .collab-status-group label { 224 | align-items: center; 225 | display: flex; 226 | flex-direction: row; 227 | flex-shrink: 0; 228 | gap: 0.375rem; 229 | line-height: 1.1; 230 | } 231 | .collab-status-group label::before { 232 | border-radius: 50%; 233 | content: " "; 234 | height: 0.35rem; 235 | width: 0.35rem; 236 | } 237 | .collab-status-group[data-state="online"] label::before { 238 | background-color: var(--green); 239 | } 240 | .collab-status-group[data-state="offline"] label::before { 241 | background-color: var(--red); 242 | } 243 | 244 | /* Bubble menu */ 245 | .bubble-menu { 246 | background-color: var(--white); 247 | border: 1px solid var(--gray-1); 248 | border-radius: 0.7rem; 249 | box-shadow: var(--shadow); 250 | display: flex; 251 | padding: 0.2rem; 252 | } 253 | .bubble-menu button { 254 | background-color: unset; 255 | } 256 | .bubble-menu button:hover { 257 | background-color: var(--gray-3); 258 | } 259 | .bubble-menu button.is-active { 260 | background-color: var(--purple); 261 | } 262 | .bubble-menu button.is-active:hover { 263 | background-color: var(--purple-contrast); 264 | } 265 | /* Floating menu */ 266 | .floating-menu { 267 | display: flex; 268 | background-color: var(--gray-3); 269 | padding: 0.1rem; 270 | border-radius: 0.5rem; 271 | } 272 | .floating-menu button { 273 | background-color: unset; 274 | padding: 0.275rem 0.425rem; 275 | border-radius: 0.3rem; 276 | } 277 | .floating-menu button:hover { 278 | background-color: var(--gray-3); 279 | } 280 | .floating-menu button.is-active { 281 | background-color: var(--white); 282 | color: var(--purple); 283 | } 284 | .floating-menu button.is-active:hover { 285 | color: var(--purple-contrast); 286 | } 287 | -------------------------------------------------------------------------------- /assets/theme.css: -------------------------------------------------------------------------------- 1 | /* Shamefully stolen from https://embed.tiptap.dev/assets/helper-Bzo17db4.css to avoid tailwind build! */ 2 | 3 | :root { 4 | --white: #fff; 5 | --black: #2e2b29; 6 | --black-contrast: #110f0e; 7 | --gray-1: rgba(61, 37, 20, 0.05); 8 | --gray-2: rgba(61, 37, 20, 0.08); 9 | --gray-3: rgba(61, 37, 20, 0.12); 10 | --gray-4: rgba(53, 38, 28, 0.3); 11 | --gray-5: rgba(28, 25, 23, 0.6); 12 | --green: #22c55e; 13 | --purple: #6a00f5; 14 | --purple-contrast: #5800cc; 15 | --purple-light: rgba(88, 5, 255, 0.05); 16 | --yellow-contrast: #facc15; 17 | --yellow: rgba(250, 204, 21, 0.4); 18 | --yellow-light: #fffae5; 19 | --red: #ff5c33; 20 | --red-light: #ffebe5; 21 | --shadow: 0px 12px 33px 0px rgba(0, 0, 0, 0.06), 22 | 0px 3.618px 9.949px 0px rgba(0, 0, 0, 0.04); 23 | } 24 | *, 25 | *:before, 26 | *:after { 27 | box-sizing: border-box; 28 | } 29 | html { 30 | font-family: 31 | Inter, 32 | ui-sans-serif, 33 | system-ui, 34 | -apple-system, 35 | BlinkMacSystemFont, 36 | Segoe UI, 37 | Roboto, 38 | Helvetica Neue, 39 | Arial, 40 | Noto Sans, 41 | sans-serif, 42 | "Apple Color Emoji", 43 | "Segoe UI Emoji", 44 | Segoe UI Symbol, 45 | "Noto Color Emoji"; 46 | line-height: 1.5; 47 | -moz-osx-font-smoothing: grayscale; 48 | -webkit-font-smoothing: antialiased; 49 | } 50 | body { 51 | min-height: 25rem; 52 | margin: 0; 53 | } 54 | :first-child { 55 | margin-top: 0; 56 | } 57 | .tiptap { 58 | caret-color: var(--purple); 59 | margin: 1.5rem; 60 | } 61 | .tiptap:focus { 62 | outline: none; 63 | } 64 | ::-webkit-scrollbar { 65 | height: 14px; 66 | width: 14px; 67 | } 68 | ::-webkit-scrollbar-track { 69 | background-clip: padding-box; 70 | background-color: transparent; 71 | border: 4px solid transparent; 72 | border-radius: 8px; 73 | } 74 | ::-webkit-scrollbar-thumb { 75 | background-clip: padding-box; 76 | background-color: #0000; 77 | border: 4px solid rgba(0, 0, 0, 0); 78 | border-radius: 8px; 79 | } 80 | :hover::-webkit-scrollbar-thumb { 81 | background-color: #0000001a; 82 | } 83 | ::-webkit-scrollbar-thumb:hover { 84 | background-color: #00000026; 85 | } 86 | ::-webkit-scrollbar-button { 87 | display: none; 88 | height: 0; 89 | width: 0; 90 | } 91 | ::-webkit-scrollbar-corner { 92 | background-color: transparent; 93 | } 94 | button, 95 | input, 96 | select, 97 | textarea { 98 | background: var(--gray-2); 99 | border-radius: 0.5rem; 100 | border: none; 101 | color: var(--black); 102 | font-family: inherit; 103 | font-size: 0.875rem; 104 | font-weight: 500; 105 | line-height: 1.15; 106 | margin: none; 107 | padding: 0.375rem 0.625rem; 108 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 109 | } 110 | button:hover, 111 | input:hover, 112 | select:hover, 113 | textarea:hover { 114 | background-color: var(--gray-3); 115 | color: var(--black-contrast); 116 | } 117 | button[disabled], 118 | input[disabled], 119 | select[disabled], 120 | textarea[disabled] { 121 | background: var(--gray-1); 122 | color: var(--gray-4); 123 | } 124 | button:checked, 125 | input:checked, 126 | select:checked, 127 | textarea:checked { 128 | accent-color: var(--purple); 129 | } 130 | button.primary, 131 | input.primary, 132 | select.primary, 133 | textarea.primary { 134 | background: var(--black); 135 | color: var(--white); 136 | } 137 | button.primary:hover, 138 | input.primary:hover, 139 | select.primary:hover, 140 | textarea.primary:hover { 141 | background-color: var(--black-contrast); 142 | } 143 | button.primary[disabled], 144 | input.primary[disabled], 145 | select.primary[disabled], 146 | textarea.primary[disabled] { 147 | background: var(--gray-1); 148 | color: var(--gray-4); 149 | } 150 | button.is-active, 151 | input.is-active, 152 | select.is-active, 153 | textarea.is-active { 154 | background: var(--purple); 155 | color: var(--white); 156 | } 157 | button.is-active:hover, 158 | input.is-active:hover, 159 | select.is-active:hover, 160 | textarea.is-active:hover { 161 | background-color: var(--purple-contrast); 162 | color: var(--white); 163 | } 164 | button:not([disabled]), 165 | select:not([disabled]) { 166 | cursor: pointer; 167 | } 168 | input[type="text"], 169 | textarea { 170 | background-color: unset; 171 | border: 1px solid var(--gray-3); 172 | border-radius: 0.5rem; 173 | color: var(--black); 174 | } 175 | input[type="text"]::-moz-placeholder, 176 | textarea::-moz-placeholder { 177 | color: var(--gray-4); 178 | } 179 | input[type="text"]::placeholder, 180 | textarea::placeholder { 181 | color: var(--gray-4); 182 | } 183 | input[type="text"]:hover, 184 | textarea:hover { 185 | background-color: unset; 186 | border-color: var(--gray-4); 187 | } 188 | input[type="text"]:focus-visible, 189 | input[type="text"]:focus, 190 | textarea:focus-visible, 191 | textarea:focus { 192 | border-color: var(--purple); 193 | outline: none; 194 | } 195 | select { 196 | appearance: none; 197 | -webkit-appearance: none; 198 | -moz-appearance: none; 199 | background-image: url('data:image/svg+xml;utf8,'); 200 | background-repeat: no-repeat; 201 | background-position: right 0.1rem center; 202 | background-size: 1.25rem 1.25rem; 203 | padding-right: 1.25rem; 204 | } 205 | select:focus { 206 | outline: 0; 207 | } 208 | form { 209 | align-items: flex-start; 210 | display: flex; 211 | flex-direction: column; 212 | gap: 0.25rem; 213 | } 214 | .hint { 215 | align-items: center; 216 | background-color: var(--yellow-light); 217 | border-radius: 0.5rem; 218 | border: 1px solid var(--gray-2); 219 | display: flex; 220 | flex-direction: row; 221 | font-size: 0.75rem; 222 | gap: 0.25rem; 223 | line-height: 1.15; 224 | padding: 0.3rem 0.5rem; 225 | } 226 | .hint.purple-spinner, 227 | .hint.error { 228 | justify-content: center; 229 | text-align: center; 230 | width: 100%; 231 | } 232 | .hint .badge { 233 | background-color: var(--gray-1); 234 | border: 1px solid var(--gray-3); 235 | border-radius: 2rem; 236 | color: var(--gray-5); 237 | font-size: 0.625rem; 238 | font-weight: 700; 239 | line-height: 1; 240 | padding: 0.25rem 0.5rem; 241 | } 242 | .hint.purple-spinner { 243 | background-color: var(--purple-light); 244 | } 245 | .hint.purple-spinner:after { 246 | content: ""; 247 | background-image: url("data:image/svg+xml;utf8,"); 248 | background-size: cover; 249 | background-repeat: no-repeat; 250 | background-position: center; 251 | height: 1rem; 252 | width: 1rem; 253 | } 254 | .hint.error { 255 | background-color: var(--red-light); 256 | } 257 | .label, 258 | .label-small, 259 | .label-large { 260 | color: var(--black); 261 | font-size: 0.8125rem; 262 | font-weight: 500; 263 | line-height: 1.15; 264 | } 265 | .label-small { 266 | color: var(--gray-5); 267 | font-size: 0.75rem; 268 | font-weight: 400; 269 | } 270 | .label-large { 271 | font-size: 0.875rem; 272 | font-weight: 700; 273 | } 274 | hr { 275 | border: none; 276 | border-top: 1px solid var(--gray-3); 277 | margin: 0; 278 | width: 100%; 279 | } 280 | kbd { 281 | background-color: var(--gray-2); 282 | border: 1px solid var(--gray-2); 283 | border-radius: 0.25rem; 284 | font-size: 0.6rem; 285 | line-height: 1.15; 286 | padding: 0.1rem 0.25rem; 287 | text-transform: uppercase; 288 | } 289 | #app, 290 | .container { 291 | display: flex; 292 | flex-direction: column; 293 | } 294 | .button-group { 295 | display: flex; 296 | flex-wrap: wrap; 297 | gap: 0.25rem; 298 | } 299 | .control-group { 300 | align-items: flex-start; 301 | background-color: var(--white); 302 | display: flex; 303 | flex-direction: column; 304 | gap: 1rem; 305 | padding: 1.5rem; 306 | } 307 | .control-group .sticky { 308 | position: sticky; 309 | top: 0; 310 | } 311 | [data-node-view-wrapper] > .control-group { 312 | padding: 0; 313 | } 314 | .flex-row { 315 | display: flex; 316 | flex-direction: row; 317 | flex-wrap: wrap; 318 | gap: 1rem; 319 | justify-content: space-between; 320 | width: 100%; 321 | } 322 | .switch-group { 323 | align-items: center; 324 | background: var(--gray-2); 325 | border-radius: 0.5rem; 326 | display: flex; 327 | flex-direction: row; 328 | flex-wrap: wrap; 329 | flex: 0 1 auto; 330 | justify-content: flex-start; 331 | padding: 0.125rem; 332 | } 333 | .switch-group label { 334 | align-items: center; 335 | border-radius: 0.375rem; 336 | color: var(--gray-5); 337 | cursor: pointer; 338 | display: flex; 339 | flex-direction: row; 340 | font-size: 0.75rem; 341 | font-weight: 500; 342 | gap: 0.25rem; 343 | line-height: 1.15; 344 | min-height: 1.5rem; 345 | padding: 0 0.375rem; 346 | transition: all 0.2s cubic-bezier(0.65, 0.05, 0.36, 1); 347 | } 348 | .switch-group label:has(input:checked) { 349 | background-color: var(--white); 350 | color: var(--black-contrast); 351 | } 352 | .switch-group label:hover { 353 | color: var(--black); 354 | } 355 | .switch-group label input { 356 | display: none; 357 | margin: unset; 358 | } 359 | .output-group { 360 | background-color: var(--gray-1); 361 | display: flex; 362 | flex-direction: column; 363 | font-family: JetBrainsMono, monospace; 364 | font-size: 0.75rem; 365 | gap: 1rem; 366 | margin-top: 2.5rem; 367 | padding: 1.5rem; 368 | } 369 | .output-group label { 370 | color: var(--black); 371 | font-size: 0.875rem; 372 | font-weight: 700; 373 | line-height: 1.15; 374 | } 375 | --------------------------------------------------------------------------------