@kernhq/module-quire 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,83 @@
1
+ <script lang="ts">
2
+ import {
3
+ CollaborativeEditor,
4
+ type CollabPeer,
5
+ type CollabStatus,
6
+ type CommentRange,
7
+ EmptyState,
8
+ getHost,
9
+ session,
10
+ } from '@kernhq/ui'
11
+ import { t } from '../i18n.js'
12
+ import { type Page, pageDocumentName } from '../index.js'
13
+
14
+ /**
15
+ * The body of a page, synchronised through the collab service.
16
+ *
17
+ * The document name is built with `formatCollabDocument` rather than assembled here: the gateway
18
+ * parses it with the matching function from the same package, and a name it cannot parse is a
19
+ * rejected connection with no useful error.
20
+ */
21
+ interface Props {
22
+ doc: Page
23
+ onpeers?: (peers: CollabPeer[]) => void
24
+ onstatus?: (status: CollabStatus) => void
25
+ commentRanges?: CommentRange[]
26
+ activeComment?: string | null
27
+ onCommentClick?: (id: string) => void
28
+ oncomment?: (anchor: { from: string; to: string }, quotedText: string) => void
29
+ }
30
+ const {
31
+ doc,
32
+ onpeers,
33
+ onstatus,
34
+ commentRanges = [],
35
+ activeComment = null,
36
+ onCommentClick,
37
+ oncomment,
38
+ }: Props = $props()
39
+
40
+ const name = $derived(pageDocumentName(doc))
41
+
42
+ /**
43
+ * Same-origin by default, so the dev proxy and the reverse proxy both work without configuration.
44
+ * The shell owns the endpoint: same origin under `/collab` in every ordinary deployment, and an
45
+ * explicit one for an instance that puts the collab service somewhere else.
46
+ */
47
+ const url = $derived(
48
+ getHost().collabUrl ??
49
+ (typeof location === 'undefined' ? '' : `${location.origin.replace(/^http/, 'ws')}/collab`),
50
+ )
51
+
52
+ const user = $derived({
53
+ id: session.user?.id ?? '',
54
+ name: session.user?.name ?? '',
55
+ avatarUrl: session.user?.avatarUrl ?? null,
56
+ })
57
+ </script>
58
+
59
+ {#if getHost().isMock}
60
+ <!--
61
+ There is no collab service behind `dev:mock`, and an editor that silently fails to sync is worse
62
+ than one that says so — this is the environment used for demos, where "it looked like it saved"
63
+ is exactly the wrong impression to leave.
64
+ -->
65
+ <EmptyState icon="wifi-off" title={t('editor_mock')} description={t('editor_mock_desc')} />
66
+ {:else if !user.id}
67
+ <EmptyState icon="triangle-alert" title={t('editor_no_session')} description={t('editor_no_session_desc')} />
68
+ {:else}
69
+ {#key name}
70
+ <CollaborativeEditor
71
+ {url}
72
+ {name}
73
+ {user}
74
+ placeholder={t('editor_placeholder')}
75
+ {onpeers}
76
+ {onstatus}
77
+ {commentRanges}
78
+ {activeComment}
79
+ {onCommentClick}
80
+ {oncomment}
81
+ />
82
+ {/key}
83
+ {/if}
@@ -0,0 +1,44 @@
1
+ <script lang="ts">
2
+ import { Icon, navigation, session } from '@kernhq/ui'
3
+ import { createQuery } from '@tanstack/svelte-query'
4
+ import { getQuireApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import { quireKeys } from '../query.js'
7
+
8
+ /**
9
+ * How a Quire page reads when another module links to one — a mention in an issue, a search hit, a
10
+ * notification. Inline presenters are how a module renders its own objects somewhere it does not own.
11
+ */
12
+ interface Props {
13
+ id: string
14
+ }
15
+ const { id }: Props = $props()
16
+
17
+ const api = getQuireApi()
18
+ const workspaceSlug = $derived(navigation.workspaceSlug)
19
+ const workspaceId = $derived(session.workspaces.find((w) => w.slug === workspaceSlug)?.id ?? '')
20
+
21
+ const query = createQuery(() => ({
22
+ queryKey: quireKeys.page(workspaceId, id),
23
+ enabled: Boolean(workspaceId && id),
24
+ queryFn: () => api.pages.get({ workspaceId, pageId: id }),
25
+ }))
26
+ const title = $derived(query.data?.title?.trim() || t('untitled'))
27
+ </script>
28
+
29
+ <span class="inline">
30
+ <Icon name="file-text" size={14} />
31
+ <span class="t">{query.isLoading ? '…' : title}</span>
32
+ </span>
33
+
34
+ <style>
35
+ .inline {
36
+ display: inline-flex;
37
+ align-items: center;
38
+ gap: 5px;
39
+ color: var(--kern-ink-700);
40
+ }
41
+ .t {
42
+ font-weight: 500;
43
+ }
44
+ </style>
@@ -0,0 +1,147 @@
1
+ <script lang="ts">
2
+ import { Icon, IconButton, SidebarItem } from '@kernhq/ui'
3
+ import { t } from '../i18n.js'
4
+ import type { PageTreeNode } from '../index.js'
5
+ import PageTreeRow from './PageTreeRow.svelte'
6
+
7
+ /**
8
+ * One row of the page tree, and its children.
9
+ *
10
+ * Recursive rather than flattened: the disclosure state belongs to the row that owns it, and a
11
+ * flattened list has to carry a depth on every node just to indent it. `SidebarItem` draws the row
12
+ * so indentation, the active state and RTL come from the design system rather than from here —
13
+ * `indent` caps at 2 there, so deeper levels stop stepping in rather than marching off the edge of a
14
+ * 268px column.
15
+ */
16
+ interface Props {
17
+ node: PageTreeNode
18
+ depth: number
19
+ activeId: string | null
20
+ expanded: Set<string>
21
+ onToggle: (id: string) => void
22
+ onOpen: (id: string) => void
23
+ onCreateChild: (id: string) => void
24
+ canCreate: boolean
25
+ }
26
+
27
+ const { node, depth, activeId, expanded, onToggle, onOpen, onCreateChild, canCreate }: Props = $props()
28
+
29
+ const isOpen = $derived(expanded.has(node.id))
30
+ const title = $derived(node.title.trim() || t('untitled'))
31
+ const indent = $derived(Math.min(depth, 2) as 0 | 1 | 2)
32
+ </script>
33
+
34
+ <div class="row">
35
+ <SidebarItem
36
+ label={title}
37
+ icon={node.kind === 'live' ? 'square-pen' : 'file-text'}
38
+ active={activeId === node.id}
39
+ {indent}
40
+ onclick={() => onOpen(node.id)}
41
+ >
42
+ {#snippet trailing()}
43
+ {#if node.archivedAt}
44
+ <span class="flag" title={t('archived')}><Icon name="archive" size={12} /></span>
45
+ {/if}
46
+ {#if canCreate}
47
+ <span class="add">
48
+ <IconButton
49
+ icon="plus"
50
+ size={22}
51
+ variant="ghost"
52
+ label={t('new_child_page')}
53
+ onclick={(e: MouseEvent) => {
54
+ e.stopPropagation()
55
+ onCreateChild(node.id)
56
+ }}
57
+ />
58
+ </span>
59
+ {/if}
60
+ {/snippet}
61
+ </SidebarItem>
62
+
63
+ {#if node.hasChildren}
64
+ <button
65
+ class="twisty"
66
+ type="button"
67
+ style:inset-inline-start="{indent * 22 - 4}px"
68
+ aria-label={isOpen ? t('collapse') : t('expand')}
69
+ aria-expanded={isOpen}
70
+ onclick={() => onToggle(node.id)}
71
+ >
72
+ <span class:open={isOpen}><Icon name="chevron-right" size={11} strokeWidth={2} /></span>
73
+ </button>
74
+ {/if}
75
+ </div>
76
+
77
+ {#if isOpen}
78
+ {#each node.children as child (child.id)}
79
+ <PageTreeRow
80
+ node={child}
81
+ depth={depth + 1}
82
+ {activeId}
83
+ {expanded}
84
+ {onToggle}
85
+ {onOpen}
86
+ {onCreateChild}
87
+ {canCreate}
88
+ />
89
+ {/each}
90
+ {/if}
91
+
92
+ <style>
93
+ .row {
94
+ position: relative;
95
+ }
96
+ /*
97
+ * The twisty sits over the item's icon well rather than inside the row, so expanding a page is not
98
+ * the same click target as opening it — a tree where one is inside the other makes it impossible to
99
+ * expand without navigating.
100
+ */
101
+ .twisty {
102
+ position: absolute;
103
+ inset-block-start: 50%;
104
+ transform: translateY(-50%);
105
+ display: inline-flex;
106
+ align-items: center;
107
+ justify-content: center;
108
+ width: 16px;
109
+ height: 16px;
110
+ border: 0;
111
+ background: none;
112
+ padding: 0;
113
+ color: var(--kern-ink-350);
114
+ cursor: pointer;
115
+ border-radius: var(--kern-r-xs);
116
+ }
117
+ .twisty:hover {
118
+ color: var(--kern-ink-900);
119
+ }
120
+ .twisty span {
121
+ display: inline-flex;
122
+ transition: transform var(--kern-dur-fast) var(--kern-ease-out);
123
+ }
124
+ .twisty span.open {
125
+ transform: rotate(90deg);
126
+ }
127
+ /* The chevron points along the reading direction, so it mirrors with the document. */
128
+ :global([dir='rtl']) .twisty span {
129
+ transform: scaleX(-1);
130
+ }
131
+ :global([dir='rtl']) .twisty span.open {
132
+ transform: rotate(-90deg);
133
+ }
134
+ .flag {
135
+ display: inline-flex;
136
+ color: var(--kern-ink-400);
137
+ flex: none;
138
+ }
139
+ .add {
140
+ opacity: 0;
141
+ flex: none;
142
+ }
143
+ .row:hover .add,
144
+ .row:focus-within .add {
145
+ opacity: 1;
146
+ }
147
+ </style>
@@ -0,0 +1,248 @@
1
+ <script lang="ts">
2
+ import {
3
+ Button,
4
+ EmptyState,
5
+ navigation,
6
+ SearchBox,
7
+ SectionLabel,
8
+ Select,
9
+ Skeleton,
10
+ session,
11
+ } from '@kernhq/ui'
12
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
13
+ import { getQuireApi } from '../api-instance.js'
14
+ import { t } from '../i18n.js'
15
+ import { buildPageTree, type PageTreeNode } from '../index.js'
16
+ import { canQuire } from '../permissions.js'
17
+ import { quireKeys } from '../query.js'
18
+ import PageTreeRow from './PageTreeRow.svelte'
19
+
20
+ /**
21
+ * Quire's spaces and pages, in the application sidebar (DESIGN.md §2.3).
22
+ *
23
+ * The sidebar belongs to whichever module you are in — that is why a wiki gets a space switcher, a
24
+ * "Search this space" box and its page tree here rather than a third column. The tree is the table
25
+ * of contents, so it shows every level at once and comes from one request per space.
26
+ */
27
+ const api = getQuireApi()
28
+ const client = useQueryClient()
29
+
30
+ const workspaceSlug = $derived(navigation.workspaceSlug)
31
+ const workspace = $derived(session.workspaces.find((w) => w.slug === workspaceSlug))
32
+ const workspaceId = $derived(workspace?.id ?? '')
33
+
34
+ const spaceKeyInUrl = $derived(navigation.params.space ?? null)
35
+ const activePageId = $derived(navigation.params.page ?? null)
36
+
37
+ const spacesQuery = createQuery(() => ({
38
+ queryKey: quireKeys.spaces(workspaceId),
39
+ enabled: Boolean(workspaceId),
40
+ queryFn: () => api.spaces.list({ workspaceId, includeArchived: false }),
41
+ }))
42
+
43
+ const spaceList = $derived(spacesQuery.data ?? [])
44
+ const activeSpace = $derived(spaceList.find((space) => space.key === spaceKeyInUrl) ?? spaceList[0] ?? null)
45
+
46
+ const treeQuery = createQuery(() => ({
47
+ queryKey: quireKeys.tree(workspaceId, activeSpace?.id ?? ''),
48
+ enabled: Boolean(workspaceId && activeSpace),
49
+ queryFn: () => api.pages.tree({ workspaceId, spaceId: activeSpace?.id ?? '', includeArchived: false }),
50
+ }))
51
+
52
+ let search = $state('')
53
+ let expanded = $state(new Set<string>())
54
+
55
+ const nodes = $derived(treeQuery.data ?? [])
56
+ const roots = $derived(buildPageTree(nodes))
57
+
58
+ /**
59
+ * Searching replaces the tree in the same scroll area rather than appearing under it, so the results
60
+ * are where the list was and never below the fold. It filters what is already loaded — the whole
61
+ * space is in memory — so it costs no request and answers as you type.
62
+ */
63
+ const query = $derived(search.trim().toLowerCase())
64
+ const matches = $derived(
65
+ query ? nodes.filter((n) => (n.title || t('untitled')).toLowerCase().includes(query)) : [],
66
+ )
67
+
68
+ /** A page opened from a link may be nested; its ancestors have to be open for it to be visible. */
69
+ $effect(() => {
70
+ if (!activePageId || nodes.length === 0) return
71
+ const byId = new Map(nodes.map((n) => [n.id, n]))
72
+ const next = new Set(expanded)
73
+ let cursor = byId.get(activePageId)?.parentId ?? null
74
+ let guard = 0
75
+ while (cursor && guard++ < 100) {
76
+ next.add(cursor)
77
+ cursor = byId.get(cursor)?.parentId ?? null
78
+ }
79
+ if (next.size !== expanded.size) expanded = next
80
+ })
81
+
82
+ function toggle(id: string) {
83
+ const next = new Set(expanded)
84
+ if (!next.delete(id)) next.add(id)
85
+ expanded = next
86
+ }
87
+
88
+ function openPage(id: string) {
89
+ if (!activeSpace) return
90
+ void navigation.go(
91
+ `/${workspaceSlug}/quire/${encodeURIComponent(activeSpace.key)}/${encodeURIComponent(id)}`,
92
+ )
93
+ }
94
+
95
+ function switchSpace(key: string) {
96
+ void navigation.go(`/${workspaceSlug}/quire/${encodeURIComponent(key)}`)
97
+ }
98
+
99
+ let creating = $state(false)
100
+
101
+ async function createPage(parentId: string | null) {
102
+ if (!activeSpace || creating) return
103
+ creating = true
104
+ try {
105
+ const created = await api.pages.create({
106
+ workspaceId,
107
+ spaceId: activeSpace.id,
108
+ parentId,
109
+ title: '',
110
+ kind: 'page',
111
+ icon: null,
112
+ afterId: null,
113
+ })
114
+ await client.invalidateQueries({ queryKey: quireKeys.tree(workspaceId, activeSpace.id) })
115
+ if (parentId) expanded = new Set(expanded).add(parentId)
116
+ openPage(created.id)
117
+ } finally {
118
+ creating = false
119
+ }
120
+ }
121
+ </script>
122
+
123
+ <div class="wrap">
124
+ {#if spaceList.length > 1}
125
+ <div class="switcher">
126
+ <Select
127
+ value={activeSpace?.key ?? ''}
128
+ options={spaceList.map((space) => ({ value: space.key, label: space.name }))}
129
+ onValueChange={(v: string) => switchSpace(v)}
130
+ />
131
+ </div>
132
+ {/if}
133
+
134
+ <div class="strip">
135
+ <SearchBox bind:value={search} placeholder={t('search_space')} />
136
+ </div>
137
+
138
+ <div class="scroll">
139
+ {#if spacesQuery.isLoading || treeQuery.isLoading}
140
+ <div class="loading">
141
+ {#each [1, 2, 3, 4, 5] as n (n)}
142
+ <Skeleton height="34px" />
143
+ {/each}
144
+ </div>
145
+ {:else if spacesQuery.isError || treeQuery.isError}
146
+ <EmptyState
147
+ icon="triangle-alert"
148
+ title={t('tree_error')}
149
+ description={t('tree_error_desc')}
150
+ >
151
+ {#snippet actions()}
152
+ <Button
153
+ variant="secondary"
154
+ size="sm"
155
+ onclick={() => {
156
+ void spacesQuery.refetch()
157
+ void treeQuery.refetch()
158
+ }}
159
+ >
160
+ {t('common.retry')}
161
+ </Button>
162
+ {/snippet}
163
+ </EmptyState>
164
+ {:else if spaceList.length === 0}
165
+ <EmptyState icon="scroll-text" title={t('no_spaces')} description={t('no_spaces_desc')} />
166
+ {:else if query}
167
+ <SectionLabel label={t('search_results')} />
168
+ {#if matches.length === 0}
169
+ <p class="none">{t('search_none')}</p>
170
+ {:else}
171
+ {#each matches as node (node.id)}
172
+ <PageTreeRow
173
+ node={{ ...node, children: [] } as PageTreeNode}
174
+ depth={0}
175
+ activeId={activePageId}
176
+ expanded={new Set()}
177
+ onToggle={() => {}}
178
+ onOpen={openPage}
179
+ onCreateChild={createPage}
180
+ canCreate={false}
181
+ />
182
+ {/each}
183
+ {/if}
184
+ {:else}
185
+ <SectionLabel label={activeSpace?.name ?? t('nav')} />
186
+ {#if roots.length === 0}
187
+ <p class="none">{t('space_empty')}</p>
188
+ {:else}
189
+ {#each roots as node (node.id)}
190
+ <PageTreeRow
191
+ {node}
192
+ depth={0}
193
+ activeId={activePageId}
194
+ {expanded}
195
+ onToggle={toggle}
196
+ onOpen={openPage}
197
+ onCreateChild={createPage}
198
+ canCreate={canQuire('pageCreate')}
199
+ />
200
+ {/each}
201
+ {/if}
202
+
203
+ {#if canQuire('pageCreate') && activeSpace}
204
+ <div class="new">
205
+ <Button variant="ghost" size="sm" icon="plus" disabled={creating} onclick={() => createPage(null)}>
206
+ {t('new_page')}
207
+ </Button>
208
+ </div>
209
+ {/if}
210
+ {/if}
211
+ </div>
212
+ </div>
213
+
214
+ <style>
215
+ .wrap {
216
+ display: flex;
217
+ flex-direction: column;
218
+ min-height: 0;
219
+ flex: 1;
220
+ }
221
+ .switcher {
222
+ padding: 10px 12px 0;
223
+ }
224
+ .strip {
225
+ padding: 12px 12px 4px;
226
+ }
227
+ .scroll {
228
+ flex: 1;
229
+ overflow-y: auto;
230
+ padding: 0 12px 14px;
231
+ min-height: 0;
232
+ }
233
+ .loading {
234
+ display: flex;
235
+ flex-direction: column;
236
+ gap: 6px;
237
+ padding-block-start: 8px;
238
+ }
239
+ .none {
240
+ padding: 10px;
241
+ font-size: 13px;
242
+ color: var(--kern-ink-400);
243
+ margin: 0;
244
+ }
245
+ .new {
246
+ padding-block-start: 6px;
247
+ }
248
+ </style>
@@ -0,0 +1,159 @@
1
+ <script lang="ts">
2
+ import { Avatar, Badge, Button, EmptyState, ListRow, relativeTime, Sheet, Skeleton } from '@kernhq/ui'
3
+ import { createQuery, useQueryClient } from '@tanstack/svelte-query'
4
+ import { getQuireApi } from '../api-instance.js'
5
+ import { t } from '../i18n.js'
6
+ import type { PageVersion } from '../index.js'
7
+ import { canQuire } from '../permissions.js'
8
+ import { quireKeys } from '../query.js'
9
+
10
+ /**
11
+ * What a page used to say, and how to put it back.
12
+ *
13
+ * Restoring is offered without a confirmation on purpose: it captures the state it replaces first,
14
+ * so it is undoable by restoring the version it just made. A confirmation dialog on a reversible
15
+ * action trains people to click through the ones that are not.
16
+ */
17
+ interface Props {
18
+ open: boolean
19
+ workspaceId: string
20
+ pageId: string
21
+ publishedVersionId: string | null
22
+ }
23
+ let { open = $bindable(false), workspaceId, pageId, publishedVersionId }: Props = $props()
24
+
25
+ const api = getQuireApi()
26
+ const client = useQueryClient()
27
+
28
+ const query = createQuery(() => ({
29
+ queryKey: [...quireKeys.page(workspaceId, pageId), 'versions'],
30
+ enabled: open && Boolean(workspaceId && pageId),
31
+ queryFn: () => api.versions.list({ workspaceId, pageId, limit: 50 }),
32
+ }))
33
+
34
+ const versions = $derived(query.data?.items ?? [])
35
+
36
+ let restoring = $state<string | null>(null)
37
+ let error = $state<string | null>(null)
38
+
39
+ async function restore(version: PageVersion) {
40
+ if (restoring) return
41
+ restoring = version.id
42
+ error = null
43
+ try {
44
+ await api.versions.restore({ workspaceId, versionId: version.id })
45
+ await client.invalidateQueries({ queryKey: quireKeys.page(workspaceId, pageId) })
46
+ await query.refetch()
47
+ } catch (err) {
48
+ error = err instanceof Error ? err.message : String(err)
49
+ } finally {
50
+ restoring = null
51
+ }
52
+ }
53
+
54
+ const kindLabel = (v: PageVersion) =>
55
+ v.kind === 'publish'
56
+ ? t('version_published')
57
+ : v.kind === 'restore'
58
+ ? t('version_restored')
59
+ : v.kind === 'import'
60
+ ? t('version_imported')
61
+ : t('version_auto')
62
+ </script>
63
+
64
+ <Sheet bind:open title={t('history')} width={420}>
65
+ {#if query.isLoading}
66
+ <div class="rows">
67
+ {#each [1, 2, 3, 4] as n (n)}<Skeleton height="56px" />{/each}
68
+ </div>
69
+ {:else if query.isError}
70
+ <EmptyState icon="triangle-alert" title={t('history_error')} description={t('common.retry')}>
71
+ {#snippet actions()}
72
+ <Button variant="secondary" onclick={() => void query.refetch()}>{t('common.retry')}</Button>
73
+ {/snippet}
74
+ </EmptyState>
75
+ {:else if versions.length === 0}
76
+ <EmptyState icon="scroll-text" title={t('history_empty')} description={t('history_empty_desc')} />
77
+ {:else}
78
+ {#if error}<p class="error" role="alert">{error}</p>{/if}
79
+ <div class="rows">
80
+ {#each versions as version (version.id)}
81
+ <ListRow>
82
+ <div class="row">
83
+ <Avatar id={version.authorId} size={24} />
84
+ <div class="meta">
85
+ <div class="line">
86
+ <span class="when">{relativeTime(version.createdAt)}</span>
87
+ {#if version.id === publishedVersionId}
88
+ <Badge tone="active">{t('version_live')}</Badge>
89
+ {:else}
90
+ <span class="kind">{version.label || kindLabel(version)}</span>
91
+ {/if}
92
+ </div>
93
+ {#if version.preview}
94
+ <p class="preview">{version.preview}</p>
95
+ {/if}
96
+ </div>
97
+ {#if canQuire('pageEdit') && version.id !== publishedVersionId}
98
+ <Button
99
+ size="sm"
100
+ variant="secondary"
101
+ disabled={restoring !== null}
102
+ onclick={() => restore(version)}
103
+ >
104
+ {restoring === version.id ? t('restoring') : t('restore')}
105
+ </Button>
106
+ {/if}
107
+ </div>
108
+ </ListRow>
109
+ {/each}
110
+ </div>
111
+ {/if}
112
+ </Sheet>
113
+
114
+ <style>
115
+ .rows {
116
+ display: flex;
117
+ flex-direction: column;
118
+ gap: 4px;
119
+ }
120
+ .row {
121
+ display: flex;
122
+ align-items: flex-start;
123
+ gap: 10px;
124
+ width: 100%;
125
+ }
126
+ .meta {
127
+ flex: 1;
128
+ min-width: 0;
129
+ }
130
+ .line {
131
+ display: flex;
132
+ align-items: center;
133
+ gap: 8px;
134
+ }
135
+ .when {
136
+ font-size: 13.5px;
137
+ font-weight: 500;
138
+ color: var(--kern-ink-900);
139
+ }
140
+ .kind {
141
+ font-size: 12.5px;
142
+ color: var(--kern-ink-400);
143
+ }
144
+ .preview {
145
+ margin: 3px 0 0;
146
+ font-size: 12.5px;
147
+ line-height: 1.45;
148
+ color: var(--kern-ink-400);
149
+ overflow: hidden;
150
+ display: -webkit-box;
151
+ -webkit-line-clamp: 2;
152
+ -webkit-box-orient: vertical;
153
+ }
154
+ .error {
155
+ margin: 0 0 10px;
156
+ font-size: 13px;
157
+ color: var(--kern-danger);
158
+ }
159
+ </style>