├── .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 |
2 |
10 |
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 |
85 |
86 |
101 |
102 |
118 |
119 |
120 |
121 |
122 |
129 |
132 |
133 |
134 |
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 |
--------------------------------------------------------------------------------