@panaversity/ksor 0.0.39 → 0.0.41
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/CHANGELOG.md +965 -0
- package/README.md +29 -9
- package/dist/checker/check-main.mjs +14049 -0
- package/dist/cli.mjs +11528 -5213
- package/dist/gateway-api-CF4ED9_g-BQusM_dK.mjs +10895 -0
- package/dist/gateway.d.mts +52 -13
- package/dist/gateway.mjs +2 -2
- package/dist/index.d.mts +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{src-pl4aOpVs.mjs → src-dqpI-p1a.mjs} +1 -0
- package/docs/authorization.md +8 -6
- package/docs/deploying.md +36 -25
- package/docs/index.md +26 -13
- package/docs/ingesting.md +70 -22
- package/docs/tool-surface.md +69 -16
- package/package.json +4 -3
- package/schema/migrations/2.4-2.5__okf-profile.sql +114 -0
- package/schema/schema.sql +77 -14
- package/templates/scaffold/.agents/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.agents/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.agents/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.agents/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.agents/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.agents/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.claude/skills/add-sources/SKILL.md +63 -18
- package/templates/scaffold/.claude/skills/format-checker/SKILL.md +42 -33
- package/templates/scaffold/.claude/skills/format-checker/check.mjs +13827 -1314
- package/templates/scaffold/.claude/skills/intake-interview/SKILL.md +65 -27
- package/templates/scaffold/.claude/skills/make-slides/SKILL.md +7 -5
- package/templates/scaffold/.claude/skills/make-summary/SKILL.md +13 -6
- package/templates/scaffold/.github/workflows/validate.yml +9 -1
- package/templates/scaffold/.ksor/governance.yaml +17 -0
- package/templates/scaffold/AGENTS.md +293 -119
- package/templates/scaffold/Dockerfile +5 -1
- package/templates/scaffold/README.md +160 -42
- package/templates/scaffold/env.example +37 -6
- package/templates/scaffold/gitignore +14 -6
- package/templates/scaffold/instance.md +21 -17
- package/templates/scaffold/knowledge/governance-ladder.md +11 -2
- package/templates/scaffold/knowledge/index.md +9 -0
- package/templates/scaffold/knowledge/surfaces/for-agents.md +13 -6
- package/templates/scaffold/knowledge/surfaces/for-people.md +7 -6
- package/templates/scaffold/knowledge/surfaces/index.md +4 -20
- package/templates/scaffold/knowledge/surfaces/overview.md +25 -0
- package/templates/scaffold/knowledge/what-is-a-ksor.md +6 -5
- package/templates/scaffold/knowledge/what-is-a-ksor.summary.md +4 -0
- package/templates/scaffold/package.json +3 -4
- package/templates/scaffold/pnpm-lock.yaml +3 -0
- package/templates/scaffold/system/gateways/content.ts +13 -0
- package/templates/scaffold/system/site/app/(home)/page.tsx +2 -2
- package/templates/scaffold/system/site/app/.well-known/mcp/server.json/route.ts +10 -0
- package/templates/scaffold/system/site/app/docs/[[...slug]]/page.tsx +134 -91
- package/templates/scaffold/system/site/app/global.css +198 -6
- package/templates/scaffold/system/site/app/layout.tsx +8 -3
- package/templates/scaffold/system/site/app/llms-full.txt/route.ts +13 -7
- package/templates/scaffold/system/site/app/llms.txt/route.ts +12 -7
- package/templates/scaffold/system/site/app/md/[[...slug]]/route.ts +26 -25
- package/templates/scaffold/system/site/components/code-block.tsx +87 -0
- package/templates/scaffold/system/site/components/embed.tsx +276 -0
- package/templates/scaffold/system/site/components/footer-mark.tsx +3 -2
- package/templates/scaffold/system/site/components/governance.tsx +219 -87
- package/templates/scaffold/system/site/components/mdx.tsx +10 -2
- package/templates/scaffold/system/site/components/record-index.tsx +5 -5
- package/templates/scaffold/system/site/components/record-stack.tsx +10 -9
- package/templates/scaffold/system/site/components/record-views.tsx +6 -17
- package/templates/scaffold/system/site/components/sidebar-status.tsx +19 -18
- package/templates/scaffold/system/site/lib/alert-rule.ts +214 -0
- package/templates/scaffold/system/site/lib/attachment-rule.ts +6 -1
- package/templates/scaffold/system/site/lib/attachments.ts +0 -28
- package/templates/scaffold/system/site/lib/audience-rule.ts +15 -21
- package/templates/scaffold/system/site/lib/audience.ts +42 -146
- package/templates/scaffold/system/site/lib/embed-rule.ts +255 -0
- package/templates/scaffold/system/site/lib/governance.ts +339 -225
- package/templates/scaffold/system/site/lib/index-routes.ts +125 -0
- package/templates/scaffold/system/site/lib/lifecycle-rule.ts +52 -0
- package/templates/scaffold/system/site/lib/lock.ts +282 -0
- package/templates/scaffold/system/site/lib/order-rule.ts +37 -0
- package/templates/scaffold/system/site/lib/record-href.ts +68 -0
- package/templates/scaffold/system/site/lib/record-link.tsx +26 -0
- package/templates/scaffold/system/site/lib/rules-version.ts +11 -0
- package/templates/scaffold/system/site/lib/shared.ts +67 -104
- package/templates/scaffold/system/site/lib/sim-rule.ts +49 -0
- package/templates/scaffold/system/site/lib/source.ts +256 -186
- package/templates/scaffold/system/site/lib/stage-knowledge.ts +638 -467
- package/templates/scaffold/system/site/lib/stage-manifest.ts +128 -0
- package/templates/scaffold/system/site/package.json +1 -0
- package/templates/scaffold/system/site/record/actor.ts +23 -0
- package/templates/scaffold/system/site/record/check.ts +571 -0
- package/templates/scaffold/system/site/record/citations.ts +312 -0
- package/templates/scaffold/system/site/record/frontmatter.ts +134 -0
- package/templates/scaffold/system/site/record/git-ledger.ts +171 -0
- package/templates/scaffold/system/site/record/hygiene.ts +320 -0
- package/templates/scaffold/system/site/record/index-file.ts +150 -0
- package/templates/scaffold/system/site/record/index.ts +103 -0
- package/templates/scaffold/system/site/record/instance.ts +257 -0
- package/templates/scaffold/system/site/record/instant.ts +43 -0
- package/templates/scaffold/system/site/record/ledger.ts +694 -0
- package/templates/scaffold/system/site/record/load.ts +129 -0
- package/templates/scaffold/system/site/record/lock.ts +306 -0
- package/templates/scaffold/system/site/record/near-miss.ts +37 -0
- package/templates/scaffold/system/site/record/policy.ts +414 -0
- package/templates/scaffold/system/site/record/profile.ts +535 -0
- package/templates/scaffold/system/site/record/refusal.ts +106 -0
- package/templates/scaffold/system/site/record/yaml-file.ts +103 -0
- package/templates/scaffold/system/site/source.config.ts +109 -23
- package/dist/gateway-api-CmIthmJS-IUA9qS-T.mjs +0 -3225
- package/templates/scaffold/system/site/lib/denial-rule.ts +0 -220
- package/templates/scaffold/system/site/lib/page-order.ts +0 -93
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { ExternalLink, Play } from "lucide-react";
|
|
4
|
+
import { useCallback, useEffect, useRef, useState, type ReactElement } from "react";
|
|
5
|
+
|
|
6
|
+
import { Button } from "@/components/ui/button";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* An interactive page the document points at.
|
|
10
|
+
*
|
|
11
|
+
* Authored as an ordinary link (see lib/embed-rule.ts); rendered here as a
|
|
12
|
+
* frame the reader loads by asking. The click is not politeness — the
|
|
13
|
+
* scaffold's browser test asserts ZERO external requests on a built page, and
|
|
14
|
+
* that guarantee is what makes this record work offline, behind a firewall,
|
|
15
|
+
* and without telling a third party which document someone is reading. An
|
|
16
|
+
* always-on frame would break it on every page carrying one.
|
|
17
|
+
*
|
|
18
|
+
* Two things the panel says out loud, because they are the reason for the
|
|
19
|
+
* click. It NAMES the host, so a reader consents to a party rather than to a
|
|
20
|
+
* button. And it says the page is not part of the record: an embed carries no
|
|
21
|
+
* provenance claim, cannot be cited, and can change under the document without
|
|
22
|
+
* anyone reviewing it. The link out is always there and costs nothing, because
|
|
23
|
+
* a plain `<a>` is not a request.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A framed page may not scroll the document that hosts it.
|
|
28
|
+
*
|
|
29
|
+
* `scrollIntoView` scrolls EVERY ancestor scrolling box, and a frame's
|
|
30
|
+
* ancestors include the host page — so a page that auto-scrolls its own log
|
|
31
|
+
* throws the reader somewhere else entirely. Six of the seven sims this was
|
|
32
|
+
* built for do exactly that, and clicking one moved the page 5,807px (found
|
|
33
|
+
* live).
|
|
34
|
+
*
|
|
35
|
+
* The call still does its real job inside the frame; only the part that moved
|
|
36
|
+
* the host is undone, in the same task, so nothing is painted in between.
|
|
37
|
+
*
|
|
38
|
+
* Same-origin only, which is all it can be: a cross-origin frame's prototypes
|
|
39
|
+
* are not reachable, and it cannot scroll us either.
|
|
40
|
+
*/
|
|
41
|
+
function containScrolling(doc: Document | null): void {
|
|
42
|
+
const view = doc?.defaultView;
|
|
43
|
+
if (!view) return;
|
|
44
|
+
const proto = view.Element.prototype as Element & {
|
|
45
|
+
scrollIntoView: (...args: unknown[]) => void;
|
|
46
|
+
__ksorContained?: boolean;
|
|
47
|
+
};
|
|
48
|
+
if (proto.__ksorContained) return;
|
|
49
|
+
const native = proto.scrollIntoView;
|
|
50
|
+
proto.scrollIntoView = function contained(this: Element, ...args: unknown[]): void {
|
|
51
|
+
const { scrollX, scrollY } = window;
|
|
52
|
+
native.apply(this, args);
|
|
53
|
+
if (window.scrollX !== scrollX || window.scrollY !== scrollY) {
|
|
54
|
+
window.scrollTo(scrollX, scrollY);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
proto.__ksorContained = true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Close the gutter a framed page leaves around itself.
|
|
62
|
+
*
|
|
63
|
+
* A page that does not reset `body { margin }` keeps the user agent's 8px, and
|
|
64
|
+
* that band shows whatever is behind the frame — so a sim painted near-black
|
|
65
|
+
* sat inside a ring of near-white. Matching the colour instead was tried and
|
|
66
|
+
* does not work: these pages paint below the body, so `html`, `body` and even
|
|
67
|
+
* the wrapper all compute to transparent (measured on the one that has the
|
|
68
|
+
* gutter), and chasing the colour down the tree would be guesswork.
|
|
69
|
+
*
|
|
70
|
+
* Zeroing it is the honest fix, and it is what six of the seven sims already
|
|
71
|
+
* do in their own stylesheet — this only makes the seventh agree. Presentation
|
|
72
|
+
* of the frame is the host's to decide; the page's content is untouched.
|
|
73
|
+
*/
|
|
74
|
+
function closeGutter(doc: Document | null): void {
|
|
75
|
+
const body = doc?.body;
|
|
76
|
+
if (!body) return;
|
|
77
|
+
body.style.margin = "0";
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function Embed({
|
|
81
|
+
url,
|
|
82
|
+
host,
|
|
83
|
+
label,
|
|
84
|
+
owned,
|
|
85
|
+
}: {
|
|
86
|
+
readonly url: string;
|
|
87
|
+
readonly host: string;
|
|
88
|
+
readonly label: string;
|
|
89
|
+
/** "true" when the page is carried IN the record and served from here. */
|
|
90
|
+
readonly owned?: string;
|
|
91
|
+
}): ReactElement {
|
|
92
|
+
const [loaded, setLoaded] = useState(false);
|
|
93
|
+
const [height, setHeight] = useState<number | null>(null);
|
|
94
|
+
const frame = useRef<HTMLIFrameElement>(null);
|
|
95
|
+
const isOwned = owned === "true";
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Fit the frame to the page it holds, so nothing scrolls in a box and no
|
|
99
|
+
* band of dead space sits under it.
|
|
100
|
+
*
|
|
101
|
+
* Measure the body's CHILDREN, never `documentElement.scrollHeight`. These
|
|
102
|
+
* pages set `min-height: 100vh`, and inside a frame the viewport IS the
|
|
103
|
+
* frame — so the document's scroll height is just the frame's own height
|
|
104
|
+
* echoed back, and a frame sized from it grows without bound. Watched that
|
|
105
|
+
* run away before the CSS explained it (2026-08-24). The children do not
|
|
106
|
+
* depend on the frame: measured across all seven sims of the record this was
|
|
107
|
+
* built for, each returned the same height at a 300px frame and a 1400px
|
|
108
|
+
* one.
|
|
109
|
+
*
|
|
110
|
+
* Possible at all only because a carried page is SAME-ORIGIN. A cross-origin
|
|
111
|
+
* frame refuses `contentDocument`, so it keeps the ratio box and this
|
|
112
|
+
* returns without touching anything.
|
|
113
|
+
*/
|
|
114
|
+
const fit = useCallback((): void => {
|
|
115
|
+
let doc: Document | null = null;
|
|
116
|
+
try {
|
|
117
|
+
doc = frame.current?.contentDocument ?? null;
|
|
118
|
+
} catch {
|
|
119
|
+
return; // cross-origin: not ours to measure
|
|
120
|
+
}
|
|
121
|
+
const body = doc?.body;
|
|
122
|
+
if (!body) return;
|
|
123
|
+
// NOT `instanceof HTMLElement`. The elements inside a frame belong to the
|
|
124
|
+
// frame's realm, so they are instances of ITS HTMLElement and never of this
|
|
125
|
+
// one — the filter matched nothing, and the frame kept the ratio box while
|
|
126
|
+
// looking as though measuring had simply not helped (found live).
|
|
127
|
+
const blocks = [...body.children].filter(
|
|
128
|
+
(el): el is HTMLElement => typeof (el as HTMLElement).offsetHeight === "number",
|
|
129
|
+
);
|
|
130
|
+
if (blocks.length === 0) return;
|
|
131
|
+
const bottom = Math.max(...blocks.map((el) => el.offsetTop + el.offsetHeight));
|
|
132
|
+
// The body's own bottom edge, padding AND margin. A page that does not
|
|
133
|
+
// reset `body { margin }` keeps the user agent's 8px, and leaving the
|
|
134
|
+
// margin out left exactly that much overflowing — one scrollbar, on the
|
|
135
|
+
// one sim of seven whose stylesheet omits the reset (found live).
|
|
136
|
+
// `offsetTop` already carries the top margin.
|
|
137
|
+
const style = getComputedStyle(body);
|
|
138
|
+
const edge =
|
|
139
|
+
(Number.parseFloat(style.paddingBottom) || 0) + (Number.parseFloat(style.marginBottom) || 0);
|
|
140
|
+
const measured = Math.ceil(bottom + edge);
|
|
141
|
+
// GROW-ONLY. A running page changes height every beat — measured
|
|
142
|
+
// oscillating 504 to 562 on one sim — and following it exactly would
|
|
143
|
+
// shift everything below the frame while someone is reading. The high
|
|
144
|
+
// water mark costs a little slack after a shrink and never a scrollbar.
|
|
145
|
+
if (measured > 0)
|
|
146
|
+
setHeight((current) => (current === null ? measured : Math.max(current, measured)));
|
|
147
|
+
}, []);
|
|
148
|
+
|
|
149
|
+
const watcher = useRef<ResizeObserver | null>(null);
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Measure on load, then keep watching — these pages animate, and some add a
|
|
153
|
+
* row per beat.
|
|
154
|
+
*
|
|
155
|
+
* Set up HERE rather than in an effect on `loaded`: that effect runs the
|
|
156
|
+
* moment the click flips the state, which is before the frame's document
|
|
157
|
+
* exists, so it observed an empty `about:blank` and never fired again (found
|
|
158
|
+
* live — the frame fitted once and then ignored everything).
|
|
159
|
+
*
|
|
160
|
+
* Watch the CHILDREN, not the body. The body's own box is pinned by the
|
|
161
|
+
* page's `min-height: 100vh` to exactly the frame, so it never reports a
|
|
162
|
+
* change.
|
|
163
|
+
*/
|
|
164
|
+
const handleLoad = useCallback((): void => {
|
|
165
|
+
fit();
|
|
166
|
+
let doc: Document | null = null;
|
|
167
|
+
try {
|
|
168
|
+
doc = frame.current?.contentDocument ?? null;
|
|
169
|
+
} catch {
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
containScrolling(doc);
|
|
173
|
+
closeGutter(doc);
|
|
174
|
+
const body = doc?.body;
|
|
175
|
+
if (!body || typeof ResizeObserver === "undefined") return;
|
|
176
|
+
watcher.current?.disconnect();
|
|
177
|
+
const observer = new ResizeObserver(fit);
|
|
178
|
+
for (const child of body.children) observer.observe(child);
|
|
179
|
+
watcher.current = observer;
|
|
180
|
+
}, [fit]);
|
|
181
|
+
|
|
182
|
+
useEffect(() => () => watcher.current?.disconnect(), []);
|
|
183
|
+
|
|
184
|
+
return (
|
|
185
|
+
<figure
|
|
186
|
+
// The measure, like every other figure in the document. Wider was tried
|
|
187
|
+
// twice — 64rem, then 56rem — and both read as a block that had stopped
|
|
188
|
+
// belonging to the text around it (owner, seen live). These pages are
|
|
189
|
+
// responsive and lay themselves out at whatever width they are given, so
|
|
190
|
+
// the measure costs them nothing.
|
|
191
|
+
className="not-prose my-8 w-full"
|
|
192
|
+
>
|
|
193
|
+
<div
|
|
194
|
+
// A ratio rather than a fixed height, so the frame scales with the
|
|
195
|
+
// measure. The floor is there because these pages are usually taller
|
|
196
|
+
// than they are wide, and a narrow window would otherwise letterbox
|
|
197
|
+
// an interactive thing down to a strip.
|
|
198
|
+
className={
|
|
199
|
+
// Before the click this is a card, and it should read as one. After
|
|
200
|
+
// it, the page inside brings its own surface — and the box's does
|
|
201
|
+
// not match it: a sim painted near-black arrived ringed in
|
|
202
|
+
// near-white, on every side the page did not fill.
|
|
203
|
+
//
|
|
204
|
+
// Fitting the WIDTH to the page was tried and cannot work: these
|
|
205
|
+
// pages are responsive, so narrowing the frame made them reflow
|
|
206
|
+
// narrower still, which left a fresh gap and, on one, a scrollbar
|
|
207
|
+
// (measured). A page that does not fill the width now simply sits on
|
|
208
|
+
// the page, which is the honest arrangement — it is the record's own
|
|
209
|
+
// figure, not a screenshot in a mount.
|
|
210
|
+
loaded
|
|
211
|
+
? "relative w-full overflow-hidden rounded-lg"
|
|
212
|
+
: "relative w-full overflow-hidden rounded-lg border border-fd-border bg-fd-muted"
|
|
213
|
+
}
|
|
214
|
+
// The invitation is a card. Once the page is measured the height goes
|
|
215
|
+
// on the FRAME instead and this box wraps it: put on the box, the
|
|
216
|
+
// border is inside that height, so the frame came out two pixels short
|
|
217
|
+
// and every single embed carried a scrollbar (found live — content 862
|
|
218
|
+
// in a frame of 860). Until it is measured, and always for a frame we
|
|
219
|
+
// may not measure, a ratio with a floor.
|
|
220
|
+
style={
|
|
221
|
+
!loaded
|
|
222
|
+
? { height: "14rem" }
|
|
223
|
+
: height === null
|
|
224
|
+
? { aspectRatio: "16 / 10", minHeight: "26rem" }
|
|
225
|
+
: undefined
|
|
226
|
+
}
|
|
227
|
+
>
|
|
228
|
+
{loaded ? (
|
|
229
|
+
<iframe
|
|
230
|
+
ref={frame}
|
|
231
|
+
onLoad={handleLoad}
|
|
232
|
+
src={url}
|
|
233
|
+
title={label}
|
|
234
|
+
allowFullScreen
|
|
235
|
+
// The host learns that its page was opened, not which document of
|
|
236
|
+
// this record opened it.
|
|
237
|
+
referrerPolicy="no-referrer"
|
|
238
|
+
// Scripts, because the point of the page is that it runs. Not
|
|
239
|
+
// top-navigation, and not forms: a framed page may not steer the
|
|
240
|
+
// reader out of the record or collect anything from inside it.
|
|
241
|
+
sandbox="allow-scripts allow-same-origin allow-popups"
|
|
242
|
+
loading="lazy"
|
|
243
|
+
// Absolute only while the box owns the height, which is the ratio
|
|
244
|
+
// case. With a measured height the frame owns it and lays out in
|
|
245
|
+
// flow, so the box is exactly as tall as the page plus its border.
|
|
246
|
+
className={height === null ? "absolute inset-0 size-full" : "block w-full"}
|
|
247
|
+
style={height === null ? undefined : { height }}
|
|
248
|
+
/>
|
|
249
|
+
) : (
|
|
250
|
+
<div className="absolute inset-0 flex flex-col items-center justify-center gap-4 px-6 text-center">
|
|
251
|
+
<Play aria-hidden className="size-8 text-fd-muted-foreground" />
|
|
252
|
+
<Button onClick={() => setLoaded(true)}>{label}</Button>
|
|
253
|
+
<p className="max-w-sm text-xs text-fd-muted-foreground">
|
|
254
|
+
{isOwned
|
|
255
|
+
? "Part of this record, served from this site. Nothing leaves your browser."
|
|
256
|
+
: `Runs on ${host}, and is not part of this record. Nothing is requested from there until you load it.`}
|
|
257
|
+
</p>
|
|
258
|
+
</div>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
|
|
262
|
+
<figcaption className="mt-2 flex items-center justify-between gap-3 text-xs text-fd-muted-foreground">
|
|
263
|
+
<span>{label}</span>
|
|
264
|
+
<a
|
|
265
|
+
href={url}
|
|
266
|
+
target="_blank"
|
|
267
|
+
rel="noreferrer"
|
|
268
|
+
className="inline-flex items-center gap-1 hover:text-fd-foreground"
|
|
269
|
+
>
|
|
270
|
+
{isOwned ? "Open on its own" : `Open on ${host}`}
|
|
271
|
+
<ExternalLink aria-hidden className="size-3" />
|
|
272
|
+
</a>
|
|
273
|
+
</figcaption>
|
|
274
|
+
</figure>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
@@ -13,10 +13,11 @@ import { audienceNotice } from "@/lib/audience";
|
|
|
13
13
|
* carry the shape of a disclosure.
|
|
14
14
|
*/
|
|
15
15
|
export function FooterMark(): ReactElement {
|
|
16
|
-
|
|
16
|
+
const notice = audienceNotice();
|
|
17
|
+
if (notice === null) return <BuiltWith />;
|
|
17
18
|
return (
|
|
18
19
|
<>
|
|
19
|
-
<BuiltWith /> · <span className="text-fd-muted-foreground">{
|
|
20
|
+
<BuiltWith /> · <span className="text-fd-muted-foreground">{notice}</span>
|
|
20
21
|
</>
|
|
21
22
|
);
|
|
22
23
|
}
|