@saga-a2ui/lit 0.0.0-stage → 0.8.0-rc.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/LICENSE +202 -0
- package/README.md +10 -2
- package/package.json +1 -6
- package/src/a2ui/app-element.ts +238 -0
- package/src/actions/action-button.ts +130 -0
- package/src/actions/index.ts +12 -0
- package/src/actions/lit-actions.ts +70 -0
- package/src/actions/mount-owner.ts +42 -0
- package/src/element-registration.ts +48 -0
- package/src/faithful-card/adopt-styles.ts +61 -0
- package/src/faithful-card/blocked-fragment.ts +58 -0
- package/src/faithful-card/card-surface.ts +181 -0
- package/src/faithful-card/catalog.ts +29 -0
- package/src/faithful-card/content-view.ts +102 -0
- package/src/faithful-card/faithful-card-element.ts +746 -0
- package/src/faithful-card/host-link.ts +118 -0
- package/src/faithful-card/index.ts +17 -0
- package/src/faithful-card/register.ts +30 -0
- package/src/index.ts +24 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createInstanceHost,
|
|
3
|
+
snapshotLimits,
|
|
4
|
+
type InstanceView,
|
|
5
|
+
type Limits,
|
|
6
|
+
} from '@saga-a2ui/core/actions';
|
|
7
|
+
import type { A2uiActions } from '../a2ui/app-element.js';
|
|
8
|
+
import { mountOwners } from './mount-owner.js';
|
|
9
|
+
|
|
10
|
+
export interface LitActionsOptions {
|
|
11
|
+
/**
|
|
12
|
+
* Application setup. Run once per presented surface, before it renders, with a view owned by
|
|
13
|
+
* that presentation alone. Discover instances with `watchById` (or `getById` for a unique ID)
|
|
14
|
+
* and bind definitions to the handles it delivers. Everything bound through those handles is
|
|
15
|
+
* disposed when the presentation ends.
|
|
16
|
+
*/
|
|
17
|
+
readonly attach: (view: InstanceView) => void;
|
|
18
|
+
/**
|
|
19
|
+
* Receives the error of a setup that throws, and of a discovery callback that fails after
|
|
20
|
+
* setup began. The element shows its safe fallback in the first case; the error text itself
|
|
21
|
+
* goes only here, never into the page.
|
|
22
|
+
*/
|
|
23
|
+
readonly onSetupError: (error: unknown) => void;
|
|
24
|
+
/**
|
|
25
|
+
* This application's defaults for every action bound through the integration: used for each
|
|
26
|
+
* bound (`deadlineMs`, `hookMs`) an action does not override itself, and before the SDK defaults
|
|
27
|
+
* of 30,000 ms of work and 5,000 ms per awaited hook or cleanup step. Each supplied value is
|
|
28
|
+
* checked when the integration is created, and copied, so changing the object afterwards
|
|
29
|
+
* affects nothing. Each presentation's host gets the same values; another integration has its own.
|
|
30
|
+
*/
|
|
31
|
+
readonly limits?: Limits;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Makes `createA2uiAppElement` give each presented surface its own action host. Passing the
|
|
36
|
+
* result as `actions` is the one place an application opts in.
|
|
37
|
+
*
|
|
38
|
+
* Each actual mount owns its host, its bindings and its discovery subscriptions. They end when the
|
|
39
|
+
* surface is deleted or replaced, when setup fails, and when the app element disconnects. A
|
|
40
|
+
* reconnected element presents a new surface and so starts with a new host and a new `attach`.
|
|
41
|
+
*/
|
|
42
|
+
export function createLitActions(options: LitActionsOptions): A2uiActions {
|
|
43
|
+
const limits = snapshotLimits(options.limits);
|
|
44
|
+
return {
|
|
45
|
+
begin(presentation) {
|
|
46
|
+
const host = createInstanceHost({
|
|
47
|
+
onSetupError: options.onSetupError,
|
|
48
|
+
limits,
|
|
49
|
+
});
|
|
50
|
+
const end = (): void => {
|
|
51
|
+
host.close();
|
|
52
|
+
if (mountOwners.get(presentation.owner)?.host === host)
|
|
53
|
+
mountOwners.delete(presentation.owner);
|
|
54
|
+
};
|
|
55
|
+
mountOwners.set(presentation.owner, {
|
|
56
|
+
host,
|
|
57
|
+
surface: presentation.surface,
|
|
58
|
+
mount: presentation.mount,
|
|
59
|
+
});
|
|
60
|
+
try {
|
|
61
|
+
host.register(options.attach);
|
|
62
|
+
} catch (error) {
|
|
63
|
+
end();
|
|
64
|
+
options.onSetupError(error);
|
|
65
|
+
throw error;
|
|
66
|
+
}
|
|
67
|
+
return end;
|
|
68
|
+
},
|
|
69
|
+
};
|
|
70
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import type { SurfaceModel } from '@a2ui/web_core/v0_9';
|
|
2
|
+
import type { InstanceHost } from '@saga-a2ui/core/actions';
|
|
3
|
+
|
|
4
|
+
/** What one app element presents while its action integration is active. */
|
|
5
|
+
export interface MountOwner {
|
|
6
|
+
readonly host: InstanceHost;
|
|
7
|
+
readonly surface: SurfaceModel<any>;
|
|
8
|
+
/** The one `<a2ui-surface>` the app element rendered for this presentation. */
|
|
9
|
+
readonly mount: () => Element | undefined;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Set while an app element presents one surface, and removed when that presentation ends. */
|
|
13
|
+
export const mountOwners = new WeakMap<Element, MountOwner>();
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Finds the host from the element's position in the DOM, not from the surface object: two
|
|
17
|
+
* `<a2ui-surface .surface=${S}>` mounts for one model share that key, and both would publish into
|
|
18
|
+
* one scope. The walk crosses shadow roots outward, remembers the nearest `<a2ui-surface>` it
|
|
19
|
+
* passes, stops at the first element that owns a presentation, and accepts only when that nearest
|
|
20
|
+
* surface element is the owner's own mount and the owner's surface is the one the element holds.
|
|
21
|
+
* Light DOM or slotted content that never passes through the owner's shadow tree finds nothing.
|
|
22
|
+
*/
|
|
23
|
+
export function hostOf(
|
|
24
|
+
element: Element,
|
|
25
|
+
surface: SurfaceModel<any>,
|
|
26
|
+
): InstanceHost | undefined {
|
|
27
|
+
let nearestSurfaceElement: Element | undefined;
|
|
28
|
+
let root = element.getRootNode();
|
|
29
|
+
while (root instanceof ShadowRoot) {
|
|
30
|
+
const host = root.host;
|
|
31
|
+
if (host.localName === 'a2ui-surface') nearestSurfaceElement ??= host;
|
|
32
|
+
const owner = mountOwners.get(host);
|
|
33
|
+
if (owner) {
|
|
34
|
+
const sameMount =
|
|
35
|
+
nearestSurfaceElement !== undefined &&
|
|
36
|
+
nearestSurfaceElement === owner.mount();
|
|
37
|
+
return owner.surface === surface && sameMount ? owner.host : undefined;
|
|
38
|
+
}
|
|
39
|
+
root = host.getRootNode();
|
|
40
|
+
}
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Custom-element registration for an application's catalog. This module deliberately imports no
|
|
2
|
+
// renderer, so the rule can be exercised without a DOM.
|
|
3
|
+
|
|
4
|
+
/** One custom element an application needs: a catalog component's `LitComponentApi` fits as is. */
|
|
5
|
+
export interface ElementDefinition {
|
|
6
|
+
readonly tagName: string;
|
|
7
|
+
readonly element: CustomElementConstructor;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The part of `CustomElementRegistry` that registration uses. */
|
|
11
|
+
export interface ElementRegistry {
|
|
12
|
+
get(tagName: string): CustomElementConstructor | undefined;
|
|
13
|
+
define(tagName: string, element: CustomElementConstructor): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A tag is already bound to a different constructor, so using it would render the wrong component. */
|
|
17
|
+
export class ElementConflictError extends Error {
|
|
18
|
+
constructor(readonly tagName: string) {
|
|
19
|
+
super(
|
|
20
|
+
`The custom element <${tagName}> is already defined with a different constructor. ` +
|
|
21
|
+
'Give each implementation its own tag name.',
|
|
22
|
+
);
|
|
23
|
+
this.name = 'ElementConflictError';
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Defines each element in the registry. Setup is explicit: importing this package registers nothing.
|
|
29
|
+
*
|
|
30
|
+
* - A tag that is not defined yet is defined.
|
|
31
|
+
* - A tag already bound to the very same constructor is left alone, so repeating setup is harmless.
|
|
32
|
+
* - A tag bound to a different constructor fails with `ElementConflictError`. The existing
|
|
33
|
+
* definition is never replaced, and no definition from the failing call is added: every tag is
|
|
34
|
+
* checked before the first one is defined.
|
|
35
|
+
*/
|
|
36
|
+
export function defineElements(
|
|
37
|
+
definitions: readonly ElementDefinition[],
|
|
38
|
+
registry: ElementRegistry = customElements,
|
|
39
|
+
): void {
|
|
40
|
+
const missing = new Map<string, CustomElementConstructor>();
|
|
41
|
+
for (const { tagName, element } of definitions) {
|
|
42
|
+
const existing = registry.get(tagName) ?? missing.get(tagName);
|
|
43
|
+
if (existing !== undefined && existing !== element)
|
|
44
|
+
throw new ElementConflictError(tagName);
|
|
45
|
+
if (existing === undefined) missing.set(tagName, element);
|
|
46
|
+
}
|
|
47
|
+
for (const [tagName, element] of missing) registry.define(tagName, element);
|
|
48
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// How an application's own styles reach a Lit card. The card lives inside shadow roots that the
|
|
2
|
+
// catalog and the A2UI surface create, and a page's or an element's stylesheet cannot select into
|
|
3
|
+
// them, so a rule such as `.saga-card p` would never match. The view therefore carries the
|
|
4
|
+
// application's constructable stylesheets, and each card adopts them into the root it is in for as
|
|
5
|
+
// long as it is connected. Custom properties inherit through shadow roots and need none of this.
|
|
6
|
+
|
|
7
|
+
/** The part of a document or shadow root that style adoption uses. */
|
|
8
|
+
export interface StyleRoot {
|
|
9
|
+
adoptedStyleSheets: CSSStyleSheet[];
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** Per sheet in a root: how many cards hold it, and whether adoption (not the root's owner) put it there. */
|
|
13
|
+
interface Holding {
|
|
14
|
+
count: number;
|
|
15
|
+
readonly inserted: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const holders = new WeakMap<object, Map<CSSStyleSheet, Holding>>();
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Adds `sheets` to `root`, after the sheets it already has, and returns the function that gives
|
|
22
|
+
* them back. A sheet several cards share stays until the last of them lets go. Only a sheet that
|
|
23
|
+
* adoption itself inserted is ever removed: one the root already had is its owner's and stays,
|
|
24
|
+
* whoever adopts it too. The returned function acts once. A root that cannot adopt styles is left
|
|
25
|
+
* alone.
|
|
26
|
+
*/
|
|
27
|
+
export function adoptStyles(
|
|
28
|
+
root: StyleRoot,
|
|
29
|
+
sheets: readonly CSSStyleSheet[],
|
|
30
|
+
): () => void {
|
|
31
|
+
if (!Array.isArray(root.adoptedStyleSheets) || sheets.length === 0)
|
|
32
|
+
return () => undefined;
|
|
33
|
+
const holdings = holders.get(root) ?? new Map<CSSStyleSheet, Holding>();
|
|
34
|
+
holders.set(root, holdings);
|
|
35
|
+
for (const sheet of sheets) {
|
|
36
|
+
const held = holdings.get(sheet);
|
|
37
|
+
if (held !== undefined) {
|
|
38
|
+
held.count += 1;
|
|
39
|
+
continue;
|
|
40
|
+
}
|
|
41
|
+
const inserted = !root.adoptedStyleSheets.includes(sheet);
|
|
42
|
+
if (inserted) root.adoptedStyleSheets = [...root.adoptedStyleSheets, sheet];
|
|
43
|
+
holdings.set(sheet, { count: 1, inserted });
|
|
44
|
+
}
|
|
45
|
+
let released = false;
|
|
46
|
+
return () => {
|
|
47
|
+
if (released) return;
|
|
48
|
+
released = true;
|
|
49
|
+
for (const sheet of sheets) {
|
|
50
|
+
const held = holdings.get(sheet);
|
|
51
|
+
if (held === undefined) continue;
|
|
52
|
+
held.count -= 1;
|
|
53
|
+
if (held.count > 0) continue;
|
|
54
|
+
holdings.delete(sheet);
|
|
55
|
+
if (held.inserted)
|
|
56
|
+
root.adoptedStyleSheets = root.adoptedStyleSheets.filter(
|
|
57
|
+
(one) => one !== sheet,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { html, type TemplateResult } from 'lit';
|
|
2
|
+
|
|
3
|
+
// How a fragment the content policy blocks (a link, an image reference or raw markup) is shown:
|
|
4
|
+
// as an accessible placeholder, or, once the reader reveals it, as its source text. Both are fixed
|
|
5
|
+
// templates with text bindings. Nothing here activates a destination, loads an image or builds
|
|
6
|
+
// markup from the source, so revealing changes visibility only.
|
|
7
|
+
|
|
8
|
+
export type BlockedKind = 'link' | 'image' | 'markup';
|
|
9
|
+
|
|
10
|
+
export interface BlockedFragment {
|
|
11
|
+
readonly kind: BlockedKind;
|
|
12
|
+
/** Stable hook for tests and styling: `md-link-inert`, `md-image-blocked` or `md-markup`. */
|
|
13
|
+
readonly testId: string;
|
|
14
|
+
/** Why the policy blocked it (a decision reason, or `load-failed`). */
|
|
15
|
+
readonly reason: string;
|
|
16
|
+
readonly revealed: boolean;
|
|
17
|
+
/** Readable text that stands in for the fragment, for example `[Image blocked: chart]`. */
|
|
18
|
+
readonly placeholder: string;
|
|
19
|
+
/** The source form shown when revealed. It may wrap the fragment's own label. */
|
|
20
|
+
readonly source: (label: TemplateResult | undefined) => TemplateResult;
|
|
21
|
+
/** Ordinary text that belongs to the fragment, such as a link's label, kept in place either way. */
|
|
22
|
+
readonly label?: TemplateResult;
|
|
23
|
+
/** A block-level fragment occupies its own paragraph; an inline one stays in its line. */
|
|
24
|
+
readonly block?: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** An inline or block placeholder, or the revealed source, in the fragment's original position. */
|
|
28
|
+
export function blockedFragment(fragment: BlockedFragment): TemplateResult {
|
|
29
|
+
const { kind, testId, reason, revealed, placeholder, source, label, block } =
|
|
30
|
+
fragment;
|
|
31
|
+
const presentation = revealed ? 'source' : 'placeholder';
|
|
32
|
+
// The placeholder is the card's own wording, so it is marked as an annotation; the supplied label
|
|
33
|
+
// beside it, and revealed source, are the author's text and are never marked.
|
|
34
|
+
const annotation = html`<span data-annotation=${`${kind}-blocked`}
|
|
35
|
+
>${placeholder}</span
|
|
36
|
+
>`;
|
|
37
|
+
const standIn =
|
|
38
|
+
label === undefined ? annotation : html`${label} ${annotation}`;
|
|
39
|
+
const content = revealed ? source(label) : standIn;
|
|
40
|
+
return block === true
|
|
41
|
+
? html`<p
|
|
42
|
+
class="saga-card-source"
|
|
43
|
+
data-testid=${testId}
|
|
44
|
+
data-kind=${kind}
|
|
45
|
+
data-reason=${reason}
|
|
46
|
+
data-presentation=${presentation}
|
|
47
|
+
>
|
|
48
|
+
${content}
|
|
49
|
+
</p>`
|
|
50
|
+
: html`<span
|
|
51
|
+
class="saga-card-blocked"
|
|
52
|
+
data-testid=${testId}
|
|
53
|
+
data-kind=${kind}
|
|
54
|
+
data-reason=${reason}
|
|
55
|
+
data-presentation=${presentation}
|
|
56
|
+
>${content}</span
|
|
57
|
+
>`;
|
|
58
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
import type { LitComponentApi } from '@a2ui/lit/v0_9';
|
|
2
|
+
import { Catalog } from '@a2ui/web_core/v0_9';
|
|
3
|
+
import {
|
|
4
|
+
cardData,
|
|
5
|
+
cardLayout,
|
|
6
|
+
FAITHFUL_CARD_CATALOG_ID,
|
|
7
|
+
type CardContent,
|
|
8
|
+
type ImageConsentScope,
|
|
9
|
+
} from '@saga-a2ui/components/faithful-card';
|
|
10
|
+
import { html, nothing } from 'lit';
|
|
11
|
+
import { keyed } from 'lit/directives/keyed.js';
|
|
12
|
+
import {
|
|
13
|
+
createA2uiAppElement,
|
|
14
|
+
type A2uiUpdateResult,
|
|
15
|
+
} from '../a2ui/app-element.js';
|
|
16
|
+
import { faithfulCardEntry } from './catalog.js';
|
|
17
|
+
import { requestContentView } from './content-view.js';
|
|
18
|
+
|
|
19
|
+
// The card's surface for Lit: layer A's layout presented by layer B's app element over the card's
|
|
20
|
+
// own catalog, with the one operation that chooses what the card shows. Selection is not a data
|
|
21
|
+
// update. `present` ends the previous presentation (its image consent, its late link and image
|
|
22
|
+
// answers, its source toggles) and starts a new one even when the text is equal, so a reader's
|
|
23
|
+
// earlier choices never carry over to a new answer. An ordinary update of an own-catalog surface
|
|
24
|
+
// stays in place and is the app element's `updateData`; it is not this operation.
|
|
25
|
+
|
|
26
|
+
/** The tag of the card surface element. `registerFaithfulCard` defines it. */
|
|
27
|
+
export const CARD_SURFACE_TAG = 'saga-faithful-card-surface';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* What an application does with the card. Place `element` in the document beneath the element that
|
|
31
|
+
* provides the card's view (`provideContentView`).
|
|
32
|
+
*/
|
|
33
|
+
export interface CardSurface {
|
|
34
|
+
/** The element that shows the card: an empty card until the first `present`. */
|
|
35
|
+
readonly element: HTMLElement;
|
|
36
|
+
/**
|
|
37
|
+
* Shows `content` as a new presentation. Never throws. Content the surface refuses (for example
|
|
38
|
+
* markdown that is not text) returns the failure and leaves the shown presentation untouched,
|
|
39
|
+
* consent included. Content the view's reader refuses is not refused here: the card shows its
|
|
40
|
+
* own failure for it, in place of the content. Content presented before `element` is in the
|
|
41
|
+
* document is shown once it is.
|
|
42
|
+
*/
|
|
43
|
+
present(content: CardContent): A2uiUpdateResult;
|
|
44
|
+
/** Ends the presentation and empties the card. The next `present` shows again. */
|
|
45
|
+
clear(): void;
|
|
46
|
+
/** Ends the surface. Idempotent. Afterwards `present` fails and the card shows nothing. */
|
|
47
|
+
dispose(): void;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const layout = cardLayout();
|
|
51
|
+
const catalog = new Catalog<LitComponentApi>(FAITHFUL_CARD_CATALOG_ID, 'v0.9', [
|
|
52
|
+
faithfulCardEntry,
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
/** What the element shows: set by the surface that owns it, never by the application. */
|
|
56
|
+
interface Shown {
|
|
57
|
+
readonly content: CardContent | undefined;
|
|
58
|
+
/** Advances with every presentation, so the previous one is replaced even by equal text. */
|
|
59
|
+
readonly presentation: number;
|
|
60
|
+
/** True once the surface is disposed: nothing is shown. An empty or cleared card is still shown, empty. */
|
|
61
|
+
readonly ended: boolean;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The state of one card surface. Only this module can make one. */
|
|
65
|
+
class OpenedCardSurface implements CardSurface {
|
|
66
|
+
readonly element: FaithfulCardSurfaceElement;
|
|
67
|
+
private readonly listeners = new Set<() => void>();
|
|
68
|
+
private consent: ImageConsentScope | undefined;
|
|
69
|
+
private ended = false;
|
|
70
|
+
private current: Shown = {
|
|
71
|
+
content: undefined,
|
|
72
|
+
presentation: 0,
|
|
73
|
+
ended: false,
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
constructor() {
|
|
77
|
+
this.element = new FaithfulCardSurfaceElement();
|
|
78
|
+
this.element.owner = this;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
get shown(): Shown {
|
|
82
|
+
return this.current;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The mounted element tells this surface which consent scope its view owns; it takes it back on removal. */
|
|
86
|
+
attach(
|
|
87
|
+
consent: ImageConsentScope | undefined,
|
|
88
|
+
listener: () => void,
|
|
89
|
+
): () => void {
|
|
90
|
+
this.consent = consent;
|
|
91
|
+
this.listeners.add(listener);
|
|
92
|
+
return () => {
|
|
93
|
+
if (this.consent === consent) this.consent = undefined;
|
|
94
|
+
this.listeners.delete(listener);
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
present(content: CardContent): A2uiUpdateResult {
|
|
99
|
+
if (this.ended)
|
|
100
|
+
return {
|
|
101
|
+
ok: false,
|
|
102
|
+
failure: { kind: 'rejected', message: 'The surface was disposed.' },
|
|
103
|
+
};
|
|
104
|
+
// Checked against the layout first, so content it refuses changes nothing: not the shown
|
|
105
|
+
// presentation, and not the consent given for it.
|
|
106
|
+
const checked = layout.update(cardData(content));
|
|
107
|
+
if (!checked.ok) return { ok: false, failure: checked.failure };
|
|
108
|
+
this.replace(content);
|
|
109
|
+
return { ok: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
clear(): void {
|
|
113
|
+
if (!this.ended) this.replace(undefined);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
dispose(): void {
|
|
117
|
+
if (this.ended) return;
|
|
118
|
+
this.ended = true;
|
|
119
|
+
this.replace(undefined);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
private replace(content: CardContent | undefined): void {
|
|
123
|
+
this.consent?.revokeAll();
|
|
124
|
+
this.current = {
|
|
125
|
+
content,
|
|
126
|
+
presentation: this.current.presentation + 1,
|
|
127
|
+
ended: this.ended,
|
|
128
|
+
};
|
|
129
|
+
for (const listener of [...this.listeners]) listener();
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The element behind {@link CardSurface}: an app element over the card's layout and catalog. */
|
|
134
|
+
export class FaithfulCardSurfaceElement extends createA2uiAppElement({
|
|
135
|
+
catalog,
|
|
136
|
+
layout,
|
|
137
|
+
}) {
|
|
138
|
+
/** Set once by the surface that created this element. */
|
|
139
|
+
owner?: OpenedCardSurface;
|
|
140
|
+
private detach?: () => void;
|
|
141
|
+
|
|
142
|
+
override connectedCallback(): void {
|
|
143
|
+
super.connectedCallback();
|
|
144
|
+
this.detach = this.owner?.attach(requestContentView(this)?.consent, () => {
|
|
145
|
+
this.apply();
|
|
146
|
+
this.requestUpdate();
|
|
147
|
+
});
|
|
148
|
+
this.apply();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
override disconnectedCallback(): void {
|
|
152
|
+
super.disconnectedCallback();
|
|
153
|
+
this.detach?.();
|
|
154
|
+
this.detach = undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
override render() {
|
|
158
|
+
const shown = this.owner?.shown;
|
|
159
|
+
if (shown === undefined || shown.ended) return nothing;
|
|
160
|
+
// The key is the presentation: a new one mounts a fresh card, which carries nothing of the old.
|
|
161
|
+
return html`${keyed(shown.presentation, super.render())}`;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Brings the connected session's data to what the card should show: the content, or nothing. */
|
|
165
|
+
private apply(): void {
|
|
166
|
+
if (!this.isConnected) return;
|
|
167
|
+
this.updateData(cardData(this.owner?.shown.content ?? { markdown: '' }));
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Opens a card surface, empty until the first `present`. Needs `registerFaithfulCard()` to have
|
|
173
|
+
* run, since the surface is a custom element; throws naming that when it has not.
|
|
174
|
+
*/
|
|
175
|
+
export function openFaithfulCardSurface(): CardSurface {
|
|
176
|
+
if (customElements.get(CARD_SURFACE_TAG) !== FaithfulCardSurfaceElement)
|
|
177
|
+
throw new Error(
|
|
178
|
+
`<${CARD_SURFACE_TAG}> is not defined: call registerFaithfulCard() before opening a card surface.`,
|
|
179
|
+
);
|
|
180
|
+
return new OpenedCardSurface();
|
|
181
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { A2uiLitElement, type LitComponentApi } from '@a2ui/lit/v0_9';
|
|
2
|
+
import { FaithfulCardApi } from '@saga-a2ui/components/faithful-card';
|
|
3
|
+
import { html, nothing } from 'lit';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The catalog element. It binds the contract's two values to the card element and nothing else:
|
|
7
|
+
* the trusted reader, policy and callbacks come from the enclosing view, never from properties.
|
|
8
|
+
*/
|
|
9
|
+
export class FaithfulCardElement extends A2uiLitElement<
|
|
10
|
+
typeof FaithfulCardApi
|
|
11
|
+
> {
|
|
12
|
+
protected readonly api = FaithfulCardApi;
|
|
13
|
+
|
|
14
|
+
render() {
|
|
15
|
+
const props = this.controller?.props;
|
|
16
|
+
if (!props) return nothing;
|
|
17
|
+
return html`<saga-faithful-card-content
|
|
18
|
+
.contractVersion=${props.contractVersion}
|
|
19
|
+
.markdown=${props.markdown}
|
|
20
|
+
></saga-faithful-card-content>`;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The catalog entry. An application lists it in its own catalog, then calls `registerFaithfulCard`. */
|
|
25
|
+
export const faithfulCardEntry = {
|
|
26
|
+
...FaithfulCardApi,
|
|
27
|
+
tagName: 'saga-faithful-card',
|
|
28
|
+
element: FaithfulCardElement,
|
|
29
|
+
} satisfies LitComponentApi;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createCardView,
|
|
3
|
+
type CardViewSettings,
|
|
4
|
+
type FaithfulCardReader,
|
|
5
|
+
type ImageConsentScope,
|
|
6
|
+
type ContentPolicy,
|
|
7
|
+
type ImageEnforcement,
|
|
8
|
+
} from '@saga-a2ui/components/faithful-card';
|
|
9
|
+
import type { HostLinks } from '@saga-a2ui/core/host-links';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Everything a card needs from its view, passed explicitly when the view is created: the trusted
|
|
13
|
+
* reader (Markdown dialect and content bound), the immutable trusted policy, the one way to ask
|
|
14
|
+
* the host to open a link, whether the image restrictions the policy relies on are demonstrably
|
|
15
|
+
* in force, and this view's own scope for the reader's revocable image consent. Nothing here is
|
|
16
|
+
* module-wide state, so two views never share or change each other's effects or consent. The
|
|
17
|
+
* scope is separate from the policy: whoever observes a retirement signal for the view (a new
|
|
18
|
+
* selection, teardown) calls `consent.revokeAll()`.
|
|
19
|
+
*/
|
|
20
|
+
export interface ContentView {
|
|
21
|
+
readonly reader: FaithfulCardReader;
|
|
22
|
+
readonly policy: ContentPolicy;
|
|
23
|
+
readonly hostLinks: HostLinks;
|
|
24
|
+
readonly imageEnforcement: ImageEnforcement;
|
|
25
|
+
readonly consent: ImageConsentScope;
|
|
26
|
+
/**
|
|
27
|
+
* The application's own stylesheets for the card. A card lives inside shadow roots that a rule
|
|
28
|
+
* of the page cannot select into, so each connected card adopts these into the root it is in
|
|
29
|
+
* (see `adopt-styles.ts`). Custom properties need none of this: they inherit.
|
|
30
|
+
*/
|
|
31
|
+
readonly styles?: readonly CSSStyleSheet[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What {@link createFaithfulCardView} takes: the renderer-neutral settings and, for Lit, the application's styles. */
|
|
35
|
+
export interface LitCardViewSettings extends CardViewSettings {
|
|
36
|
+
readonly styles?: readonly CSSStyleSheet[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Creates the view an application provides to its cards, once: the reader for its dialect and
|
|
41
|
+
* content bound, the policy and host links it passes, image enforcement established from the
|
|
42
|
+
* Content-Security-Policy values it passes (it reads no document: a caller that has one reads the
|
|
43
|
+
* values and passes them in, for example with the Lit stack's `documentContentSecurityPolicies`) and a
|
|
44
|
+
* fresh consent scope that ends with the view. Throws, naming the setting, when the reader
|
|
45
|
+
* settings are invalid; no view exists then.
|
|
46
|
+
*/
|
|
47
|
+
export function createFaithfulCardView(
|
|
48
|
+
settings: LitCardViewSettings,
|
|
49
|
+
): ContentView {
|
|
50
|
+
const { styles, ...cardSettings } = settings;
|
|
51
|
+
return Object.freeze({
|
|
52
|
+
...createCardView(cardSettings),
|
|
53
|
+
...(styles === undefined ? {} : { styles: Object.freeze([...styles]) }),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export const CONTENT_VIEW_REQUEST = 'saga-content-view-request';
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Sent upward by a card when it connects. The nearest view answers with its own
|
|
61
|
+
* {@link ContentView}. A card nobody answers refuses to show anything.
|
|
62
|
+
*/
|
|
63
|
+
export class ContentViewRequest extends Event {
|
|
64
|
+
private answer?: ContentView;
|
|
65
|
+
|
|
66
|
+
constructor() {
|
|
67
|
+
super(CONTENT_VIEW_REQUEST, { bubbles: true, composed: true });
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
get view(): ContentView | undefined {
|
|
71
|
+
return this.answer;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The nearest view answers first; later answers are ignored. */
|
|
75
|
+
provide(view: ContentView): void {
|
|
76
|
+
this.answer ??= view;
|
|
77
|
+
this.stopPropagation();
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Makes `host` the view for the cards inside it: they receive `view` when they connect. Returns
|
|
83
|
+
* the function that ends it. The host's own element decides when to call each; nothing is
|
|
84
|
+
* registered anywhere else.
|
|
85
|
+
*/
|
|
86
|
+
export function provideContentView(
|
|
87
|
+
host: EventTarget,
|
|
88
|
+
view: ContentView,
|
|
89
|
+
): () => void {
|
|
90
|
+
const answer = (event: Event) => {
|
|
91
|
+
if (event instanceof ContentViewRequest) event.provide(view);
|
|
92
|
+
};
|
|
93
|
+
host.addEventListener(CONTENT_VIEW_REQUEST, answer);
|
|
94
|
+
return () => host.removeEventListener(CONTENT_VIEW_REQUEST, answer);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Asks the nearest enclosing view for its content settings. */
|
|
98
|
+
export function requestContentView(from: EventTarget): ContentView | undefined {
|
|
99
|
+
const request = new ContentViewRequest();
|
|
100
|
+
from.dispatchEvent(request);
|
|
101
|
+
return request.view;
|
|
102
|
+
}
|