@atmo-dev/kit 0.1.0 → 0.2.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/README.md CHANGED
@@ -18,3 +18,92 @@ The `login(identifier, actor?, options?)` and `onSuccess(identifier, actor?)` ca
18
18
  Validation is syntactic, not proof that an account exists. Apps own DID resolution, supported DID methods, OAuth, and sessions. DID inputs bypass optional handle-search providers and cancel pending suggestions.
19
19
 
20
20
  For other login entry points, import `normalizeIdentifier` and `isValidIdentifier`. Existing `normalizeHandle` and `isValidHandle` remain handle-only helpers; do not use them on DIDs.
21
+
22
+ ## Legal pages and footer
23
+
24
+ `LegalDocument` renders app-owned document data with readable typography and an optional app-reviewed revision date. Publication status is metadata, not a visible page label. `LegalFooter` renders only configured links. Preview the actual pages at `/legal`; authoring configuration and diagnostics have their own view.
25
+
26
+ ```svelte
27
+ <script lang="ts">
28
+ import { LegalDocument, LegalFooter } from '@atmo-dev/kit';
29
+ import { privacy } from './reviewed-policies';
30
+ </script>
31
+
32
+ <LegalDocument document={privacy} />
33
+ <LegalFooter
34
+ links={{
35
+ terms: '/terms',
36
+ privacy: '/privacy',
37
+ imprint: '/imprint',
38
+ moderation: false,
39
+ report: '/report',
40
+ source: { label: 'Source code', href: 'https://github.com/example/project' }
41
+ }}
42
+ />
43
+ ```
44
+
45
+ Every destination is optional: omit it or set it to `false` to hide it. A string uses the default label; `{ label, href }` customizes it. Plain `mailto:` destinations are accepted for reporting. Unsafe URLs are not made clickable. No links/actions means no empty footer. The footer forwards native attributes and `class`.
46
+
47
+ An app-owned `reportAction` snippet replaces `links.report` with a contextual button/dialog trigger. Setting `links.report: false` disables that action too. The footer owns no session, reporting subject, dialog, or network operation.
48
+
49
+ ### Author and review documents
50
+
51
+ Import framework-free helpers from `@atmo-dev/kit/legal` in a script:
52
+
53
+ ```ts
54
+ import { createLegalDrafts, getLegalDraftIssues } from '@atmo-dev/kit/legal';
55
+
56
+ const config = {
57
+ app: { name: 'Example', url: 'https://example.com', description: 'an event app' },
58
+ operator: { name: 'Operator', email: 'hello@example.com', address: [] },
59
+ privacy: [
60
+ {
61
+ id: 'storage',
62
+ title: 'Data storage',
63
+ paragraphs: [
64
+ 'Describe actual data, purposes, recipients, retention, legal bases, and safeguards.'
65
+ ]
66
+ }
67
+ ],
68
+ moderation: [
69
+ {
70
+ id: 'reports',
71
+ title: 'Reporting',
72
+ paragraphs: ['Describe the working report channel and review procedure.']
73
+ }
74
+ ]
75
+ };
76
+ console.error(getLegalDraftIssues(config));
77
+ console.log(JSON.stringify(createLegalDrafts(config), null, 2));
78
+ ```
79
+
80
+ Save generated documents in the consuming app, review/edit their wording, and render that snapshot—not a runtime call to `createLegalDrafts`. **A Kit upgrade must not silently rewrite a published policy.** The templates are drafts, not legal advice or a guarantee of compliance. They infer no jurisdiction, age threshold, legal basis, retention period, processor agreement, acceptance flow, or notice commitment. Operator facts can be reused across apps; each app still needs its actual processing and terms reviewed.
81
+
82
+ `LegalDocumentData` contains a `title`, `status: 'draft' | 'published'`, optional app-owned `updated` date, and `sections`. Each section has a unique `id`, `title`, and optional `paragraphs`, `items`, `address`, and `links`. Text is escaped, not rendered as HTML. Templates provide terms, privacy, imprint, and moderation drafts, but apps can supply their own text/documents and external policy links instead.
83
+
84
+ Set `status: 'published'` and an ISO `updated` date only after review. `getLegalDocumentIssues` checks publication fields and section identifiers; `getLegalDraftIssues` checks authoring configuration. Neither determines legal compliance. Routes, page titles, indexing of draft pages, notice/acceptance mechanisms, and enforcement remain app-owned.
85
+
86
+ ## Reporting
87
+
88
+ `ReportDialog` owns the reason/details form, pending and success states, and the signed-out email fallback. Apps own the trigger, sessions, subject references, canonical links, mailbox, and server submission.
89
+
90
+ ```svelte
91
+ <ReportDialog
92
+ bind:open
93
+ isLoggedIn={viewer.isLoggedIn}
94
+ email="reports@example.com"
95
+ subjectUrl={canonicalUrl}
96
+ onsignin={showLogin}
97
+ submit={async (input) => {
98
+ try {
99
+ await sendReport({ subject, ...input });
100
+ } catch {
101
+ return { message: 'Reporting is unavailable. Please report by email.' };
102
+ }
103
+ }}
104
+ />
105
+ ```
106
+
107
+ `submit` receives `{ reasonType, reason? }`, using the standard ATProto spam, violation, and other reason identifiers. Blank details are omitted; entered details are trimmed and limited to 2,000 characters. Resolve with no value after a successful save, or return `{ message, needsSignIn? }` for a safe, user-facing failure. `needsSignIn` offers a sign-in-again action. Unexpected rejections show a generic failure without exposing exception details.
108
+
109
+ The email fallback prefills the subject link and any entered details. Opening a new dialog or changing `subjectUrl` resets its state; stale async results are ignored. The dialog does not import an app's auth singleton, contact a moderation service, or decide who can report a subject. Server authorization remains mandatory.
@@ -1,6 +1,7 @@
1
1
  <script lang="ts">
