@tinytars/frame 0.1.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.
- package/AccountMenu.svelte +210 -0
- package/AttachPicker.svelte +36 -0
- package/Diagnostics.svelte +188 -0
- package/ExportTab.svelte +86 -0
- package/LeafActionMenu.svelte +168 -0
- package/LeafCard.svelte +72 -0
- package/LoginScreen.svelte +124 -0
- package/Onboarding.svelte +122 -0
- package/VisibilitySettings.svelte +62 -0
- package/account-methods.svelte.ts +321 -0
- package/anchored-menu.svelte.ts +208 -0
- package/attach-controller.ts +27 -0
- package/brand.ts +8 -0
- package/menu-items.ts +17 -0
- package/menu-registry.svelte.ts +18 -0
- package/package.json +65 -0
- package/recovery-controller.svelte.ts +301 -0
- package/roster-session.svelte.ts +263 -0
- package/support-access.svelte.ts +229 -0
- package/vault-principals.svelte.ts +245 -0
- package/vault-session.svelte.ts +77 -0
package/LeafCard.svelte
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import type { Snippet } from "svelte";
|
|
3
|
+
import LeafActionMenu from "./LeafActionMenu.svelte";
|
|
4
|
+
import type { LeafMenuItem } from "./menu-items";
|
|
5
|
+
|
|
6
|
+
// The one shared leaf-card shell: a soft gray rounded card with an optional
|
|
7
|
+
// full-width header row (title left, LeafActionMenu right). Generalizes the `.report-group` +
|
|
8
|
+
// `.ct-topic-row`/`.row-actions` pattern Treatment/Study/FutureTreatment each hand-rolled
|
|
9
|
+
// identically.
|
|
10
|
+
// The two-column body grid (`.rg-grid`/`.rg-col`) moved in here too, as `:global()`, once
|
|
11
|
+
// a 5th/6th caller (Markers, Chat) was about to copy it a 5th/6th time. Callers still pass their
|
|
12
|
+
// own body as `children` and layer their own additive modifier classes on top (e.g. `.rg-dx`,
|
|
13
|
+
// `.ft-row`).
|
|
14
|
+
interface Props {
|
|
15
|
+
id?: string;
|
|
16
|
+
title?: Snippet;
|
|
17
|
+
items?: LeafMenuItem[];
|
|
18
|
+
pinned?: boolean;
|
|
19
|
+
onTogglePin?: () => void;
|
|
20
|
+
dashed?: boolean;
|
|
21
|
+
borderColor?: string;
|
|
22
|
+
children: Snippet;
|
|
23
|
+
}
|
|
24
|
+
let { id, title, items = [], pinned = false, onTogglePin, dashed = false, borderColor, children }: Props = $props();
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<div class="leaf-card" class:dashed {id} style:border-color={borderColor}>
|
|
28
|
+
{#if title || items.length || onTogglePin}
|
|
29
|
+
<div class="leaf-card-head">
|
|
30
|
+
<span class="persona-head-left">{#if title}{@render title()}{/if}</span>
|
|
31
|
+
{#if items.length || onTogglePin}
|
|
32
|
+
<div class="row-actions">
|
|
33
|
+
<LeafActionMenu {items} {pinned} {onTogglePin} />
|
|
34
|
+
</div>
|
|
35
|
+
{/if}
|
|
36
|
+
</div>
|
|
37
|
+
{/if}
|
|
38
|
+
{@render children()}
|
|
39
|
+
</div>
|
|
40
|
+
|
|
41
|
+
<style>
|
|
42
|
+
.leaf-card { border: 1px solid var(--border); border-radius: 16px; background: var(--bg); padding: 0.5rem; margin-bottom: 0.6rem; }
|
|
43
|
+
.leaf-card.dashed { border-style: dashed; }
|
|
44
|
+
.leaf-card-head { display: flex; align-items: center; justify-content: space-between; gap: 0.5rem; padding: 0.15rem 0.3rem 0.45rem; }
|
|
45
|
+
.row-actions { display: flex; align-items: center; gap: 0.35rem; flex: none; }
|
|
46
|
+
:global(:root) {
|
|
47
|
+
--rg-side-w: 80%;
|
|
48
|
+
--rg-side-w-narrow: 92%;
|
|
49
|
+
}
|
|
50
|
+
:global(.rg-grid) { display: flex; flex-direction: column; row-gap: 0.5rem; }
|
|
51
|
+
:global(.rg-grid) > :global(*:first-child) {
|
|
52
|
+
align-self: flex-end;
|
|
53
|
+
max-width: var(--rg-side-w);
|
|
54
|
+
text-align: right;
|
|
55
|
+
}
|
|
56
|
+
/* :not(:first-child) makes this and the rule above mutually exclusive for a lone child
|
|
57
|
+
(the old .single case) — no separate override needed. */
|
|
58
|
+
:global(.rg-grid) > :global(*:last-child:not(:first-child)) {
|
|
59
|
+
align-self: flex-start;
|
|
60
|
+
max-width: var(--rg-side-w);
|
|
61
|
+
text-align: left;
|
|
62
|
+
}
|
|
63
|
+
@media (max-width: 480px) {
|
|
64
|
+
:global(:root) { --rg-side-w: var(--rg-side-w-narrow); }
|
|
65
|
+
}
|
|
66
|
+
:global(.rg-col) { display: flex; flex-direction: column; gap: 0.5rem; min-width: 0; }
|
|
67
|
+
:global(.leaf-side) {
|
|
68
|
+
align-self: flex-start;
|
|
69
|
+
max-width: var(--rg-side-w);
|
|
70
|
+
text-align: left;
|
|
71
|
+
}
|
|
72
|
+
</style>
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
/**
|
|
3
|
+
* The lock screen's own slice of a recovery controller: which ladder rung `codeInput` looks like,
|
|
4
|
+
* and the one action (`recover`) the screen triggers. The full controller (issuing codes, the org
|
|
5
|
+
* envelope, the Account modal's blocks) is caller-owned — this only needs enough to render the form.
|
|
6
|
+
*/
|
|
7
|
+
export interface LoginRecovery {
|
|
8
|
+
recoverMode: boolean;
|
|
9
|
+
codeInput: string;
|
|
10
|
+
newPassword: string;
|
|
11
|
+
readonly kind: "code" | "grant";
|
|
12
|
+
recover(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
</script>
|
|
15
|
+
|
|
16
|
+
<script lang="ts">
|
|
17
|
+
let {
|
|
18
|
+
productName,
|
|
19
|
+
resuming = false,
|
|
20
|
+
resumingLabel = "Restoring your session…",
|
|
21
|
+
email = $bindable(""),
|
|
22
|
+
password = $bindable(""),
|
|
23
|
+
signupMode = $bindable(false),
|
|
24
|
+
error = $bindable(null),
|
|
25
|
+
unlocking = false,
|
|
26
|
+
googleError = null,
|
|
27
|
+
recovery,
|
|
28
|
+
onLogin,
|
|
29
|
+
onSignup,
|
|
30
|
+
onGoogle,
|
|
31
|
+
signInSubheading = "Sign in to your encrypted record.",
|
|
32
|
+
signUpSubheading = "Create your account.",
|
|
33
|
+
recoveryCodeSubheading = "Enter your recovery code — your own, or the one-time code your provider read you — and choose a new password.",
|
|
34
|
+
recoveryNoteCode = "Your passkey and Google sign-in, if you use them, keep working — this replaces the password only.",
|
|
35
|
+
recoveryNoteGrant = "Ask your provider to read you a code — it works once and expires in an hour. Your old password, passkey and Google sign-in will all stop working.",
|
|
36
|
+
}: {
|
|
37
|
+
productName: string;
|
|
38
|
+
resuming?: boolean;
|
|
39
|
+
resumingLabel?: string;
|
|
40
|
+
email?: string;
|
|
41
|
+
password?: string;
|
|
42
|
+
signupMode?: boolean;
|
|
43
|
+
error?: string | null;
|
|
44
|
+
unlocking?: boolean;
|
|
45
|
+
googleError?: string | null;
|
|
46
|
+
recovery: LoginRecovery;
|
|
47
|
+
onLogin: (method: "password" | "passkey") => void;
|
|
48
|
+
onSignup: (method: "password" | "passkey") => void;
|
|
49
|
+
onGoogle: () => void;
|
|
50
|
+
signInSubheading?: string;
|
|
51
|
+
signUpSubheading?: string;
|
|
52
|
+
recoveryCodeSubheading?: string;
|
|
53
|
+
recoveryNoteCode?: string;
|
|
54
|
+
recoveryNoteGrant?: string;
|
|
55
|
+
} = $props();
|
|
56
|
+
</script>
|
|
57
|
+
|
|
58
|
+
{#if resuming}
|
|
59
|
+
<main class="lock">
|
|
60
|
+
<h1>{productName}</h1>
|
|
61
|
+
<p class="sub">{resumingLabel}</p>
|
|
62
|
+
</main>
|
|
63
|
+
{:else}
|
|
64
|
+
<main class="lock">
|
|
65
|
+
<h1>{productName}</h1>
|
|
66
|
+
{#if recovery.recoverMode}
|
|
67
|
+
<p class="sub">{recoveryCodeSubheading}</p>
|
|
68
|
+
<form onsubmit={(e) => { e.preventDefault(); recovery.recover(); }}>
|
|
69
|
+
<input type="email" autocomplete="username" placeholder="email" aria-label="Email" bind:value={email} />
|
|
70
|
+
<input type="text" placeholder="recovery code" aria-label="Recovery code" bind:value={recovery.codeInput} />
|
|
71
|
+
<input type="password" autocomplete="new-password" placeholder="new password" aria-label="New password" bind:value={recovery.newPassword} />
|
|
72
|
+
<button type="submit" disabled={unlocking || !email || !recovery.codeInput.trim() || !recovery.newPassword}>{unlocking ? "…" : "Recover"}</button>
|
|
73
|
+
</form>
|
|
74
|
+
{#if recovery.kind === "code"}
|
|
75
|
+
<p class="lock-note">{recoveryNoteCode}</p>
|
|
76
|
+
{:else}
|
|
77
|
+
<p class="lock-note">{recoveryNoteGrant}</p>
|
|
78
|
+
{/if}
|
|
79
|
+
<div class="lock-alt">
|
|
80
|
+
<button class="linkish" onclick={() => { recovery.recoverMode = false; error = null; }}>← Back to sign in</button>
|
|
81
|
+
</div>
|
|
82
|
+
{:else}
|
|
83
|
+
<p class="sub">{signupMode ? signUpSubheading : signInSubheading}</p>
|
|
84
|
+
<form onsubmit={(e) => { e.preventDefault(); signupMode ? onSignup("password") : onLogin("password"); }}>
|
|
85
|
+
<!-- svelte-ignore a11y_autofocus -->
|
|
86
|
+
<input type="email" autocomplete="username" placeholder="email" aria-label="Email" bind:value={email} autofocus />
|
|
87
|
+
<input type="password" autocomplete={signupMode ? "new-password" : "current-password"} placeholder="password" aria-label="Password" bind:value={password} />
|
|
88
|
+
<button type="submit" disabled={unlocking || !email || !password}>
|
|
89
|
+
{unlocking ? "…" : signupMode ? "Create account" : "Sign in"}
|
|
90
|
+
</button>
|
|
91
|
+
</form>
|
|
92
|
+
<button class="google-btn" onclick={onGoogle} disabled={unlocking}>Continue with Google</button>
|
|
93
|
+
<div class="lock-alt">
|
|
94
|
+
<button class="linkish" onclick={() => (signupMode ? onSignup("passkey") : onLogin("passkey"))} disabled={unlocking || !email}>
|
|
95
|
+
{signupMode ? "Create with a passkey" : "Use a passkey"}
|
|
96
|
+
</button>
|
|
97
|
+
<button class="linkish" onclick={() => { signupMode = !signupMode; error = null; }}>
|
|
98
|
+
{signupMode ? "Have an account? Sign in" : "New here? Create an account"}
|
|
99
|
+
</button>
|
|
100
|
+
{#if !signupMode}<button class="linkish" onclick={() => { recovery.recoverMode = true; error = null; }}>Forgot password?</button>{/if}
|
|
101
|
+
</div>
|
|
102
|
+
{/if}
|
|
103
|
+
{#if error}<p class="err">{error}</p>{/if}
|
|
104
|
+
{#if googleError}<p class="err">{googleError}</p>{/if}
|
|
105
|
+
</main>
|
|
106
|
+
{/if}
|
|
107
|
+
|
|
108
|
+
<style>
|
|
109
|
+
.lock { max-width: 360px; margin: 12rem auto; text-align: center; }
|
|
110
|
+
.lock h1 { font-size: 1.5rem; margin: 0 0 0.5rem; }
|
|
111
|
+
.lock .sub { color: var(--muted); margin: 0 0 2rem; }
|
|
112
|
+
.lock form { display: flex; flex-direction: column; gap: 0.5rem; }
|
|
113
|
+
.lock input { flex: 1; padding: 0.6rem 0.75rem; border: 1px solid var(--border); border-radius: 6px; font: inherit; }
|
|
114
|
+
.lock form button { padding: 0.6rem 1rem; border: 1px solid var(--accent); background: var(--accent); color: white; border-radius: 6px; cursor: pointer; }
|
|
115
|
+
.lock button:disabled { opacity: 0.5; cursor: default; }
|
|
116
|
+
.google-btn { margin-top: 0.75rem; width: 100%; padding: 0.6rem 1rem; border: 1px solid var(--border, #d0d5dd); background: white; color: #3c4043; border-radius: 6px; cursor: pointer; font: inherit; font-weight: 500; }
|
|
117
|
+
.google-btn:hover:not(:disabled) { background: #f8f9fa; }
|
|
118
|
+
.google-btn:disabled { opacity: 0.5; cursor: default; }
|
|
119
|
+
.lock-alt { display: flex; flex-direction: column; gap: 0.4rem; margin-top: 1rem; }
|
|
120
|
+
.lock-note { margin: 0.75rem 0 0; font-size: 0.8rem; color: var(--muted, #667); max-width: 22rem; text-align: center; }
|
|
121
|
+
.linkish { background: none; border: none; color: var(--accent); cursor: pointer; font: inherit; font-size: 0.85rem; padding: 0.2rem; }
|
|
122
|
+
.linkish:disabled { color: var(--muted); cursor: default; }
|
|
123
|
+
.err { color: var(--alert); margin-top: 1rem; }
|
|
124
|
+
</style>
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export interface OnboardingNumberField {
|
|
3
|
+
key: string;
|
|
4
|
+
kind: "number";
|
|
5
|
+
label: string;
|
|
6
|
+
placeholder?: string;
|
|
7
|
+
min?: number;
|
|
8
|
+
max?: number;
|
|
9
|
+
invalidMessage?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface OnboardingSelectField {
|
|
12
|
+
key: string;
|
|
13
|
+
kind: "select";
|
|
14
|
+
label: string;
|
|
15
|
+
options: { value: string; label: string }[];
|
|
16
|
+
}
|
|
17
|
+
export type OnboardingField = OnboardingNumberField | OnboardingSelectField;
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<script lang="ts">
|
|
21
|
+
// First-run screen for a signed-in account with zero records. The field catalog
|
|
22
|
+
// (what to ask, and each field's own validity rule) is entirely caller-supplied — a number field
|
|
23
|
+
// is optional and omitted from the create payload when left blank or when the user skips; a
|
|
24
|
+
// select field always has a value and is always included. Everything the caller collects is
|
|
25
|
+
// stored wherever it wants (LexiTar puts it in the encrypted vault, never the server).
|
|
26
|
+
let {
|
|
27
|
+
productName,
|
|
28
|
+
subheading = "Welcome. A couple of details help tailor your results — or skip and add them later.",
|
|
29
|
+
fields, defaults, onCreate, onSignOut,
|
|
30
|
+
continueLabel = "Continue", busyLabel = "Setting up…", skipLabel = "Skip for now", signOutLabel = "Sign out",
|
|
31
|
+
}: {
|
|
32
|
+
productName: string;
|
|
33
|
+
subheading?: string;
|
|
34
|
+
fields: OnboardingField[];
|
|
35
|
+
defaults: Record<string, unknown>;
|
|
36
|
+
onCreate: (info: Record<string, unknown>) => Promise<void>;
|
|
37
|
+
onSignOut: () => void;
|
|
38
|
+
continueLabel?: string;
|
|
39
|
+
busyLabel?: string;
|
|
40
|
+
skipLabel?: string;
|
|
41
|
+
signOutLabel?: string;
|
|
42
|
+
} = $props();
|
|
43
|
+
|
|
44
|
+
let values = $state<Record<string, any>>({ ...defaults });
|
|
45
|
+
let busy = $state(false);
|
|
46
|
+
let error = $state<string | null>(null);
|
|
47
|
+
|
|
48
|
+
async function create(withProfile: boolean) {
|
|
49
|
+
const payload: Record<string, unknown> = {};
|
|
50
|
+
for (const f of fields) {
|
|
51
|
+
if (f.kind === "number") {
|
|
52
|
+
if (!withProfile) continue; // optional fields are omitted entirely on Skip
|
|
53
|
+
const raw = values[f.key] as number | null | undefined;
|
|
54
|
+
if (raw == null) continue; // left blank — omit rather than send an invalid value
|
|
55
|
+
if (!Number.isInteger(raw) || (f.min != null && raw < f.min) || (f.max != null && raw > f.max)) {
|
|
56
|
+
error = f.invalidMessage ?? `Enter a valid ${f.label.toLowerCase()}.`;
|
|
57
|
+
return;
|
|
58
|
+
}
|
|
59
|
+
payload[f.key] = raw;
|
|
60
|
+
} else {
|
|
61
|
+
payload[f.key] = values[f.key];
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
busy = true; error = null;
|
|
65
|
+
try {
|
|
66
|
+
await onCreate(payload);
|
|
67
|
+
// On success the caller re-renders away from this screen.
|
|
68
|
+
} catch (err) {
|
|
69
|
+
error = (err as Error).message ?? "Could not create the record.";
|
|
70
|
+
busy = false;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<main class="onboard">
|
|
76
|
+
<h1>{productName}</h1>
|
|
77
|
+
<p class="sub">{subheading}</p>
|
|
78
|
+
<form onsubmit={(e) => { e.preventDefault(); create(true); }}>
|
|
79
|
+
<div class="row">
|
|
80
|
+
{#each fields as f (f.key)}
|
|
81
|
+
<label>{f.label}
|
|
82
|
+
{#if f.kind === "number"}
|
|
83
|
+
<input type="number" inputmode="numeric" min={f.min} max={f.max} placeholder={f.placeholder} bind:value={values[f.key]} />
|
|
84
|
+
{:else}
|
|
85
|
+
<select bind:value={values[f.key]}>
|
|
86
|
+
{#each f.options as opt (opt.value)}
|
|
87
|
+
<option value={opt.value}>{opt.label}</option>
|
|
88
|
+
{/each}
|
|
89
|
+
</select>
|
|
90
|
+
{/if}
|
|
91
|
+
</label>
|
|
92
|
+
{/each}
|
|
93
|
+
</div>
|
|
94
|
+
<button type="submit" class="primary" disabled={busy}>{busy ? busyLabel : continueLabel}</button>
|
|
95
|
+
{#if error}<p class="err">{error}</p>{/if}
|
|
96
|
+
</form>
|
|
97
|
+
<button class="link" onclick={() => create(false)} disabled={busy}>{skipLabel}</button>
|
|
98
|
+
<button class="link" onclick={onSignOut}>{signOutLabel}</button>
|
|
99
|
+
</main>
|
|
100
|
+
|
|
101
|
+
<style>
|
|
102
|
+
.onboard { max-width: 380px; margin: 12vh auto 0; text-align: center; padding: 0 1rem; }
|
|
103
|
+
.sub { color: var(--muted, #667); margin: 0.25rem 0 1.5rem; }
|
|
104
|
+
form { display: flex; flex-direction: column; gap: 0.75rem; text-align: left; }
|
|
105
|
+
label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: var(--muted, #667); }
|
|
106
|
+
input, select {
|
|
107
|
+
padding: 0.5rem; font-size: 1rem; border: 1px solid var(--border, #ccc);
|
|
108
|
+
border-radius: 6px; background: var(--bg, #fff); color: var(--fg, #111);
|
|
109
|
+
}
|
|
110
|
+
.row { display: flex; gap: 0.75rem; }
|
|
111
|
+
.row label { flex: 1; }
|
|
112
|
+
.primary {
|
|
113
|
+
margin-top: 0.5rem; padding: 0.6rem; font-size: 1rem; font-weight: 600;
|
|
114
|
+
border: none; border-radius: 6px; background: var(--accent, #2b6aa8); color: var(--surface, #fff); cursor: pointer;
|
|
115
|
+
}
|
|
116
|
+
.primary:disabled { opacity: 0.6; cursor: default; }
|
|
117
|
+
.err { color: var(--alert); font-size: 0.85rem; margin: 0.25rem 0 0; }
|
|
118
|
+
.link {
|
|
119
|
+
margin-top: 1.5rem; background: none; border: none; color: var(--muted, #667);
|
|
120
|
+
text-decoration: underline; cursor: pointer; font-size: 0.85rem;
|
|
121
|
+
}
|
|
122
|
+
</style>
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
<script module lang="ts">
|
|
2
|
+
export interface VisibilityFeature {
|
|
3
|
+
key: string;
|
|
4
|
+
label: string;
|
|
5
|
+
}
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<script lang="ts">
|
|
9
|
+
// Generic panel to configure what a subject's own session sees. Each feature toggles
|
|
10
|
+
// between subject-visible and viewer-only; the catalog, current-visibility check, and save are
|
|
11
|
+
// all injected, so this component knows nothing about clients, patients, or providers.
|
|
12
|
+
let {
|
|
13
|
+
subjectLabel, features, isVisible, onSave, viewerLabel = "You",
|
|
14
|
+
}: {
|
|
15
|
+
subjectLabel: string;
|
|
16
|
+
features: VisibilityFeature[];
|
|
17
|
+
isVisible: (key: string) => boolean;
|
|
18
|
+
onSave: (key: string, visible: boolean) => Promise<void>;
|
|
19
|
+
viewerLabel?: string;
|
|
20
|
+
} = $props();
|
|
21
|
+
|
|
22
|
+
let busyKey = $state<string | null>(null);
|
|
23
|
+
let error = $state<string | null>(null);
|
|
24
|
+
|
|
25
|
+
async function set(key: string, visible: boolean) {
|
|
26
|
+
error = null;
|
|
27
|
+
busyKey = key;
|
|
28
|
+
try {
|
|
29
|
+
await onSave(key, visible);
|
|
30
|
+
} catch (e) {
|
|
31
|
+
error = (e as Error).message;
|
|
32
|
+
} finally {
|
|
33
|
+
busyKey = null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
</script>
|
|
37
|
+
|
|
38
|
+
<div class="vis">
|
|
39
|
+
<p class="lead">Choose what <strong>{subjectLabel}</strong> sees when signed in as themselves. {viewerLabel} always see everything.</p>
|
|
40
|
+
{#if error}<p class="vis-error">{error}</p>{/if}
|
|
41
|
+
|
|
42
|
+
<ul class="vis-list">
|
|
43
|
+
{#each features as f (f.key)}
|
|
44
|
+
<li>
|
|
45
|
+
<label>
|
|
46
|
+
<input type="checkbox" checked={isVisible(f.key)} disabled={busyKey === f.key} onchange={(e) => set(f.key, e.currentTarget.checked)} />
|
|
47
|
+
<span>{f.label}</span>
|
|
48
|
+
</label>
|
|
49
|
+
</li>
|
|
50
|
+
{/each}
|
|
51
|
+
</ul>
|
|
52
|
+
</div>
|
|
53
|
+
|
|
54
|
+
<style>
|
|
55
|
+
.vis { max-width: 34rem; }
|
|
56
|
+
.lead { color: var(--muted); line-height: 1.5; margin: 0 0 1rem; }
|
|
57
|
+
.vis-list { list-style: none; margin: 0; padding: 0; }
|
|
58
|
+
.vis-list li { padding: 0.15rem 0; }
|
|
59
|
+
.vis-list label { display: flex; align-items: center; gap: 0.55rem; cursor: pointer; }
|
|
60
|
+
.vis-list input { width: 1.05rem; height: 1.05rem; }
|
|
61
|
+
.vis-error { color: var(--alert); font-size: 0.85rem; }
|
|
62
|
+
</style>
|