@frockbot/client-ui 0.0.0 → 0.1.1
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.
- package/package.json +24 -6
- package/src/UiAnchor.vue +159 -0
- package/src/UiButton.vue +78 -0
- package/src/UiField.vue +59 -0
- package/src/UiIcon.vue +47 -0
- package/src/UiIconButton.vue +141 -0
- package/src/UiMarkdown.vue +156 -0
- package/src/UiSidebarOverlay.vue +130 -0
- package/src/UiSkeleton.vue +68 -0
- package/src/anchors.ts +23 -0
- package/src/env.d.ts +6 -0
- package/src/icons.ts +72 -0
- package/src/index.ts +17 -0
- package/src/markdown.test.ts +46 -0
- package/src/markdown.ts +34 -0
- package/src/surfaces.test.ts +66 -0
- package/src/surfaces.ts +48 -0
- package/tsconfig.json +14 -0
- package/README.md +0 -3
package/package.json
CHANGED
|
@@ -1,14 +1,32 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@frockbot/client-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"private": false,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": "./src/index.ts",
|
|
8
|
+
"./package.json": "./package.json"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test src",
|
|
12
|
+
"typecheck": "vue-tsc --noEmit -p tsconfig.json"
|
|
13
|
+
},
|
|
14
|
+
"dependencies": {
|
|
15
|
+
"@frockbot/client-core": "0.1.1",
|
|
16
|
+
"markdown-it": "15.0.1",
|
|
17
|
+
"vue": "3.5.41"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/bun": "1.4.0",
|
|
21
|
+
"typescript": "5.9.3",
|
|
22
|
+
"vue-tsc": "3.3.10"
|
|
23
|
+
},
|
|
24
|
+
"publishConfig": {
|
|
25
|
+
"access": "public"
|
|
26
|
+
},
|
|
6
27
|
"repository": {
|
|
7
28
|
"type": "git",
|
|
8
29
|
"url": "git+https://github.com/timoconnellaus/frockbot.git",
|
|
9
30
|
"directory": "packages/client-ui"
|
|
10
|
-
},
|
|
11
|
-
"publishConfig": {
|
|
12
|
-
"access": "public"
|
|
13
31
|
}
|
|
14
32
|
}
|
package/src/UiAnchor.vue
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* One deep-linkable settings row or section.
|
|
4
|
+
*
|
|
5
|
+
* It gives its content a stable DOM id, offers a copy-link control, and
|
|
6
|
+
* highlights itself when a link resolves to it. Two things can make it the
|
|
7
|
+
* target: the shell announcing an anchor after it opened the surface, and the
|
|
8
|
+
* document's own fragment at the moment this row mounts — a panel loads its
|
|
9
|
+
* state asynchronously, so a row frequently appears *after* the link that
|
|
10
|
+
* named it was handled, and polling for it from the shell would be the wrong
|
|
11
|
+
* side of the seam.
|
|
12
|
+
*/
|
|
13
|
+
import { onBeforeUnmount, onMounted, ref } from "vue";
|
|
14
|
+
import UiIconButton from "./UiIconButton.vue";
|
|
15
|
+
import {
|
|
16
|
+
UI_ANCHOR_EVENT,
|
|
17
|
+
UI_ANCHOR_HIGHLIGHT_MS,
|
|
18
|
+
type UiAnchorEvent,
|
|
19
|
+
} from "./anchors.js";
|
|
20
|
+
|
|
21
|
+
const props = withDefaults(
|
|
22
|
+
defineProps<{
|
|
23
|
+
/** The fragment identifier, and this element's DOM id. */
|
|
24
|
+
anchor: string;
|
|
25
|
+
/** What the row is called; used for the copy control's accessible name. */
|
|
26
|
+
label: string;
|
|
27
|
+
/** The link the copy control puts on the clipboard. */
|
|
28
|
+
href?: string;
|
|
29
|
+
/** An `article` for a card, a `section` for a titled block. */
|
|
30
|
+
as?: "div" | "section" | "article";
|
|
31
|
+
}>(),
|
|
32
|
+
{ href: undefined, as: "div" },
|
|
33
|
+
);
|
|
34
|
+
|
|
35
|
+
/*
|
|
36
|
+
* Two states, deliberately. `highlighted` is the flash and fades; `targeted`
|
|
37
|
+
* records that a link resolved to this row and stays, so a reader who looks
|
|
38
|
+
* away can still tell which row they were sent to — and so a test can assert
|
|
39
|
+
* the link landed without racing the fade.
|
|
40
|
+
*/
|
|
41
|
+
const highlighted = ref(false);
|
|
42
|
+
const targeted = ref(false);
|
|
43
|
+
const copied = ref(false);
|
|
44
|
+
const root = ref<HTMLElement>();
|
|
45
|
+
let highlightTimer: ReturnType<typeof setTimeout> | undefined;
|
|
46
|
+
let copiedTimer: ReturnType<typeof setTimeout> | undefined;
|
|
47
|
+
|
|
48
|
+
function reveal(): void {
|
|
49
|
+
targeted.value = true;
|
|
50
|
+
highlighted.value = true;
|
|
51
|
+
clearTimeout(highlightTimer);
|
|
52
|
+
highlightTimer = setTimeout(() => {
|
|
53
|
+
highlighted.value = false;
|
|
54
|
+
}, UI_ANCHOR_HIGHLIGHT_MS);
|
|
55
|
+
const element = root.value;
|
|
56
|
+
if (!element?.scrollIntoView) return;
|
|
57
|
+
const reduced =
|
|
58
|
+
typeof window !== "undefined" &&
|
|
59
|
+
typeof window.matchMedia === "function" &&
|
|
60
|
+
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
|
61
|
+
element.scrollIntoView({
|
|
62
|
+
behavior: reduced ? "auto" : "smooth",
|
|
63
|
+
block: "nearest",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function onAnnounced(event: Event): void {
|
|
68
|
+
if ((event as UiAnchorEvent).detail === props.anchor) reveal();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
onMounted(() => {
|
|
72
|
+
window.addEventListener(UI_ANCHOR_EVENT, onAnnounced);
|
|
73
|
+
const fragment = decodeURIComponent(
|
|
74
|
+
window.location.hash.replace(/^#/u, "") || "",
|
|
75
|
+
);
|
|
76
|
+
if (fragment === props.anchor) reveal();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
onBeforeUnmount(() => {
|
|
80
|
+
window.removeEventListener(UI_ANCHOR_EVENT, onAnnounced);
|
|
81
|
+
clearTimeout(highlightTimer);
|
|
82
|
+
clearTimeout(copiedTimer);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
async function copyLink(): Promise<void> {
|
|
86
|
+
if (!props.href) return;
|
|
87
|
+
const absolute = new URL(props.href, window.location.href).href;
|
|
88
|
+
try {
|
|
89
|
+
await navigator.clipboard.writeText(absolute);
|
|
90
|
+
copied.value = true;
|
|
91
|
+
clearTimeout(copiedTimer);
|
|
92
|
+
copiedTimer = setTimeout(() => {
|
|
93
|
+
copied.value = false;
|
|
94
|
+
}, UI_ANCHOR_HIGHLIGHT_MS);
|
|
95
|
+
} catch {
|
|
96
|
+
// Clipboard access can be refused, and a refused copy is not a product
|
|
97
|
+
// failure: the row is already addressable, and the User can read the link
|
|
98
|
+
// off the address bar after following it once.
|
|
99
|
+
copied.value = false;
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
</script>
|
|
103
|
+
|
|
104
|
+
<template>
|
|
105
|
+
<component
|
|
106
|
+
:is="as"
|
|
107
|
+
:id="anchor"
|
|
108
|
+
ref="root"
|
|
109
|
+
class="ui-anchor"
|
|
110
|
+
:class="{ 'ui-anchor--target': highlighted }"
|
|
111
|
+
:data-anchor-target="targeted ? 'true' : undefined"
|
|
112
|
+
>
|
|
113
|
+
<slot />
|
|
114
|
+
<UiIconButton
|
|
115
|
+
v-if="href"
|
|
116
|
+
class="ui-anchor__copy"
|
|
117
|
+
:icon="copied ? 'check' : 'link'"
|
|
118
|
+
:label="copied ? `Copied link to ${label}` : `Copy link to ${label}`"
|
|
119
|
+
size="sm"
|
|
120
|
+
@click="copyLink"
|
|
121
|
+
/>
|
|
122
|
+
</component>
|
|
123
|
+
</template>
|
|
124
|
+
|
|
125
|
+
<style scoped>
|
|
126
|
+
.ui-anchor {
|
|
127
|
+
position: relative;
|
|
128
|
+
border-radius: var(--frock-radius-control);
|
|
129
|
+
scroll-margin-block: 12px;
|
|
130
|
+
transition:
|
|
131
|
+
background-color var(--frock-motion-panel),
|
|
132
|
+
box-shadow var(--frock-motion-panel);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
.ui-anchor--target {
|
|
136
|
+
background: var(--frock-surface-accent-soft);
|
|
137
|
+
box-shadow: 0 0 0 2px var(--frock-border-focus);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
.ui-anchor__copy {
|
|
141
|
+
position: absolute;
|
|
142
|
+
top: 0;
|
|
143
|
+
right: 0;
|
|
144
|
+
opacity: 0;
|
|
145
|
+
transition: opacity var(--frock-motion-fast);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
.ui-anchor:hover .ui-anchor__copy,
|
|
149
|
+
.ui-anchor:focus-within .ui-anchor__copy,
|
|
150
|
+
.ui-anchor[data-anchor-target] .ui-anchor__copy {
|
|
151
|
+
opacity: 1;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
@media (hover: none) {
|
|
155
|
+
.ui-anchor__copy {
|
|
156
|
+
opacity: 1;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
</style>
|
package/src/UiButton.vue
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
withDefaults(
|
|
3
|
+
defineProps<{
|
|
4
|
+
type?: "button" | "submit" | "reset";
|
|
5
|
+
variant?: "primary" | "secondary" | "danger" | "ghost";
|
|
6
|
+
disabled?: boolean;
|
|
7
|
+
}>(),
|
|
8
|
+
{ type: "button", variant: "secondary", disabled: false },
|
|
9
|
+
);
|
|
10
|
+
</script>
|
|
11
|
+
|
|
12
|
+
<template>
|
|
13
|
+
<button
|
|
14
|
+
:type="type"
|
|
15
|
+
class="ui-button"
|
|
16
|
+
:class="`ui-button--${variant}`"
|
|
17
|
+
:disabled="disabled"
|
|
18
|
+
>
|
|
19
|
+
<slot />
|
|
20
|
+
</button>
|
|
21
|
+
</template>
|
|
22
|
+
|
|
23
|
+
<style scoped>
|
|
24
|
+
.ui-button {
|
|
25
|
+
min-height: 36px;
|
|
26
|
+
padding: 0 16px;
|
|
27
|
+
border: 1px solid transparent;
|
|
28
|
+
border-radius: var(--frock-radius-control);
|
|
29
|
+
color: var(--frock-text);
|
|
30
|
+
background: transparent;
|
|
31
|
+
font-size: var(--frock-text-base);
|
|
32
|
+
font-weight: 700;
|
|
33
|
+
white-space: nowrap;
|
|
34
|
+
cursor: pointer;
|
|
35
|
+
transition:
|
|
36
|
+
background-color var(--frock-motion-fast),
|
|
37
|
+
border-color var(--frock-motion-fast),
|
|
38
|
+
color var(--frock-motion-fast),
|
|
39
|
+
transform var(--frock-motion-fast);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
.ui-button:active:not(:disabled) {
|
|
43
|
+
transform: scale(0.97);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
.ui-button--primary {
|
|
47
|
+
color: var(--frock-on-accent);
|
|
48
|
+
background: var(--frock-action-primary);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.ui-button--primary:hover:not(:disabled) {
|
|
52
|
+
background: var(--frock-action-primary-hover);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ui-button--primary:active:not(:disabled) {
|
|
56
|
+
background: var(--frock-action-primary-pressed);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
.ui-button--secondary {
|
|
60
|
+
border-color: var(--frock-border);
|
|
61
|
+
background: var(--frock-surface-raised);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
.ui-button--secondary:hover:not(:disabled),
|
|
65
|
+
.ui-button--ghost:hover:not(:disabled) {
|
|
66
|
+
background: var(--frock-surface-subtle);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.ui-button--danger {
|
|
70
|
+
color: var(--frock-danger-text);
|
|
71
|
+
background: var(--frock-danger-surface);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
.ui-button:disabled {
|
|
75
|
+
opacity: 0.55;
|
|
76
|
+
cursor: default;
|
|
77
|
+
}
|
|
78
|
+
</style>
|
package/src/UiField.vue
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{
|
|
3
|
+
label: string;
|
|
4
|
+
hint?: string;
|
|
5
|
+
}>();
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<label class="ui-field">
|
|
10
|
+
<span class="ui-field__label">
|
|
11
|
+
{{ label }} <small v-if="hint">{{ hint }}</small>
|
|
12
|
+
</span>
|
|
13
|
+
<slot />
|
|
14
|
+
</label>
|
|
15
|
+
</template>
|
|
16
|
+
|
|
17
|
+
<style scoped>
|
|
18
|
+
.ui-field {
|
|
19
|
+
display: flex;
|
|
20
|
+
flex-direction: column;
|
|
21
|
+
gap: 7px;
|
|
22
|
+
color: var(--frock-text-muted);
|
|
23
|
+
font-size: var(--frock-text-sm);
|
|
24
|
+
font-weight: 700;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
.ui-field__label small {
|
|
28
|
+
color: var(--frock-text-subtle);
|
|
29
|
+
font-weight: 500;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
.ui-field :deep(input),
|
|
33
|
+
.ui-field :deep(textarea),
|
|
34
|
+
.ui-field :deep(select) {
|
|
35
|
+
width: 100%;
|
|
36
|
+
border: 1px solid var(--frock-border-strong);
|
|
37
|
+
border-radius: var(--frock-radius-control);
|
|
38
|
+
outline: none;
|
|
39
|
+
padding: 10px 12px;
|
|
40
|
+
color: var(--frock-text);
|
|
41
|
+
background: var(--frock-surface-raised);
|
|
42
|
+
font-size: var(--frock-text-md);
|
|
43
|
+
transition:
|
|
44
|
+
border-color var(--frock-motion-fast),
|
|
45
|
+
box-shadow var(--frock-motion-fast);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.ui-field :deep(input:focus),
|
|
49
|
+
.ui-field :deep(textarea:focus),
|
|
50
|
+
.ui-field :deep(select:focus) {
|
|
51
|
+
border-color: var(--frock-border-focus);
|
|
52
|
+
box-shadow: 0 0 0 3px var(--frock-focus-ring);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
.ui-field :deep(textarea) {
|
|
56
|
+
resize: vertical;
|
|
57
|
+
line-height: var(--frock-leading-normal);
|
|
58
|
+
}
|
|
59
|
+
</style>
|
package/src/UiIcon.vue
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { uiIconPaths, type UiIconName } from "./icons.js";
|
|
4
|
+
|
|
5
|
+
const props = withDefaults(
|
|
6
|
+
defineProps<{
|
|
7
|
+
name: UiIconName;
|
|
8
|
+
/** Rendered box in CSS pixels. Defaults to the medium icon token. */
|
|
9
|
+
size?: number | "sm" | "md" | "lg";
|
|
10
|
+
/** Stroke width in 24-unit viewBox units. */
|
|
11
|
+
weight?: number;
|
|
12
|
+
}>(),
|
|
13
|
+
{ size: "md", weight: 1.75 },
|
|
14
|
+
);
|
|
15
|
+
|
|
16
|
+
const dimension = computed(() =>
|
|
17
|
+
typeof props.size === "number"
|
|
18
|
+
? `${props.size}px`
|
|
19
|
+
: `var(--frock-icon-${props.size})`,
|
|
20
|
+
);
|
|
21
|
+
const segments = computed(() => uiIconPaths[props.name]);
|
|
22
|
+
const filled = computed(() => props.name === "stop");
|
|
23
|
+
</script>
|
|
24
|
+
|
|
25
|
+
<template>
|
|
26
|
+
<svg
|
|
27
|
+
class="ui-icon"
|
|
28
|
+
:style="{ width: dimension, height: dimension }"
|
|
29
|
+
viewBox="0 0 24 24"
|
|
30
|
+
:fill="filled ? 'currentColor' : 'none'"
|
|
31
|
+
stroke="currentColor"
|
|
32
|
+
:stroke-width="weight"
|
|
33
|
+
stroke-linecap="round"
|
|
34
|
+
stroke-linejoin="round"
|
|
35
|
+
aria-hidden="true"
|
|
36
|
+
focusable="false"
|
|
37
|
+
>
|
|
38
|
+
<path v-for="(segment, index) in segments" :key="index" :d="segment" />
|
|
39
|
+
</svg>
|
|
40
|
+
</template>
|
|
41
|
+
|
|
42
|
+
<style scoped>
|
|
43
|
+
.ui-icon {
|
|
44
|
+
display: block;
|
|
45
|
+
flex: 0 0 auto;
|
|
46
|
+
}
|
|
47
|
+
</style>
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import UiIcon from "./UiIcon.vue";
|
|
3
|
+
import type { UiIconName } from "./icons.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A square control that holds exactly one icon. The icon is centred by the
|
|
7
|
+
* grid, not by line metrics, so it never drifts the way a text glyph does.
|
|
8
|
+
* `label` is mandatory because the control has no visible text.
|
|
9
|
+
*/
|
|
10
|
+
withDefaults(
|
|
11
|
+
defineProps<{
|
|
12
|
+
label: string;
|
|
13
|
+
icon?: UiIconName;
|
|
14
|
+
type?: "button" | "submit" | "reset";
|
|
15
|
+
/** ghost: no chrome until hover. outlined: hairline ring. primary: accent fill. */
|
|
16
|
+
variant?: "ghost" | "outlined" | "primary";
|
|
17
|
+
size?: "sm" | "md" | "lg";
|
|
18
|
+
shape?: "round" | "square";
|
|
19
|
+
disabled?: boolean;
|
|
20
|
+
pressed?: boolean;
|
|
21
|
+
}>(),
|
|
22
|
+
{
|
|
23
|
+
icon: undefined,
|
|
24
|
+
type: "button",
|
|
25
|
+
variant: "ghost",
|
|
26
|
+
size: "md",
|
|
27
|
+
shape: "round",
|
|
28
|
+
disabled: false,
|
|
29
|
+
pressed: undefined,
|
|
30
|
+
},
|
|
31
|
+
);
|
|
32
|
+
</script>
|
|
33
|
+
|
|
34
|
+
<template>
|
|
35
|
+
<button
|
|
36
|
+
:type="type"
|
|
37
|
+
class="ui-icon-button"
|
|
38
|
+
:class="[
|
|
39
|
+
`ui-icon-button--${variant}`,
|
|
40
|
+
`ui-icon-button--${size}`,
|
|
41
|
+
`ui-icon-button--${shape}`,
|
|
42
|
+
]"
|
|
43
|
+
:disabled="disabled"
|
|
44
|
+
:aria-label="label"
|
|
45
|
+
:aria-pressed="pressed"
|
|
46
|
+
:title="label"
|
|
47
|
+
>
|
|
48
|
+
<slot>
|
|
49
|
+
<UiIcon v-if="icon" :name="icon" :size="size === 'sm' ? 'sm' : 'md'" />
|
|
50
|
+
</slot>
|
|
51
|
+
</button>
|
|
52
|
+
</template>
|
|
53
|
+
|
|
54
|
+
<style scoped>
|
|
55
|
+
.ui-icon-button {
|
|
56
|
+
display: grid;
|
|
57
|
+
flex: 0 0 auto;
|
|
58
|
+
place-items: center;
|
|
59
|
+
padding: 0;
|
|
60
|
+
border: 1px solid transparent;
|
|
61
|
+
color: var(--frock-text-muted);
|
|
62
|
+
background: transparent;
|
|
63
|
+
cursor: pointer;
|
|
64
|
+
-webkit-app-region: no-drag;
|
|
65
|
+
transition:
|
|
66
|
+
background-color var(--frock-motion-fast),
|
|
67
|
+
border-color var(--frock-motion-fast),
|
|
68
|
+
color var(--frock-motion-fast),
|
|
69
|
+
transform var(--frock-motion-fast);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.ui-icon-button--sm {
|
|
73
|
+
width: var(--frock-control-sm);
|
|
74
|
+
height: var(--frock-control-sm);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.ui-icon-button--md {
|
|
78
|
+
width: var(--frock-control-md);
|
|
79
|
+
height: var(--frock-control-md);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
.ui-icon-button--lg {
|
|
83
|
+
width: var(--frock-control-lg);
|
|
84
|
+
height: var(--frock-control-lg);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
.ui-icon-button--round {
|
|
88
|
+
border-radius: 999px;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
.ui-icon-button--square {
|
|
92
|
+
border-radius: var(--frock-radius-control);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
.ui-icon-button--outlined {
|
|
96
|
+
border-color: var(--frock-border);
|
|
97
|
+
background: var(--frock-surface);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
.ui-icon-button--primary {
|
|
101
|
+
color: var(--frock-on-accent);
|
|
102
|
+
background: var(--frock-action-primary);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
.ui-icon-button--ghost:hover:not(:disabled),
|
|
106
|
+
.ui-icon-button--outlined:hover:not(:disabled) {
|
|
107
|
+
color: var(--frock-text);
|
|
108
|
+
background: var(--frock-fill-hover);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.ui-icon-button--primary:hover:not(:disabled) {
|
|
112
|
+
background: var(--frock-action-primary-hover);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
.ui-icon-button--primary:active:not(:disabled) {
|
|
116
|
+
background: var(--frock-action-primary-pressed);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
.ui-icon-button:active:not(:disabled) {
|
|
120
|
+
transform: scale(0.94);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
.ui-icon-button--ghost:active:not(:disabled),
|
|
124
|
+
.ui-icon-button--outlined:active:not(:disabled) {
|
|
125
|
+
background: var(--frock-fill-pressed);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
.ui-icon-button[aria-pressed="true"] {
|
|
129
|
+
color: var(--frock-action-primary-hover);
|
|
130
|
+
background: var(--frock-surface-accent);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.ui-icon-button:disabled {
|
|
134
|
+
color: var(--frock-text-disabled);
|
|
135
|
+
cursor: default;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
.ui-icon-button--primary:disabled {
|
|
139
|
+
background: var(--frock-surface-disabled);
|
|
140
|
+
}
|
|
141
|
+
</style>
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { computed } from "vue";
|
|
3
|
+
import { renderMarkdown } from "./markdown.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Renders Markdown text. The HTML comes from `renderMarkdown`, which escapes
|
|
7
|
+
* every author tag, so `v-html` here injects only markup the parser produced.
|
|
8
|
+
*/
|
|
9
|
+
const props = defineProps<{ text: string }>();
|
|
10
|
+
const html = computed(() => renderMarkdown(props.text));
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<!-- eslint-disable-next-line vue/no-v-html -->
|
|
15
|
+
<div class="ui-markdown" v-html="html" />
|
|
16
|
+
</template>
|
|
17
|
+
|
|
18
|
+
<style scoped>
|
|
19
|
+
.ui-markdown {
|
|
20
|
+
overflow-wrap: anywhere;
|
|
21
|
+
/* The bubble sets pre-wrap for plain text; rendered Markdown owns its own
|
|
22
|
+
whitespace, so it opts back out. */
|
|
23
|
+
white-space: normal;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
.ui-markdown :deep(> :first-child) {
|
|
27
|
+
margin-top: 0;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
.ui-markdown :deep(> :last-child) {
|
|
31
|
+
margin-bottom: 0;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.ui-markdown :deep(p) {
|
|
35
|
+
margin: 0 0 8px;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
.ui-markdown :deep(ul),
|
|
39
|
+
.ui-markdown :deep(ol) {
|
|
40
|
+
margin: 0 0 8px;
|
|
41
|
+
padding-left: 20px;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ui-markdown :deep(li) {
|
|
45
|
+
margin: 2px 0;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
.ui-markdown :deep(li > ul),
|
|
49
|
+
.ui-markdown :deep(li > ol) {
|
|
50
|
+
margin-bottom: 0;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
.ui-markdown :deep(h1),
|
|
54
|
+
.ui-markdown :deep(h2),
|
|
55
|
+
.ui-markdown :deep(h3),
|
|
56
|
+
.ui-markdown :deep(h4) {
|
|
57
|
+
margin: 14px 0 6px;
|
|
58
|
+
font-weight: 600;
|
|
59
|
+
line-height: var(--frock-leading-snug);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
.ui-markdown :deep(h1) {
|
|
63
|
+
font-size: var(--frock-text-xl);
|
|
64
|
+
letter-spacing: var(--frock-tracking-display);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
.ui-markdown :deep(h2) {
|
|
68
|
+
font-size: var(--frock-text-lg);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
.ui-markdown :deep(h3) {
|
|
72
|
+
font-size: var(--frock-text-md);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
.ui-markdown :deep(h4) {
|
|
76
|
+
color: var(--frock-text-muted);
|
|
77
|
+
font-size: var(--frock-text-base);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
.ui-markdown :deep(strong) {
|
|
81
|
+
font-weight: 650;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
.ui-markdown :deep(a) {
|
|
85
|
+
color: var(--frock-action-primary-hover);
|
|
86
|
+
text-decoration: underline;
|
|
87
|
+
text-underline-offset: 2px;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.ui-markdown :deep(code) {
|
|
91
|
+
padding: 1px 5px;
|
|
92
|
+
border-radius: 6px;
|
|
93
|
+
background: var(--frock-fill-hover);
|
|
94
|
+
font-family: var(--frock-font-mono);
|
|
95
|
+
font-size: var(--frock-text-sm);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.ui-markdown :deep(pre) {
|
|
99
|
+
overflow-x: auto;
|
|
100
|
+
margin: 0 0 8px;
|
|
101
|
+
padding: 10px 12px;
|
|
102
|
+
border: 1px solid var(--frock-border);
|
|
103
|
+
border-radius: var(--frock-radius-control);
|
|
104
|
+
background: var(--frock-surface);
|
|
105
|
+
scrollbar-color: var(--frock-scrollbar) transparent;
|
|
106
|
+
scrollbar-width: thin;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
.ui-markdown :deep(pre code) {
|
|
110
|
+
display: block;
|
|
111
|
+
padding: 0;
|
|
112
|
+
border-radius: 0;
|
|
113
|
+
background: transparent;
|
|
114
|
+
line-height: var(--frock-leading-snug);
|
|
115
|
+
white-space: pre;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.ui-markdown :deep(blockquote) {
|
|
119
|
+
margin: 0 0 8px;
|
|
120
|
+
padding: 2px 0 2px 12px;
|
|
121
|
+
border-left: 2px solid var(--frock-border-strong);
|
|
122
|
+
color: var(--frock-text-muted);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.ui-markdown :deep(table) {
|
|
126
|
+
display: block;
|
|
127
|
+
overflow-x: auto;
|
|
128
|
+
max-width: 100%;
|
|
129
|
+
margin: 0 0 8px;
|
|
130
|
+
border-collapse: collapse;
|
|
131
|
+
font-size: var(--frock-text-sm);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
.ui-markdown :deep(th),
|
|
135
|
+
.ui-markdown :deep(td) {
|
|
136
|
+
padding: 5px 9px;
|
|
137
|
+
border: 1px solid var(--frock-border);
|
|
138
|
+
text-align: left;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
.ui-markdown :deep(th) {
|
|
142
|
+
background: var(--frock-surface);
|
|
143
|
+
font-weight: 600;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
.ui-markdown :deep(hr) {
|
|
147
|
+
margin: 12px 0;
|
|
148
|
+
border: 0;
|
|
149
|
+
border-top: 1px solid var(--frock-border);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.ui-markdown :deep(img) {
|
|
153
|
+
max-width: 100%;
|
|
154
|
+
border-radius: var(--frock-radius-control);
|
|
155
|
+
}
|
|
156
|
+
</style>
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { nextTick, onBeforeUnmount, ref, watch } from "vue";
|
|
3
|
+
import UiIconButton from "./UiIconButton.vue";
|
|
4
|
+
|
|
5
|
+
const props = defineProps<{
|
|
6
|
+
open: boolean;
|
|
7
|
+
title: string;
|
|
8
|
+
}>();
|
|
9
|
+
const emit = defineEmits<{ close: [] }>();
|
|
10
|
+
const panel = ref<HTMLElement>();
|
|
11
|
+
let restoreFocus: HTMLElement | undefined;
|
|
12
|
+
|
|
13
|
+
watch(
|
|
14
|
+
() => props.open,
|
|
15
|
+
async (open, previous) => {
|
|
16
|
+
if (open && !previous) {
|
|
17
|
+
restoreFocus =
|
|
18
|
+
document.activeElement instanceof HTMLElement
|
|
19
|
+
? document.activeElement
|
|
20
|
+
: undefined;
|
|
21
|
+
await nextTick();
|
|
22
|
+
panel.value?.querySelector<HTMLElement>("[data-surface-close]")?.focus();
|
|
23
|
+
} else if (!open && previous) {
|
|
24
|
+
restoreFocus?.focus();
|
|
25
|
+
restoreFocus = undefined;
|
|
26
|
+
}
|
|
27
|
+
},
|
|
28
|
+
{ flush: "post" },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
function onKeydown(event: KeyboardEvent): void {
|
|
32
|
+
if (event.key !== "Escape") return;
|
|
33
|
+
event.preventDefault();
|
|
34
|
+
emit("close");
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
onBeforeUnmount(() => restoreFocus?.focus());
|
|
38
|
+
</script>
|
|
39
|
+
|
|
40
|
+
<template>
|
|
41
|
+
<Transition name="ui-surface">
|
|
42
|
+
<aside
|
|
43
|
+
v-if="open"
|
|
44
|
+
ref="panel"
|
|
45
|
+
class="ui-sidebar-overlay"
|
|
46
|
+
role="region"
|
|
47
|
+
aria-labelledby="ui-sidebar-overlay-title"
|
|
48
|
+
@keydown="onKeydown"
|
|
49
|
+
>
|
|
50
|
+
<header class="ui-sidebar-overlay__header">
|
|
51
|
+
<h2 id="ui-sidebar-overlay-title">{{ title }}</h2>
|
|
52
|
+
<UiIconButton
|
|
53
|
+
data-surface-close
|
|
54
|
+
icon="close"
|
|
55
|
+
label="Close panel"
|
|
56
|
+
@click="emit('close')"
|
|
57
|
+
/>
|
|
58
|
+
</header>
|
|
59
|
+
<div class="ui-sidebar-overlay__content">
|
|
60
|
+
<slot />
|
|
61
|
+
</div>
|
|
62
|
+
</aside>
|
|
63
|
+
</Transition>
|
|
64
|
+
</template>
|
|
65
|
+
|
|
66
|
+
<style scoped>
|
|
67
|
+
.ui-sidebar-overlay {
|
|
68
|
+
position: absolute;
|
|
69
|
+
z-index: var(--frock-layer-surface);
|
|
70
|
+
inset: 0 auto 0 0;
|
|
71
|
+
display: flex;
|
|
72
|
+
width: min(var(--frock-settings-width), 100vw);
|
|
73
|
+
min-height: 0;
|
|
74
|
+
flex-direction: column;
|
|
75
|
+
border-right: 1px solid var(--frock-border);
|
|
76
|
+
color: var(--frock-text);
|
|
77
|
+
background: var(--frock-surface);
|
|
78
|
+
box-shadow: var(--frock-shadow-panel);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.ui-sidebar-overlay__header {
|
|
82
|
+
display: grid;
|
|
83
|
+
min-height: var(--frock-titlebar-height);
|
|
84
|
+
flex: 0 0 auto;
|
|
85
|
+
grid-template-columns: minmax(0, 1fr) auto;
|
|
86
|
+
align-items: center;
|
|
87
|
+
gap: 16px;
|
|
88
|
+
padding: 0 18px 0 24px;
|
|
89
|
+
border-bottom: 1px solid var(--frock-border);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
.ui-sidebar-overlay__header h2 {
|
|
93
|
+
overflow: hidden;
|
|
94
|
+
margin: 0;
|
|
95
|
+
font-family: var(--frock-font-display);
|
|
96
|
+
font-size: var(--frock-text-2xl);
|
|
97
|
+
font-weight: 400;
|
|
98
|
+
letter-spacing: var(--frock-tracking-display);
|
|
99
|
+
white-space: nowrap;
|
|
100
|
+
text-overflow: ellipsis;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
.ui-sidebar-overlay__content {
|
|
104
|
+
min-height: 0;
|
|
105
|
+
flex: 1;
|
|
106
|
+
overflow-y: auto;
|
|
107
|
+
scrollbar-color: var(--frock-scrollbar) transparent;
|
|
108
|
+
scrollbar-width: thin;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
.ui-surface-enter-active,
|
|
112
|
+
.ui-surface-leave-active {
|
|
113
|
+
transition:
|
|
114
|
+
transform var(--frock-motion-panel),
|
|
115
|
+
opacity var(--frock-motion-panel);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.ui-surface-enter-from,
|
|
119
|
+
.ui-surface-leave-to {
|
|
120
|
+
opacity: 0;
|
|
121
|
+
transform: translateX(-18px);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
@media (prefers-reduced-motion: reduce) {
|
|
125
|
+
.ui-surface-enter-active,
|
|
126
|
+
.ui-surface-leave-active {
|
|
127
|
+
transition-duration: 0.01ms;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
</style>
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* A loading placeholder that keeps the final layout's footprint so content
|
|
4
|
+
* does not jump when it arrives. Compose several to sketch a row or a card.
|
|
5
|
+
*/
|
|
6
|
+
withDefaults(
|
|
7
|
+
defineProps<{
|
|
8
|
+
width?: string;
|
|
9
|
+
height?: string;
|
|
10
|
+
shape?: "text" | "block" | "circle";
|
|
11
|
+
}>(),
|
|
12
|
+
{ width: "100%", height: undefined, shape: "text" },
|
|
13
|
+
);
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<template>
|
|
17
|
+
<span
|
|
18
|
+
class="ui-skeleton"
|
|
19
|
+
:class="`ui-skeleton--${shape}`"
|
|
20
|
+
:style="{ width, height }"
|
|
21
|
+
aria-hidden="true"
|
|
22
|
+
/>
|
|
23
|
+
</template>
|
|
24
|
+
|
|
25
|
+
<style scoped>
|
|
26
|
+
.ui-skeleton {
|
|
27
|
+
position: relative;
|
|
28
|
+
display: block;
|
|
29
|
+
overflow: hidden;
|
|
30
|
+
flex: 0 0 auto;
|
|
31
|
+
background: var(--frock-skeleton);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.ui-skeleton--text {
|
|
35
|
+
height: 0.75em;
|
|
36
|
+
border-radius: 4px;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
.ui-skeleton--block {
|
|
40
|
+
height: var(--frock-control-md);
|
|
41
|
+
border-radius: var(--frock-radius-control);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
.ui-skeleton--circle {
|
|
45
|
+
width: var(--frock-avatar-md);
|
|
46
|
+
height: var(--frock-avatar-md);
|
|
47
|
+
border-radius: 999px;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
.ui-skeleton::after {
|
|
51
|
+
position: absolute;
|
|
52
|
+
inset: 0;
|
|
53
|
+
content: "";
|
|
54
|
+
background: linear-gradient(
|
|
55
|
+
90deg,
|
|
56
|
+
transparent,
|
|
57
|
+
var(--frock-skeleton-shine),
|
|
58
|
+
transparent
|
|
59
|
+
);
|
|
60
|
+
animation: frock-shimmer 1.4s ease-in-out infinite;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
@media (prefers-reduced-motion: reduce) {
|
|
64
|
+
.ui-skeleton::after {
|
|
65
|
+
animation: none;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
</style>
|
package/src/anchors.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The client-side half of the settings deep-link scheme.
|
|
3
|
+
*
|
|
4
|
+
* The scheme itself lives in `@frockbot/plugin-shell/settings-links`, which
|
|
5
|
+
* `client-ui` must not depend on — a primitive cannot import the shell. What
|
|
6
|
+
* the primitives need is narrower: the name of the event the shell fires when
|
|
7
|
+
* a link resolves, so an anchored row can highlight itself without either side
|
|
8
|
+
* reaching into the other's styles.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** The window event a resolved settings link fires. `detail` is the anchor. */
|
|
12
|
+
export const UI_ANCHOR_EVENT = "frockbot:anchor";
|
|
13
|
+
|
|
14
|
+
export type UiAnchorEvent = CustomEvent<string>;
|
|
15
|
+
|
|
16
|
+
/** Tell every mounted anchor that this one is the link's target. */
|
|
17
|
+
export function announceUiAnchor(anchor: string): void {
|
|
18
|
+
if (typeof window === "undefined") return;
|
|
19
|
+
window.dispatchEvent(new CustomEvent(UI_ANCHOR_EVENT, { detail: anchor }));
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** How long an anchored row stays highlighted after a link resolves to it. */
|
|
23
|
+
export const UI_ANCHOR_HIGHLIGHT_MS = 2_600;
|
package/src/env.d.ts
ADDED
package/src/icons.ts
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shared icon set. Glyphs are 24-unit stroke paths so every icon shares
|
|
3
|
+
* the same optical weight and sits centred inside a control; plugins must not
|
|
4
|
+
* fall back to Unicode symbols, whose ink offsets differ per font.
|
|
5
|
+
*/
|
|
6
|
+
export type UiIconName =
|
|
7
|
+
| "arrow-down"
|
|
8
|
+
| "arrow-up"
|
|
9
|
+
| "check"
|
|
10
|
+
| "chevron-left"
|
|
11
|
+
| "chevrons-left"
|
|
12
|
+
| "chevrons-right"
|
|
13
|
+
| "close"
|
|
14
|
+
| "gear"
|
|
15
|
+
| "history"
|
|
16
|
+
| "link"
|
|
17
|
+
| "menu"
|
|
18
|
+
| "mic"
|
|
19
|
+
| "plugins"
|
|
20
|
+
| "plus"
|
|
21
|
+
| "refresh"
|
|
22
|
+
| "search"
|
|
23
|
+
| "sparkle"
|
|
24
|
+
| "stop"
|
|
25
|
+
| "user";
|
|
26
|
+
|
|
27
|
+
export const uiIconPaths: Record<UiIconName, string[]> = {
|
|
28
|
+
"arrow-down": ["m19 12-7 7-7-7", "M12 5v14"],
|
|
29
|
+
"arrow-up": ["m5 12 7-7 7 7", "M12 19V5"],
|
|
30
|
+
check: ["M20 6 9 17l-5-5"],
|
|
31
|
+
"chevron-left": ["m15 18-6-6 6-6"],
|
|
32
|
+
"chevrons-left": ["m11 17-5-5 5-5", "m18 17-5-5 5-5"],
|
|
33
|
+
"chevrons-right": ["m6 17 5-5-5-5", "m13 17 5-5-5-5"],
|
|
34
|
+
close: ["M18 6 6 18", "m6 6 12 12"],
|
|
35
|
+
gear: [
|
|
36
|
+
"M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z",
|
|
37
|
+
"M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z",
|
|
38
|
+
],
|
|
39
|
+
history: [
|
|
40
|
+
"M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8",
|
|
41
|
+
"M3 3v5h5",
|
|
42
|
+
"M12 7v5l4 2",
|
|
43
|
+
],
|
|
44
|
+
link: [
|
|
45
|
+
"M10 13a5 5 0 0 0 7.07 0l2.83-2.83a5 5 0 0 0-7.07-7.07L11.5 4.5",
|
|
46
|
+
"M14 11a5 5 0 0 0-7.07 0l-2.83 2.83a5 5 0 0 0 7.07 7.07L12.5 19.5",
|
|
47
|
+
],
|
|
48
|
+
menu: ["M4 7h16", "M4 12h16", "M4 17h16"],
|
|
49
|
+
mic: [
|
|
50
|
+
"M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3z",
|
|
51
|
+
"M19 10v2a7 7 0 0 1-14 0v-2",
|
|
52
|
+
"M12 19v3",
|
|
53
|
+
],
|
|
54
|
+
plugins: [
|
|
55
|
+
"M4 4h6v6H4z",
|
|
56
|
+
"M14 4h6v6h-6z",
|
|
57
|
+
"M4 14h6v6H4z",
|
|
58
|
+
"M17 14v6",
|
|
59
|
+
"M14 17h6",
|
|
60
|
+
],
|
|
61
|
+
plus: ["M5 12h14", "M12 5v14"],
|
|
62
|
+
refresh: [
|
|
63
|
+
"M3 12a9 9 0 0 1 9-9 9.75 9.75 0 0 1 6.74 2.74L21 8",
|
|
64
|
+
"M21 3v5h-5",
|
|
65
|
+
"M21 12a9 9 0 0 1-9 9 9.75 9.75 0 0 1-6.74-2.74L3 16",
|
|
66
|
+
"M8 16H3v5",
|
|
67
|
+
],
|
|
68
|
+
search: ["M11 19a8 8 0 1 0 0-16 8 8 0 0 0 0 16z", "m21 21-4.3-4.3"],
|
|
69
|
+
sparkle: ["M12 3l1.9 5.6 5.6 1.9-5.6 1.9L12 18l-1.9-5.6L4.5 10.5l5.6-1.9z"],
|
|
70
|
+
stop: ["M6 6h12v12H6z"],
|
|
71
|
+
user: ["M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8z", "M20 21a8 8 0 0 0-16 0"],
|
|
72
|
+
};
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export { default as UiSidebarOverlay } from "./UiSidebarOverlay.vue";
|
|
2
|
+
export { default as UiAnchor } from "./UiAnchor.vue";
|
|
3
|
+
export {
|
|
4
|
+
announceUiAnchor,
|
|
5
|
+
UI_ANCHOR_EVENT,
|
|
6
|
+
UI_ANCHOR_HIGHLIGHT_MS,
|
|
7
|
+
type UiAnchorEvent,
|
|
8
|
+
} from "./anchors.js";
|
|
9
|
+
export { default as UiButton } from "./UiButton.vue";
|
|
10
|
+
export { default as UiField } from "./UiField.vue";
|
|
11
|
+
export { default as UiIcon } from "./UiIcon.vue";
|
|
12
|
+
export { uiIconPaths, type UiIconName } from "./icons.js";
|
|
13
|
+
export { default as UiIconButton } from "./UiIconButton.vue";
|
|
14
|
+
export { default as UiMarkdown } from "./UiMarkdown.vue";
|
|
15
|
+
export { renderMarkdown } from "./markdown.js";
|
|
16
|
+
export { default as UiSkeleton } from "./UiSkeleton.vue";
|
|
17
|
+
export { createClientSurfaceRegistry } from "./surfaces.js";
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { renderMarkdown } from "./markdown.js";
|
|
3
|
+
|
|
4
|
+
describe("renderMarkdown", () => {
|
|
5
|
+
test("escapes author HTML instead of emitting it", () => {
|
|
6
|
+
const html = renderMarkdown('<img src=x onerror="alert(1)"> **bold**');
|
|
7
|
+
|
|
8
|
+
expect(html).not.toContain("<img");
|
|
9
|
+
expect(html).not.toContain('onerror="');
|
|
10
|
+
expect(html).toContain("<img");
|
|
11
|
+
expect(html).toContain("<strong>bold</strong>");
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test("opens links in a new context without window access", () => {
|
|
15
|
+
const html = renderMarkdown("[docs](https://example.com/docs)");
|
|
16
|
+
|
|
17
|
+
expect(html).toContain('href="https://example.com/docs"');
|
|
18
|
+
expect(html).toContain('target="_blank"');
|
|
19
|
+
expect(html).toContain('rel="noopener noreferrer"');
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
test("linkifies bare URLs with the same link attributes", () => {
|
|
23
|
+
const html = renderMarkdown("see https://example.com for more");
|
|
24
|
+
|
|
25
|
+
expect(html).toContain('<a href="https://example.com"');
|
|
26
|
+
expect(html).toContain('rel="noopener noreferrer"');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("renders fenced code blocks and inline code", () => {
|
|
30
|
+
const html = renderMarkdown("```\nconst x = 1 < 2;\n```\n\nuse `bun test`");
|
|
31
|
+
|
|
32
|
+
expect(html).toContain("<pre><code>const x = 1 < 2;");
|
|
33
|
+
expect(html).toContain("<code>bun test</code>");
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
test("keeps single newlines as breaks", () => {
|
|
37
|
+
expect(renderMarkdown("one\ntwo")).toContain("<br>");
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("renders lists", () => {
|
|
41
|
+
const html = renderMarkdown("- first\n- second");
|
|
42
|
+
|
|
43
|
+
expect(html).toContain("<ul>");
|
|
44
|
+
expect(html).toContain("<li>first</li>");
|
|
45
|
+
});
|
|
46
|
+
});
|
package/src/markdown.ts
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import MarkdownIt from "markdown-it";
|
|
2
|
+
|
|
3
|
+
/*
|
|
4
|
+
* Assistant text is Markdown. Rendering it needs no sanitizer because the
|
|
5
|
+
* parser never emits author HTML: `html: false` escapes every raw tag, and the
|
|
6
|
+
* only markup in the output is what markdown-it itself generates. `breaks`
|
|
7
|
+
* keeps single newlines meaningful the way chat clients do, and `typographer`
|
|
8
|
+
* stays off so code, paths, and quotes survive verbatim.
|
|
9
|
+
*/
|
|
10
|
+
const markdown = new MarkdownIt({
|
|
11
|
+
html: false,
|
|
12
|
+
linkify: true,
|
|
13
|
+
breaks: true,
|
|
14
|
+
typographer: false,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const defaultLinkOpen =
|
|
18
|
+
markdown.renderer.rules.link_open ??
|
|
19
|
+
((tokens, index, options, _env, self) =>
|
|
20
|
+
self.renderToken(tokens, index, options));
|
|
21
|
+
|
|
22
|
+
// Every link leaves the application, so it opens in a new context and is
|
|
23
|
+
// denied access to this window through `opener`.
|
|
24
|
+
markdown.renderer.rules.link_open = (tokens, index, options, env, self) => {
|
|
25
|
+
const token = tokens[index];
|
|
26
|
+
token.attrSet("target", "_blank");
|
|
27
|
+
token.attrSet("rel", "noopener noreferrer");
|
|
28
|
+
return defaultLinkOpen(tokens, index, options, env, self);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Renders Markdown to HTML that is safe to inject without a sanitizer. */
|
|
32
|
+
export function renderMarkdown(text: string): string {
|
|
33
|
+
return markdown.render(text);
|
|
34
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { defineComponent } from "vue";
|
|
3
|
+
import { createClientSurfaceRegistry } from "./surfaces.js";
|
|
4
|
+
|
|
5
|
+
const Surface = defineComponent(() => () => null);
|
|
6
|
+
|
|
7
|
+
describe("client surface registry", () => {
|
|
8
|
+
test("opens registered surfaces and closes a disposed active surface", () => {
|
|
9
|
+
const registry = createClientSurfaceRegistry();
|
|
10
|
+
const dispose = registry.register({
|
|
11
|
+
id: "bot-settings",
|
|
12
|
+
title: "Bot settings",
|
|
13
|
+
component: Surface,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
expect(registry.has("bot-settings")).toBe(true);
|
|
17
|
+
registry.open("bot-settings");
|
|
18
|
+
expect(registry.active.value).toMatchObject({
|
|
19
|
+
id: "bot-settings",
|
|
20
|
+
title: "Bot settings",
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
dispose();
|
|
24
|
+
expect(registry.active.value).toBeUndefined();
|
|
25
|
+
expect(registry.activeId.value).toBeUndefined();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("carries the declared placement and defaults to an overlay", () => {
|
|
29
|
+
const registry = createClientSurfaceRegistry();
|
|
30
|
+
registry.register({
|
|
31
|
+
id: "bot-settings",
|
|
32
|
+
title: "Bot settings",
|
|
33
|
+
component: Surface,
|
|
34
|
+
placement: "panel",
|
|
35
|
+
});
|
|
36
|
+
registry.register({
|
|
37
|
+
id: "plugins",
|
|
38
|
+
title: "Plugins",
|
|
39
|
+
component: Surface,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
registry.open("bot-settings");
|
|
43
|
+
expect(registry.active.value?.placement).toBe("panel");
|
|
44
|
+
registry.open("plugins");
|
|
45
|
+
expect(registry.active.value?.placement).toBeUndefined();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("rejects missing and duplicate surfaces", () => {
|
|
49
|
+
const registry = createClientSurfaceRegistry();
|
|
50
|
+
expect(() => registry.open("missing")).toThrow(
|
|
51
|
+
"client surface is unavailable: missing",
|
|
52
|
+
);
|
|
53
|
+
registry.register({
|
|
54
|
+
id: "plugins",
|
|
55
|
+
title: "Plugins",
|
|
56
|
+
component: Surface,
|
|
57
|
+
});
|
|
58
|
+
expect(() =>
|
|
59
|
+
registry.register({
|
|
60
|
+
id: "plugins",
|
|
61
|
+
title: "Another Plugins view",
|
|
62
|
+
component: Surface,
|
|
63
|
+
}),
|
|
64
|
+
).toThrow("client surface is already registered: plugins");
|
|
65
|
+
});
|
|
66
|
+
});
|
package/src/surfaces.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
ClientSurfaceRegistration,
|
|
3
|
+
ClientSurfaceRegistry,
|
|
4
|
+
} from "@frockbot/client-core";
|
|
5
|
+
import { computed, readonly, ref } from "vue";
|
|
6
|
+
|
|
7
|
+
export function createClientSurfaceRegistry(): ClientSurfaceRegistry {
|
|
8
|
+
const registrations = new Map<string, ClientSurfaceRegistration>();
|
|
9
|
+
const activeId = ref<string>();
|
|
10
|
+
const active = computed(() => {
|
|
11
|
+
const id = activeId.value;
|
|
12
|
+
return id ? registrations.get(id) : undefined;
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
return {
|
|
16
|
+
active,
|
|
17
|
+
activeId: readonly(activeId),
|
|
18
|
+
register(registration: ClientSurfaceRegistration) {
|
|
19
|
+
const id = registration.id.trim();
|
|
20
|
+
if (!id) throw new Error("client surface id must be non-empty");
|
|
21
|
+
if (!registration.title.trim()) {
|
|
22
|
+
throw new Error("client surface title must be non-empty");
|
|
23
|
+
}
|
|
24
|
+
if (registrations.has(id)) {
|
|
25
|
+
throw new Error(`client surface is already registered: ${id}`);
|
|
26
|
+
}
|
|
27
|
+
const stored = { ...registration, id };
|
|
28
|
+
registrations.set(id, stored);
|
|
29
|
+
return () => {
|
|
30
|
+
if (registrations.get(id) !== stored) return;
|
|
31
|
+
registrations.delete(id);
|
|
32
|
+
if (activeId.value === id) activeId.value = undefined;
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
has(id: string) {
|
|
36
|
+
return registrations.has(id);
|
|
37
|
+
},
|
|
38
|
+
open(id: string) {
|
|
39
|
+
if (!registrations.has(id)) {
|
|
40
|
+
throw new Error(`client surface is unavailable: ${id}`);
|
|
41
|
+
}
|
|
42
|
+
activeId.value = id;
|
|
43
|
+
},
|
|
44
|
+
close() {
|
|
45
|
+
activeId.value = undefined;
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2023",
|
|
4
|
+
"module": "ESNext",
|
|
5
|
+
"moduleResolution": "Bundler",
|
|
6
|
+
"allowImportingTsExtensions": true,
|
|
7
|
+
"strict": true,
|
|
8
|
+
"noEmit": true,
|
|
9
|
+
"skipLibCheck": true,
|
|
10
|
+
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
|
11
|
+
"types": ["bun"]
|
|
12
|
+
},
|
|
13
|
+
"include": ["src/**/*.ts", "src/**/*.vue"]
|
|
14
|
+
}
|
package/README.md
DELETED