2
2
  import type { PostData } from '../social/posts.js';
3
3
  import { safeWebUrl } from '../social/richtext.js';
4
+ import { blueskyProfileUrl } from '../social/urls.js';
4
5
  import Avatar from './Avatar.svelte';
5
6
  import PostContent from './PostContent.svelte';
6
7
  import NestedComments from './NestedComments.svelte';
@@ -12,7 +13,7 @@
12
13
  <article class="min-w-0">
13
14
  <div class="flex items-start gap-3">
14
15
  <a
15
- href={`https://bsky.app/profile/${encodeURIComponent(comment.author.did)}`}
16
+ href={blueskyProfileUrl(comment.author.did)}
16
17
  target="_blank"
17
18
  rel="noopener noreferrer"
18
19
  class="shrink-0"
@@ -0,0 +1,58 @@
1
+ <script lang="ts" module>
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { LegalDocumentData } from './types.js';
4
+ export type LegalDocumentProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
5
+ document: LegalDocumentData;
6
+ };
7
+ </script>
8
+
9
+ <script lang="ts">
10
+ import { cn } from '../../utils.js';
11
+ import { safeLegalHref } from './links.js';
12
+ let { document, class: className, ...rest }: LegalDocumentProps = $props();
13
+ const uid = $props.id();
14
+ </script>
15
+
16
+ <article {...rest} class={cn('mx-auto max-w-3xl px-6 py-10 text-sm leading-7 text-ink', className)}>
17
+ <header class="mb-8">
18
+ <h1 class="m-0 text-3xl font-semibold tracking-tight">{document.title}</h1>
19
+ {#if document.updated}
20
+ <p class="mt-3 text-muted">
21
+ Last updated: <time datetime={document.updated}>{document.updated}</time>
22
+ </p>
23
+ {/if}
24
+ </header>
25
+ <div class="space-y-8">
26
+ {#each document.sections as section (section.id)}
27
+ <section aria-labelledby={`${uid}-${section.id}`}>
28
+ <h2 id={`${uid}-${section.id}`} class="mb-3 text-lg font-semibold">{section.title}</h2>
29
+ <div class="space-y-3">
30
+ {#each section.paragraphs ?? [] as paragraph, index (index)}<p>{paragraph}</p>{/each}
31
+ {#if section.address?.length}
32
+ <address class="not-italic">
33
+ {#each section.address as line, index (index)}{#if index}<br />{/if}{line}{/each}
34
+ </address>
35
+ {/if}
36
+ {#if section.items?.length}
37
+ <ul class="list-disc space-y-2 pl-5">
38
+ {#each section.items as item, index (index)}<li>{item}</li>{/each}
39
+ </ul>
40
+ {/if}
41
+ {#if section.links?.length}
42
+ <ul class="list-none space-y-2 p-0">
43
+ {#each section.links as link, index (index)}
44
+ <li>
45
+ {#if safeLegalHref(link.href, true)}<a
46
+ href={safeLegalHref(link.href, true)}
47
+ class="rounded underline underline-offset-4 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-focus"
48
+ >{link.label}</a
49
+ >{:else}{link.label}{/if}
50
+ </li>
51
+ {/each}
52
+ </ul>
53
+ {/if}
54
+ </div>
55
+ </section>
56
+ {/each}
57
+ </div>
58
+ </article>
@@ -0,0 +1,8 @@
1
+ import type { HTMLAttributes } from 'svelte/elements';
2
+ import type { LegalDocumentData } from './types.js';
3
+ export type LegalDocumentProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
4
+ document: LegalDocumentData;
5
+ };
6
+ declare const LegalDocument: import("svelte").Component<LegalDocumentProps, {}, "">;
7
+ type LegalDocument = ReturnType<typeof LegalDocument>;
8
+ export default LegalDocument;
@@ -0,0 +1,34 @@
1
+ <script lang="ts" module>
2
+ import type { Snippet } from 'svelte';
3
+ import type { HTMLAttributes } from 'svelte/elements';
4
+ import type { LegalFooterLinks } from './types.js';
5
+
6
+ export type LegalFooterProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
7
+ links?: LegalFooterLinks;
8
+ /** App-owned contextual report action; replaces links.report when supplied. */
9
+ reportAction?: Snippet;
10
+ };
11
+ </script>
12
+
13
+ <script lang="ts">
14
+ import { cn } from '../../utils.js';
15
+ import { legalFooterItems } from './links.js';
16
+ let { links = {}, reportAction, class: className, ...rest }: LegalFooterProps = $props();
17
+ const showReportAction = $derived(links.report !== false && !!reportAction);
18
+ const items = $derived(legalFooterItems(showReportAction ? { ...links, report: false } : links));
19
+ const linkClass =
20
+ 'rounded text-inherit underline-offset-4 hover:text-ink hover:underline focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-focus';
21
+ </script>
22
+
23
+ {#if items.length || showReportAction}
24
+ <footer {...rest} class={cn('py-8 text-xs text-muted', className)}>
25
+ <nav aria-label="Legal and contact">
26
+ <ul class="m-0 flex list-none flex-wrap items-center justify-center gap-x-5 gap-y-3 p-0">
27
+ {#each items as item, index (index)}
28
+ <li><a href={item.href} class={linkClass}>{item.label}</a></li>
29
+ {/each}
30
+ {#if showReportAction}<li>{@render reportAction?.()}</li>{/if}
31
+ </ul>
32
+ </nav>
33
+ </footer>
34
+ {/if}
@@ -0,0 +1,11 @@
1
+ import type { Snippet } from 'svelte';
2
+ import type { HTMLAttributes } from 'svelte/elements';
3
+ import type { LegalFooterLinks } from './types.js';
4
+ export type LegalFooterProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
5
+ links?: LegalFooterLinks;
6
+ /** App-owned contextual report action; replaces links.report when supplied. */
7
+ reportAction?: Snippet;
8
+ };
9
+ declare const LegalFooter: import("svelte").Component<LegalFooterProps, {}, "">;
10
+ type LegalFooter = ReturnType<typeof LegalFooter>;
11
+ export default LegalFooter;
@@ -0,0 +1,10 @@
1
+ import type { LegalDocumentData, LegalDocumentKind, LegalDraftConfig } from './types.js';
2
+ /** Completeness checks only, never legal approval. Run during authoring/review. */
3
+ export declare function getLegalDraftIssues(config: LegalDraftConfig): string[];
4
+ /** Publication completeness only. A passing check is not legal approval. */
5
+ export declare function getLegalDocumentIssues(document: LegalDocumentData): string[];
6
+ /** Starter text, not legal advice. Save the result in the app and review it.
7
+ * Never use this as a runtime policy generator: a package update must not silently
8
+ * change published commitments. No jurisdiction, age, retention, or legal basis
9
+ * is inferred. Every generated document is a draft with no publication date. */
10
+ export declare function createLegalDrafts(config: LegalDraftConfig): Record<LegalDocumentKind, LegalDocumentData>;
@@ -0,0 +1,134 @@
1
+ import { safeLegalHref } from './links.js';
2
+ /** Completeness checks only, never legal approval. Run during authoring/review. */
3
+ export function getLegalDraftIssues(config) {
4
+ const issues = [];
5
+ if (!config.app.name.trim())
6
+ issues.push('App name is missing.');
7
+ if (!safeLegalHref(config.app.url)?.startsWith('http'))
8
+ issues.push('A valid public app URL is required.');
9
+ if (!config.app.description.trim())
10
+ issues.push('Service description is missing.');
11
+ if (!config.operator.name.trim())
12
+ issues.push('Operator name is missing.');
13
+ if (!config.operator.address.length || config.operator.address.some((line) => !line.trim())) {
14
+ issues.push('The operator postal address must be supplied and reviewed.');
15
+ }
16
+ if (!safeLegalHref(`mailto:${config.operator.email}`, true))
17
+ issues.push('A valid operator contact email is required.');
18
+ if (!config.privacy.length)
19
+ issues.push('Describe actual data processing, recipients, storage, retention, transfers, and legal bases.');
20
+ if (!config.moderation.length)
21
+ issues.push('Describe actual reporting, moderation, and review procedures.');
22
+ return issues;
23
+ }
24
+ /** Publication completeness only. A passing check is not legal approval. */
25
+ export function getLegalDocumentIssues(document) {
26
+ const issues = [];
27
+ if (!document.title.trim())
28
+ issues.push('Document title is missing.');
29
+ if (document.status !== 'published')
30
+ issues.push('Document is still a draft.');
31
+ if (!document.updated ||
32
+ !/^\d{4}-\d{2}-\d{2}$/.test(document.updated) ||
33
+ !Number.isFinite(Date.parse(document.updated)) ||
34
+ new Date(document.updated).toISOString().slice(0, 10) !== document.updated) {
35
+ issues.push('A valid app-reviewed revision date is required.');
36
+ }
37
+ if (!document.sections.length)
38
+ issues.push('Document sections are missing.');
39
+ const ids = new Set();
40
+ for (const section of document.sections) {
41
+ if (!section.id || ids.has(section.id))
42
+ issues.push('Section IDs must be nonempty and unique.');
43
+ ids.add(section.id);
44
+ if (!section.title.trim())
45
+ issues.push('Section title is missing.');
46
+ }
47
+ return issues;
48
+ }
49
+ /** Starter text, not legal advice. Save the result in the app and review it.
50
+ * Never use this as a runtime policy generator: a package update must not silently
51
+ * change published commitments. No jurisdiction, age, retention, or legal basis
52
+ * is inferred. Every generated document is a draft with no publication date. */
53
+ export function createLegalDrafts(config) {
54
+ const { app, operator } = config;
55
+ const contact = {
56
+ id: 'contact',
57
+ title: 'Contact',
58
+ links: [{ label: operator.email, href: `mailto:${operator.email}` }]
59
+ };
60
+ const owner = {
61
+ id: 'operator',
62
+ title: 'Operator',
63
+ address: [operator.name, ...operator.address],
64
+ links: [{ label: app.name, href: app.url }]
65
+ };
66
+ const draft = (title, sections) => ({
67
+ title,
68
+ status: 'draft',
69
+ sections: structuredClone(sections)
70
+ });
71
+ // Copy per document, so editing one never changes another or the source config.
72
+ return {
73
+ terms: draft('Terms of service', [
74
+ {
75
+ id: 'service',
76
+ title: 'About the service',
77
+ paragraphs: [`${app.name} is ${app.description}.`]
78
+ },
79
+ {
80
+ id: 'account',
81
+ title: 'Your account',
82
+ paragraphs: [
83
+ 'Keep your account secure and only grant permissions you are comfortable with. Your account provider has its own terms.'
84
+ ]
85
+ },
86
+ {
87
+ id: 'content',
88
+ title: 'Your content',
89
+ paragraphs: [
90
+ 'You own your content and must have the right to publish it. Public AT Protocol records can be copied by other services. Hiding content here does not remove those copies.'
91
+ ]
92
+ },
93
+ {
94
+ id: 'conduct',
95
+ title: 'Acceptable use',
96
+ items: [
97
+ 'Do not publish illegal content or violate the rights of others.',
98
+ 'Do not use the service for harassment, threats, scams, spam, or malicious software.',
99
+ 'Do not disrupt the service or bypass access restrictions.'
100
+ ]
101
+ },
102
+ {
103
+ id: 'availability',
104
+ title: 'Availability and ending use',
105
+ paragraphs: [
106
+ 'The service may change or be unavailable. You can stop using it and revoke its permissions through your account provider. We may restrict access to address misuse or legal requirements. Your statutory rights remain unaffected.'
107
+ ]
108
+ },
109
+ contact
110
+ ]),
111
+ privacy: draft('Privacy policy', [
112
+ {
113
+ id: 'overview',
114
+ title: 'About this policy',
115
+ paragraphs: [
116
+ `This policy covers data handled by ${app.name}. Your account provider and other AT Protocol services have their own policies.`
117
+ ]
118
+ },
119
+ owner,
120
+ ...config.privacy,
121
+ {
122
+ id: 'rights',
123
+ title: 'Your choices and rights',
124
+ paragraphs: [
125
+ 'Contact us to request access, correction, deletion, or restriction of your personal data, or to object to processing. You can also complain to a data-protection authority where applicable.',
126
+ 'Delete public records or revoke app access through your account provider. We cannot remove copies held by other services.'
127
+ ]
128
+ },
129
+ contact
130
+ ]),
131
+ imprint: draft('Imprint', [owner, contact]),
132
+ moderation: draft('Moderation policy', [...config.moderation, contact])
133
+ };
134
+ }
@@ -0,0 +1,5 @@
1
+ import type { LegalFooterLinks, LegalLink } from './types.js';
2
+ /** Same-origin absolute paths and web URLs only. Never executable, credentialed,
3
+ * protocol-relative, or backslash-normalized URLs. Mail is allowed explicitly. */
4
+ export declare function safeLegalHref(value: string, allowMail?: boolean): string | undefined;
5
+ export declare function legalFooterItems(links: LegalFooterLinks): LegalLink[];
@@ -0,0 +1,39 @@
1
+ /** Same-origin absolute paths and web URLs only. Never executable, credentialed,
2
+ * protocol-relative, or backslash-normalized URLs. Mail is allowed explicitly. */
3
+ export function safeLegalHref(value, allowMail = false) {
4
+ if (!value ||
5
+ /\s/.test(value) ||
6
+ [...value].some((character) => character === '\\' || character.charCodeAt(0) < 32 || character.charCodeAt(0) === 127))
7
+ return undefined;
8
+ if (value.startsWith('/') && !value.startsWith('//'))
9
+ return value;
10
+ if (allowMail && /^mailto:[^@<>?&#%]+@[^@<>?&#%]+\.[^@<>?&#%]+$/.test(value))
11
+ return value;
12
+ try {
13
+ const url = new URL(value);
14
+ if (['http:', 'https:'].includes(url.protocol) && !url.username && !url.password)
15
+ return url.href;
16
+ }
17
+ catch {
18
+ /* Invalid URLs remain non-clickable. */
19
+ }
20
+ return undefined;
21
+ }
22
+ const labels = {
23
+ terms: 'Terms',
24
+ privacy: 'Privacy',
25
+ imprint: 'Imprint',
26
+ moderation: 'Moderation',
27
+ report: 'Report',
28
+ source: 'Source'
29
+ };
30
+ export function legalFooterItems(links) {
31
+ return Object.keys(labels).flatMap((key) => {
32
+ const item = links[key];
33
+ if (!item)
34
+ return [];
35
+ const link = typeof item === 'string' ? { label: labels[key], href: item } : item;
36
+ const href = safeLegalHref(link.href, key === 'report');
37
+ return href ? [{ label: link.label, href }] : [];
38
+ });
39
+ }
@@ -0,0 +1,39 @@
1
+ export type LegalDocumentKind = 'terms' | 'privacy' | 'imprint' | 'moderation';
2
+ export interface LegalLink {
3
+ label: string;
4
+ href: string;
5
+ }
6
+ export interface LegalSection {
7
+ id: string;
8
+ title: string;
9
+ paragraphs?: readonly string[];
10
+ items?: readonly string[];
11
+ address?: readonly string[];
12
+ links?: readonly LegalLink[];
13
+ }
14
+ /** App-owned, serializable text. Kit upgrades do not change a saved document. */
15
+ export interface LegalDocumentData {
16
+ title: string;
17
+ status: 'draft' | 'published';
18
+ /** The app's reviewed revision date, not a Kit release date. */
19
+ updated?: string;
20
+ sections: readonly LegalSection[];
21
+ }
22
+ /** Omit a link, or set it to false, to hide it. */
23
+ export type LegalFooterLink = string | false | LegalLink;
24
+ export type LegalFooterLinks = Partial<Record<LegalDocumentKind | 'report' | 'source', LegalFooterLink>>;
25
+ export interface LegalDraftConfig {
26
+ app: {
27
+ name: string;
28
+ url: string;
29
+ description: string;
30
+ };
31
+ operator: {
32
+ name: string;
33
+ email: string;
34
+ address: readonly string[];
35
+ };
36
+ /** Explicitly describe deployed behavior; templates infer no legal basis. */
37
+ privacy: readonly LegalSection[];
38
+ moderation: readonly LegalSection[];
39
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,140 @@
1
+ <script lang="ts" module>
2
+ import type { ReportSubmit } from './types.js';
3
+
4
+ export interface ReportDialogProps {
5
+ open?: boolean;
6
+ isLoggedIn: boolean;
7
+ /** App-owned report mailbox, not the subject's email address. */
8
+ email: string;
9
+ /** Canonical event/account link for the email fallback. */
10
+ subjectUrl: string;
11
+ submit: ReportSubmit;
12
+ onsignin: () => void;
13
+ class?: string;
14
+ }
15
+ </script>
16
+
17
+ <script lang="ts">
18
+ import Button from '../Button.svelte';
19
+ import Modal from '../Modal.svelte';
20
+ import Textarea from '../Textarea.svelte';
21
+ import { cn } from '../../utils.js';
22
+ import { control } from '../../styles.js';
23
+ import { reportEmailHref } from './email.js';
24
+ import type { ReportReasonType } from './types.js';
25
+
26
+ let {
27
+ open = $bindable(false),
28
+ isLoggedIn,
29
+ email,
30
+ subjectUrl,
31
+ submit,
32
+ onsignin,
33
+ class: className
34
+ }: ReportDialogProps = $props();
35
+ let reasonType = $state<ReportReasonType>('com.atproto.moderation.defs#reasonSpam');
36
+ let reason = $state('');
37
+ let pending = $state(false);
38
+ let message = $state('');
39
+ let sent = $state(false);
40
+ let needsSignIn = $state(false);
41
+ let generation = 0;
42
+ const id = $props.id();
43
+ const emailHref = $derived(reportEmailHref(email, subjectUrl, reason));
44
+
45
+ // Each opening/subject starts fresh. Ignore a late result from a closed or
46
+ // retargeted dialog rather than showing success for a different subject.
47
+ $effect(() => {
48
+ void open;
49
+ void subjectUrl;
50
+ generation += 1;
51
+ reasonType = 'com.atproto.moderation.defs#reasonSpam';
52
+ reason = '';
53
+ pending = false;
54
+ message = '';
55
+ sent = false;
56
+ needsSignIn = false;
57
+ });
58
+
59
+ function signIn() {
60
+ open = false;
61
+ onsignin();
62
+ }
63
+
64
+ async function send(event: SubmitEvent) {
65
+ event.preventDefault();
66
+ if (pending || !isLoggedIn) return;
67
+ const current = ++generation;
68
+ pending = true;
69
+ message = '';
70
+ needsSignIn = false;
71
+ try {
72
+ const failure = await submit({
73
+ reasonType,
74
+ ...(reason.trim() ? { reason: reason.trim() } : {})
75
+ });
76
+ if (current !== generation) return;
77
+ if (failure) {
78
+ message = failure.message;
79
+ needsSignIn = failure.needsSignIn ?? false;
80
+ } else {
81
+ sent = true;
82
+ }
83
+ } catch {
84
+ if (current === generation) {
85
+ message = 'Report could not be sent. Try again or report by email.';
86
+ }
87
+ } finally {
88
+ if (current === generation) pending = false;
89
+ }
90
+ }
91
+ </script>
92
+
93
+ <Modal
94
+ bind:open
95
+ title="Report"
96
+ description={isLoggedIn ? 'Only the moderators can see your report.' : undefined}
97
+ dismissible={!pending}
98
+ class={cn('max-w-md', className)}
99
+ >
100
+ {#if !isLoggedIn}
101
+ <div class="space-y-4">
102
+ <p>
103
+ Sign in to report directly, or email
104
+ <a class="underline" href={emailHref}>{email}</a>. Please include a link to the event or
105
+ account and your reason for reporting.
106
+ </p>
107
+ <Button onclick={signIn}>Sign in</Button>
108
+ </div>
109
+ {:else if sent}
110
+ <p role="status" class="mb-4">Report sent.</p>
111
+ <Button onclick={() => (open = false)}>Done</Button>
112
+ {:else}
113
+ <form onsubmit={send} class="space-y-4">
114
+ <label for={`${id}-reason`} class="block text-sm">Reason</label>
115
+ <select id={`${id}-reason`} bind:value={reasonType} disabled={pending} class={control}>
116
+ <option value="com.atproto.moderation.defs#reasonSpam">Spam</option>
117
+ <option value="com.atproto.moderation.defs#reasonViolation"
118
+ >Harmful or illegal content</option
119
+ >
120
+ <option value="com.atproto.moderation.defs#reasonOther">Other</option>
121
+ </select>
122
+ <label for={`${id}-details`} class="block text-sm">Details (optional)</label>
123
+ <Textarea
124
+ id={`${id}-details`}
125
+ bind:value={reason}
126
+ maxlength={2000}
127
+ rows={4}
128
+ disabled={pending}
129
+ />
130
+ {#if message}
131
+ <div class="space-y-2 text-sm">
132
+ <p role="alert">{message}</p>
133
+ <a class="underline" href={emailHref}>Email {email} instead</a>
134
+ {#if needsSignIn}<Button variant="ghost" onclick={signIn}>Sign in again</Button>{/if}
135
+ </div>
136
+ {/if}
137
+ <Button type="submit" disabled={pending}>{pending ? 'Sending…' : 'Send report'}</Button>
138
+ </form>
139
+ {/if}
140
+ </Modal>
@@ -0,0 +1,15 @@
1
+ import type { ReportSubmit } from './types.js';
2
+ export interface ReportDialogProps {
3
+ open?: boolean;
4
+ isLoggedIn: boolean;
5
+ /** App-owned report mailbox, not the subject's email address. */
6
+ email: string;
7
+ /** Canonical event/account link for the email fallback. */
8
+ subjectUrl: string;
9
+ submit: ReportSubmit;
10
+ onsignin: () => void;
11
+ class?: string;
12
+ }
13
+ declare const ReportDialog: import("svelte").Component<ReportDialogProps, {}, "open">;
14
+ type ReportDialog = ReturnType<typeof ReportDialog>;
15
+ export default ReportDialog;
@@ -0,0 +1,3 @@
1
+ /** The app owns the destination and canonical subject URL. Encode details as
2
+ * body text, never additional mail headers. Use %20 rather than form-style +. */
3
+ export declare function reportEmailHref(email: string, subjectUrl: string, reason?: string): string;
@@ -0,0 +1,6 @@
1
+ /** The app owns the destination and canonical subject URL. Encode details as
2
+ * body text, never additional mail headers. Use %20 rather than form-style +. */
3
+ export function reportEmailHref(email, subjectUrl, reason = '') {
4
+ const body = `Event or account: ${subjectUrl}\n\nReason: ${reason.trim()}`;
5
+ return `mailto:${email}?subject=Content%20report&body=${encodeURIComponent(body)}`;
6
+ }
@@ -0,0 +1,11 @@
1
+ export type ReportReasonType = 'com.atproto.moderation.defs#reasonSpam' | 'com.atproto.moderation.defs#reasonViolation' | 'com.atproto.moderation.defs#reasonOther';
2
+ export interface ReportInput {
3
+ reasonType: ReportReasonType;
4
+ reason?: string;
5
+ }
6
+ /** Return a user-facing failure from the app's submission adapter. */
7
+ export interface ReportFailure {
8
+ message: string;
9
+ needsSignIn?: boolean;
10
+ }
11
+ export type ReportSubmit = (input: ReportInput) => Promise<void | ReportFailure>;
@@ -0,0 +1 @@
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -15,6 +15,15 @@ export { default as Avatar } from './components/Avatar.svelte';
15
15
  export { default as InsetImage } from './components/InsetImage.svelte';
16
16
  export { default as Badge } from './components/Badge.svelte';
17
17
  export { default as Modal } from './components/Modal.svelte';
18
+ export { default as LegalDocument } from './components/legal/LegalDocument.svelte';
19
+ export type { LegalDocumentProps } from './components/legal/LegalDocument.svelte';
20
+ export { default as LegalFooter } from './components/legal/LegalFooter.svelte';
21
+ export type { LegalFooterProps } from './components/legal/LegalFooter.svelte';
22
+ export { createLegalDrafts, getLegalDraftIssues, getLegalDocumentIssues } from './components/legal/drafts.js';
23
+ export type { LegalDocumentKind, LegalDocumentData, LegalSection, LegalLink, LegalFooterLink, LegalFooterLinks, LegalDraftConfig } from './components/legal/types.js';
24
+ export { default as ReportDialog } from './components/report/ReportDialog.svelte';
25
+ export type { ReportDialogProps } from './components/report/ReportDialog.svelte';
26
+ export type { ReportReasonType, ReportInput, ReportFailure, ReportSubmit } from './components/report/types.js';
18
27
  export { default as Tabs } from './components/Tabs.svelte';
19
28
  export type { TabItem } from './components/Tabs.svelte';
20
29
  export { default as Icon } from './components/Icon.svelte';
package/dist/index.js CHANGED
@@ -13,6 +13,10 @@ export { default as Avatar } from './components/Avatar.svelte';
13
13
  export { default as InsetImage } from './components/InsetImage.svelte';
14
14
  export { default as Badge } from './components/Badge.svelte';
15
15
  export { default as Modal } from './components/Modal.svelte';
16
+ export { default as LegalDocument } from './components/legal/LegalDocument.svelte';
17
+ export { default as LegalFooter } from './components/legal/LegalFooter.svelte';
18
+ export { createLegalDrafts, getLegalDraftIssues, getLegalDocumentIssues } from './components/legal/drafts.js';
19
+ export { default as ReportDialog } from './components/report/ReportDialog.svelte';
16
20
  export { default as Tabs } from './components/Tabs.svelte';
17
21
  export { default as Icon } from './components/Icon.svelte';
18
22
  export { default as IconRail } from './components/IconRail.svelte';
@@ -0,0 +1,3 @@
1
+ /** Framework-free authoring helpers for scripts; UI components use the main entry. */
2
+ export { createLegalDrafts, getLegalDraftIssues, getLegalDocumentIssues } from './components/legal/drafts.js';
3
+ export type { LegalDocumentData, LegalDocumentKind, LegalDraftConfig, LegalSection, LegalFooterLinks } from './components/legal/types.js';
package/dist/legal.js ADDED
@@ -0,0 +1,2 @@
1
+ /** Framework-free authoring helpers for scripts; UI components use the main entry. */
2
+ export { createLegalDrafts, getLegalDraftIssues, getLegalDocumentIssues } from './components/legal/drafts.js';
@@ -1,8 +1,9 @@
1
+ import { blueskyProfileUrl } from './urls.js';
1
2
  export function blueskyPostToPostData(post, depth = 0) {
2
3
  const record = post.record ?? post.value;
3
4
  const result = {
4
5
  uri: post.uri,
5
- href: `https://bsky.app/profile/${encodeURIComponent(post.author.did)}/post/${encodeURIComponent(post.uri.split('/').at(-1) ?? '')}`,
6
+ href: `${blueskyProfileUrl(post.author.did)}/post/${encodeURIComponent(post.uri.split('/').at(-1) ?? '')}`,
6
7
  author: {
7
8
  did: post.author.did,
8
9
  handle: post.author.handle,
@@ -1,4 +1,5 @@
1
1
  import { tokenize } from '@atcute/bluesky-richtext-parser';
2
+ import { blueskyProfileUrl } from './urls.js';
2
3
  export function safeWebUrl(url) {
3
4
  if (!url)
4
5
  return undefined;
@@ -81,7 +82,7 @@ export function richTextSegments(text, facets = []) {
81
82
  continue;
82
83
  const feature = facet.features?.[0];
83
84
  const href = feature?.did
84
- ? `https://bsky.app/profile/${encodeURIComponent(feature.did)}`
85
+ ? blueskyProfileUrl(feature.did)
85
86
  : feature?.uri
86
87
  ? safeWebUrl(feature.uri)
87
88
  : feature?.tag
@@ -0,0 +1,2 @@
1
+ /** Bluesky expects literal DIDs in its routes, including colons and did:web escapes. */
2
+ export declare function blueskyProfileUrl(identifier: string): string;
@@ -0,0 +1,6 @@
1
+ import { isDid } from '@atcute/lexicons/syntax';
2
+ /** Bluesky expects literal DIDs in its routes, including colons and did:web escapes. */
3
+ export function blueskyProfileUrl(identifier) {
4
+ const actor = isDid(identifier) ? identifier : encodeURIComponent(identifier);
5
+ return `https://bsky.app/profile/${actor}`;
6
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atmo-dev/kit",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "description": "The shared UI foundation for Atmo apps.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -27,6 +27,10 @@
27
27
  "svelte": "./dist/index.js",
28
28
  "default": "./dist/index.js"
29
29
  },
30
+ "./legal": {
31
+ "types": "./dist/legal.d.ts",
32
+ "default": "./dist/legal.js"
33
+ },
30
34
  "./theme.css": "./dist/theme.css"
31
35
  },
32
36
  "peerDependencies": {