@unson/brainbase-mcp 0.41.13 → 0.43.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.
@@ -195,6 +195,12 @@ export declare function createWorldModelReadModule(context: LocalWebModuleContex
195
195
  * correction is never saved through a path that skipped the host check.
196
196
  */
197
197
  export declare function createGraphWebModule(context: LocalWebModuleContext): LocalWebModule;
198
+ /**
199
+ * 「あなたの記憶」: the owner's `personal-kg.jsonl`, read only. The memory
200
+ * bodies are private, so these reads require the launch token and the same
201
+ * origin like every write on this host.
202
+ */
203
+ export declare function createPersonalMemoryModule(context: LocalWebModuleContext): LocalWebModule;
198
204
  /** 今日 (value proofs), 目的と現状 (objectives + world model), プロジェクトと関係者 and 情報と関係 (Graph). */
199
205
  export declare function defaultLocalWebModules(context: LocalWebModuleContext): LocalWebModule[];
200
206
  /** Local single-owner host. The caller owns `listen()` and must bind to a loopback address. */
@@ -12,6 +12,7 @@ import { createJudgmentHistoryHttpHandler, JUDGMENT_HISTORY_HTTP_PREFIX } from '
12
12
  import { createJudgmentHistoryReader, createLocalJudgmentHistoryFeedbackWriter, createLocalJudgmentHistorySource } from './judgment-history.js';
13
13
  import { defaultJudgmentJournalRoot, JudgmentValueProofJournalCache } from './judgment-value-proof-review.js';
14
14
  import { createMeetingMinutesHttpHandler } from './meeting-minutes-http.js';
15
+ import { createPersonalMemoryHttpHandler, createPersonalMemoryJudgmentIndex, readLocalPersonalMemories } from './personal-memory.js';
15
16
  import { createMeetingMinutesLineageHttpHandler } from './meeting-minutes-lineage-http.js';
16
17
  import { createMeetingMinutesLineageCorrectionHook, } from './meeting-minutes-lineage-lifecycle.js';
17
18
  import { createMeetingMinutesStore } from './meeting-minutes.js';
@@ -891,6 +892,38 @@ export function createGraphWebModule(context) {
891
892
  handle: handler
892
893
  };
893
894
  }
895
+ /**
896
+ * 「あなたの記憶」: the owner's `personal-kg.jsonl`, read only. The memory
897
+ * bodies are private, so these reads require the launch token and the same
898
+ * origin like every write on this host.
899
+ */
900
+ export function createPersonalMemoryModule(context) {
901
+ const readMemories = () => readLocalPersonalMemories(context.dataDir);
902
+ const index = createPersonalMemoryJudgmentIndex({
903
+ source: createLocalJudgmentHistorySource({
904
+ root: context.journalRoot,
905
+ entrypoint: context.judgmentHistoryEntrypoint ?? 'codex'
906
+ }),
907
+ memoryIds: async () => {
908
+ const list = await readMemories();
909
+ return list.status === 'ok' ? list.items.map((item) => item.id) : null;
910
+ }
911
+ });
912
+ return {
913
+ id: 'personal-memory',
914
+ uiFiles: ['personal-memory.js', 'personal-memory-http-port.js', 'personal-memory.css'],
915
+ handle: createPersonalMemoryHttpHandler({
916
+ readMemories,
917
+ index,
918
+ authorize(request) {
919
+ if (isTrustedLocalWrite(request, context.token))
920
+ return;
921
+ throw rejectUntrustedWrite(request, context.token)
922
+ ?? new LocalWebHttpError(403, 'web_token_required', 'A valid launch token is required');
923
+ }
924
+ })
925
+ };
926
+ }
894
927
  /** 今日 (value proofs), 目的と現状 (objectives + world model), プロジェクトと関係者 and 情報と関係 (Graph). */
895
928
  export function defaultLocalWebModules(context) {
896
929
  return [
@@ -900,7 +933,8 @@ export function defaultLocalWebModules(context) {
900
933
  createMeetingMinutesModule(context),
901
934
  createObjectiveFoundationModule(context),
902
935
  createWorldModelReadModule(context),
903
- createGraphWebModule(context)
936
+ createGraphWebModule(context),
937
+ createPersonalMemoryModule(context)
904
938
  ];
905
939
  }
906
940
  function escapeAttribute(value) {
@@ -0,0 +1,101 @@
1
+ import type { IncomingMessage, ServerResponse } from 'node:http';
2
+ import type { JudgmentHistorySource } from './judgment-history.js';
3
+ import type { PersonalKgEntry } from './types.js';
4
+ /**
5
+ * 「あなたの記憶」 (docs/specs/personal-memory-viewer.md): the owner's own
6
+ * memories read from the local `personal-kg.jsonl`, and the judgments that
7
+ * used each one, found back from the judgment journal's `personal_knowledge`
8
+ * references. Read only. A memory body travels in the response body only,
9
+ * never in a URL or a log line.
10
+ */
11
+ export declare const PERSONAL_MEMORY_HTTP_PREFIX = "/api/personal-memory";
12
+ export type PersonalMemoryState = 'active' | 'superseded' | 'retracted' | 'unrecorded';
13
+ export interface PersonalMemoryItem {
14
+ readonly id: string;
15
+ readonly kind: string | null;
16
+ readonly text: string;
17
+ readonly tags: readonly string[];
18
+ readonly state: PersonalMemoryState;
19
+ readonly supersededBy: string | null;
20
+ readonly origin: {
21
+ readonly organizationLabel: string | null;
22
+ readonly projectLabel: string | null;
23
+ readonly workplace: string | null;
24
+ readonly inferred: boolean;
25
+ };
26
+ readonly occurredAt: string | null;
27
+ readonly updatedAt: string | null;
28
+ readonly recallScope: 'organization' | 'general' | 'unknown';
29
+ readonly history: readonly {
30
+ readonly state: PersonalMemoryState;
31
+ readonly at: string;
32
+ readonly reason: string | null;
33
+ }[];
34
+ }
35
+ export type PersonalMemoryList = {
36
+ readonly status: 'ok';
37
+ readonly items: readonly PersonalMemoryItem[];
38
+ } | {
39
+ readonly status: 'unavailable';
40
+ readonly reason: string;
41
+ };
42
+ export interface PersonalMemoryJudgment {
43
+ readonly decisionId: string;
44
+ readonly title: string | null;
45
+ readonly at: string;
46
+ readonly kind: string | null;
47
+ }
48
+ export type PersonalMemoryJudgments = {
49
+ readonly status: 'ok';
50
+ readonly items: readonly PersonalMemoryJudgment[];
51
+ readonly total: number;
52
+ } | {
53
+ readonly status: 'unavailable';
54
+ readonly reason: string;
55
+ };
56
+ export type PersonalMemoryJudgmentCounts = {
57
+ readonly status: 'ok';
58
+ readonly counts: Readonly<Record<string, number>>;
59
+ } | {
60
+ readonly status: 'unavailable';
61
+ readonly reason: string;
62
+ };
63
+ /** The local writer keeps no state, origin organization or recall scope: the spec's fixed values. */
64
+ export declare function personalMemoryFromEntry(entry: PersonalKgEntry): PersonalMemoryItem;
65
+ /** An empty file is zero memories; a missing or malformed one is unavailable, never zero. */
66
+ export declare function readLocalPersonalMemories(dataDir: string): Promise<PersonalMemoryList>;
67
+ export interface PersonalMemoryJudgmentIndex {
68
+ judgmentsUsing(id: string, limit: number): Promise<PersonalMemoryJudgments>;
69
+ counts(): Promise<PersonalMemoryJudgmentCounts>;
70
+ }
71
+ /**
72
+ * Finds judgments back from their `personal_knowledge` references. A
73
+ * reference names a memory by its id, so the journal is bound to these
74
+ * memories only when at least one recorded reference is one of their ids.
75
+ * When every reference names a memory kept elsewhere (for example the
76
+ * organization's personal KG), "not used" cannot be told from "used under
77
+ * another id", and the answer is unavailable instead of zero.
78
+ */
79
+ export declare function createPersonalMemoryJudgmentIndex(options: {
80
+ readonly source: JudgmentHistorySource;
81
+ /** The memory ids, or null when the memories cannot be read. */
82
+ readonly memoryIds: () => Promise<readonly string[] | null>;
83
+ /**
84
+ * How long one journal read answers later requests. A large journal takes
85
+ * tens of seconds to scan, and one opened memory asks for its judgments and
86
+ * the list's counts at the same time.
87
+ */
88
+ readonly reuseMs?: number;
89
+ readonly now?: () => number;
90
+ }): PersonalMemoryJudgmentIndex;
91
+ /**
92
+ * GET routes under `/api/personal-memory`. `authorize` runs before any read
93
+ * and throws to refuse the request (the local host requires its launch token
94
+ * on these reads, not only on writes).
95
+ */
96
+ export declare function createPersonalMemoryHttpHandler(options: {
97
+ readonly readMemories: () => Promise<PersonalMemoryList>;
98
+ readonly index: PersonalMemoryJudgmentIndex;
99
+ readonly authorize: (request: IncomingMessage) => void;
100
+ readonly basePath?: string;
101
+ }): (request: IncomingMessage, response: ServerResponse) => Promise<boolean>;
@@ -0,0 +1,204 @@
1
+ import { stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { requestUrl, writeJson } from './local-web-security.js';
4
+ import { readPersonalKg } from './ssot.js';
5
+ /**
6
+ * 「あなたの記憶」 (docs/specs/personal-memory-viewer.md): the owner's own
7
+ * memories read from the local `personal-kg.jsonl`, and the judgments that
8
+ * used each one, found back from the judgment journal's `personal_knowledge`
9
+ * references. Read only. A memory body travels in the response body only,
10
+ * never in a URL or a log line.
11
+ */
12
+ export const PERSONAL_MEMORY_HTTP_PREFIX = '/api/personal-memory';
13
+ /** The local writer keeps no state, origin organization or recall scope: the spec's fixed values. */
14
+ export function personalMemoryFromEntry(entry) {
15
+ return {
16
+ id: entry.id,
17
+ kind: entry.type,
18
+ text: entry.text,
19
+ tags: [...entry.tags ?? []],
20
+ state: 'unrecorded',
21
+ supersededBy: null,
22
+ origin: { organizationLabel: '手元', projectLabel: null, workplace: null, inferred: false },
23
+ occurredAt: null,
24
+ updatedAt: entry.updatedAt ?? null,
25
+ recallScope: 'general',
26
+ history: []
27
+ };
28
+ }
29
+ function newestFirst(left, right) {
30
+ if (left === right)
31
+ return 0;
32
+ if (left === null)
33
+ return 1;
34
+ if (right === null)
35
+ return -1;
36
+ return left < right ? 1 : -1;
37
+ }
38
+ /** An empty file is zero memories; a missing or malformed one is unavailable, never zero. */
39
+ export async function readLocalPersonalMemories(dataDir) {
40
+ try {
41
+ await stat(join(dataDir, 'personal-kg.jsonl'));
42
+ }
43
+ catch {
44
+ return { status: 'unavailable', reason: 'personal_kg_missing' };
45
+ }
46
+ let entries;
47
+ try {
48
+ entries = await readPersonalKg(dataDir);
49
+ }
50
+ catch {
51
+ return { status: 'unavailable', reason: 'personal_kg_invalid' };
52
+ }
53
+ const items = entries.map(personalMemoryFromEntry);
54
+ items.sort((left, right) => newestFirst(left.updatedAt, right.updatedAt));
55
+ return { status: 'ok', items };
56
+ }
57
+ function personalReferences(record) {
58
+ const refs = new Set();
59
+ for (const reference of record.judgment.selected_references ?? []) {
60
+ if (reference.kind === 'personal_knowledge' && reference.ref)
61
+ refs.add(reference.ref);
62
+ }
63
+ return refs;
64
+ }
65
+ /**
66
+ * Finds judgments back from their `personal_knowledge` references. A
67
+ * reference names a memory by its id, so the journal is bound to these
68
+ * memories only when at least one recorded reference is one of their ids.
69
+ * When every reference names a memory kept elsewhere (for example the
70
+ * organization's personal KG), "not used" cannot be told from "used under
71
+ * another id", and the answer is unavailable instead of zero.
72
+ */
73
+ export function createPersonalMemoryJudgmentIndex(options) {
74
+ async function build() {
75
+ const [snapshot, ids] = await Promise.all([options.source.read(), options.memoryIds()]);
76
+ if (ids === null)
77
+ return { status: 'unavailable', reason: 'personal_kg_unavailable' };
78
+ if (snapshot.status !== 'available' || !snapshot.records) {
79
+ return { status: 'unavailable', reason: `journal_${snapshot.status === 'partial' ? 'partial' : 'unavailable'}` };
80
+ }
81
+ const known = new Set(ids);
82
+ const byMemory = new Map(ids.map((id) => [id, []]));
83
+ let referenced = 0;
84
+ let bound = 0;
85
+ for (const record of snapshot.records) {
86
+ const refs = personalReferences(record);
87
+ for (const ref of refs) {
88
+ referenced += 1;
89
+ if (!known.has(ref))
90
+ continue;
91
+ bound += 1;
92
+ byMemory.get(ref)?.push({ decisionId: record.record_id, title: record.judgment.summary, at: record.recorded_at, kind: null });
93
+ }
94
+ }
95
+ if (referenced > 0 && bound === 0)
96
+ return { status: 'unavailable', reason: 'reference_source_mismatch' };
97
+ for (const judgments of byMemory.values())
98
+ judgments.sort((left, right) => newestFirst(left.at, right.at));
99
+ return { status: 'ok', byMemory };
100
+ }
101
+ const reuseMs = options.reuseMs ?? 30_000;
102
+ const now = options.now ?? (() => Date.now());
103
+ let current = null;
104
+ function shared() {
105
+ if (current && now() - current.startedAt < reuseMs)
106
+ return current.promise;
107
+ const entry = { promise: build(), startedAt: now() };
108
+ current = entry;
109
+ // A failed read, a missing journal and unreadable memories are not reused: the owner may fix them now.
110
+ const forget = () => { if (current === entry)
111
+ current = null; };
112
+ entry.promise.then((index) => {
113
+ if (index.status === 'unavailable' && (index.reason === 'journal_unavailable' || index.reason === 'personal_kg_unavailable'))
114
+ forget();
115
+ }, forget);
116
+ return entry.promise;
117
+ }
118
+ return {
119
+ async judgmentsUsing(id, limit) {
120
+ const index = await shared();
121
+ if (index.status !== 'ok')
122
+ return index;
123
+ const judgments = index.byMemory.get(id) ?? [];
124
+ return { status: 'ok', items: judgments.slice(0, Math.max(0, limit)), total: judgments.length };
125
+ },
126
+ async counts() {
127
+ const index = await shared();
128
+ if (index.status !== 'ok')
129
+ return index;
130
+ return { status: 'ok', counts: Object.fromEntries([...index.byMemory].map(([id, judgments]) => [id, judgments.length])) };
131
+ }
132
+ };
133
+ }
134
+ const DEFAULT_JUDGMENT_LIMIT = 5;
135
+ const MAX_JUDGMENT_LIMIT = 500;
136
+ /**
137
+ * GET routes under `/api/personal-memory`. `authorize` runs before any read
138
+ * and throws to refuse the request (the local host requires its launch token
139
+ * on these reads, not only on writes).
140
+ */
141
+ export function createPersonalMemoryHttpHandler(options) {
142
+ const basePath = (options.basePath ?? PERSONAL_MEMORY_HTTP_PREFIX).replace(/\/+$/u, '');
143
+ return async (request, response) => {
144
+ const url = requestUrl(request);
145
+ if (url.pathname !== basePath && !url.pathname.startsWith(`${basePath}/`))
146
+ return false;
147
+ if (request.method !== 'GET') {
148
+ writeJson(response, 405, { error: { code: 'method_not_allowed', message: 'Use GET' } });
149
+ return true;
150
+ }
151
+ options.authorize(request);
152
+ const unavailable = (result) => writeJson(response, 503, { status: 'unavailable', reason: result.reason });
153
+ if (url.pathname === basePath) {
154
+ if (url.searchParams.get('view') === 'judgment-counts') {
155
+ const counts = await options.index.counts();
156
+ if (counts.status === 'ok')
157
+ writeJson(response, 200, counts);
158
+ else
159
+ unavailable(counts);
160
+ return true;
161
+ }
162
+ const list = await options.readMemories();
163
+ if (list.status === 'ok')
164
+ writeJson(response, 200, { status: 'ok', items: list.items, next_cursor: null });
165
+ else
166
+ unavailable(list);
167
+ return true;
168
+ }
169
+ const rest = url.pathname.slice(basePath.length + 1).split('/');
170
+ let id;
171
+ try {
172
+ id = decodeURIComponent(rest[0] ?? '');
173
+ }
174
+ catch {
175
+ writeJson(response, 400, { error: { code: 'invalid_id', message: 'The memory id is not valid' } });
176
+ return true;
177
+ }
178
+ if (!id || rest.length > 2 || (rest.length === 2 && rest[1] !== 'judgments')) {
179
+ writeJson(response, 404, { error: { code: 'not_found', message: 'Not found' } });
180
+ return true;
181
+ }
182
+ if (rest.length === 2) {
183
+ const requested = Number.parseInt(url.searchParams.get('limit') ?? '', 10);
184
+ const limit = Number.isInteger(requested) && requested > 0 ? Math.min(requested, MAX_JUDGMENT_LIMIT) : DEFAULT_JUDGMENT_LIMIT;
185
+ const judgments = await options.index.judgmentsUsing(id, limit);
186
+ if (judgments.status === 'ok')
187
+ writeJson(response, 200, judgments);
188
+ else
189
+ unavailable(judgments);
190
+ return true;
191
+ }
192
+ const list = await options.readMemories();
193
+ if (list.status !== 'ok') {
194
+ unavailable(list);
195
+ return true;
196
+ }
197
+ const item = list.items.find((candidate) => candidate.id === id);
198
+ if (item)
199
+ writeJson(response, 200, { status: 'ok', item });
200
+ else
201
+ writeJson(response, 404, { status: 'not_found' });
202
+ return true;
203
+ };
204
+ }
package/dist/ssot.d.ts CHANGED
@@ -1,7 +1,9 @@
1
1
  import { type CanonicalGraphMigrationPlan } from './ontology-migration.js';
2
- import type { PersonalOs } from './types.js';
2
+ import type { PersonalKgEntry, PersonalOs } from './types.js';
3
3
  export declare function initializePersonalOs(dataDir: string): Promise<void>;
4
4
  export declare function loadPersonalOs(dataDir: string): Promise<PersonalOs>;
5
+ /** Only `personal-kg.jsonl`, validated line by line as `loadPersonalOs` does. */
6
+ export declare function readPersonalKg(dataDir: string): Promise<PersonalKgEntry[]>;
5
7
  export declare function mutatePersonalOs(dataDir: string, mutator: (current: PersonalOs) => PersonalOs | Promise<PersonalOs>): Promise<PersonalOs>;
6
8
  export declare function mutatePersonalOsWithSidecar<T>(dataDir: string, sidecarPath: string, mutator: (current: PersonalOs, sidecarContent?: string) => {
7
9
  next: PersonalOs;
package/dist/ssot.js CHANGED
@@ -74,6 +74,10 @@ export async function loadPersonalOs(dataDir) {
74
74
  return loadPersonalOsUnlocked(dataDir);
75
75
  });
76
76
  }
77
+ /** Only `personal-kg.jsonl`, validated line by line as `loadPersonalOs` does. */
78
+ export async function readPersonalKg(dataDir) {
79
+ return readJsonl(join(dataDir, 'personal-kg.jsonl'), personalKgSchema, 'personal-kg.jsonl');
80
+ }
77
81
  export async function mutatePersonalOs(dataDir, mutator) {
78
82
  return withSsotLock(dataDir, async () => {
79
83
  await recoverTransactions(dataDir);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.41.13",
3
+ "version": "0.43.0",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -295,6 +295,10 @@
295
295
  "types": "./dist/streamable-http.d.ts",
296
296
  "import": "./dist/streamable-http.js"
297
297
  },
298
+ "./personal-memory": {
299
+ "types": "./dist/personal-memory.d.ts",
300
+ "import": "./dist/personal-memory.js"
301
+ },
298
302
  "./meeting-minutes": {
299
303
  "types": "./dist/meeting-minutes.d.ts",
300
304
  "import": "./dist/meeting-minutes.js"
@@ -328,6 +332,11 @@
328
332
  "./ui/world-model-view.css": "./ui/world-model-view.css",
329
333
  "./ui/people-and-companies-view": "./ui/people-and-companies-view.js",
330
334
  "./ui/people-and-companies-view.css": "./ui/people-and-companies-view.css",
335
+ "./ui/personal-memory": "./ui/personal-memory.js",
336
+ "./ui/personal-memory.css": "./ui/personal-memory.css",
337
+ "./ui/personal-memory-http-port": "./ui/personal-memory-http-port.js",
338
+ "./ui/inbox": "./ui/inbox.js",
339
+ "./ui/inbox.css": "./ui/inbox.css",
331
340
  "./ui/area-nav": "./ui/area-nav.js",
332
341
  "./ui/area-nav.css": "./ui/area-nav.css",
333
342
  "./ui/local-web-shell": "./ui/local-web-shell.js",
@@ -487,5 +496,5 @@
487
496
  "engines": {
488
497
  "node": ">=20"
489
498
  },
490
- "gitHead": "fcfea3958913012579029adf0bf785c45643c77b"
499
+ "gitHead": "a8166d088899a27bb8267af9e6dd99d3550c7b63"
491
500
  }
package/ui/area-nav.css CHANGED
@@ -1,15 +1,66 @@
1
1
  /*
2
- * Area navigation (ledger D10, stage 2): the area links on the left and the
3
- * tabs of the current area above the page. Uses only the shared tokens in
4
- * brainbase-tokens.css; a host gives the area links its own look through
2
+ * Area navigation (ledger D10): the destinations on the left, grouped under
3
+ * small headings with 設定 at the bottom, and the tabs of a destination with
4
+ * several screens above the page. Uses only the shared tokens in
5
+ * brainbase-tokens.css; a host gives the links its own look through
5
6
  * `linkClassName`.
6
7
  */
7
8
  .bb-area-nav {
9
+ flex: 1 1 auto;
10
+ display: flex;
11
+ flex-direction: column;
12
+ gap: 2px;
13
+ }
14
+
15
+ .bb-area-section,
16
+ .bb-area-section-links {
8
17
  display: grid;
9
18
  gap: 2px;
10
19
  }
11
20
 
21
+ /* Space between the groups; the footer group sits at the bottom of the nav. */
22
+ .bb-area-section + .bb-area-section {
23
+ margin-top: var(--bb-space-4);
24
+ }
25
+
26
+ .bb-area-section.is-footer {
27
+ margin-top: auto;
28
+ padding-top: var(--bb-space-4);
29
+ }
30
+
31
+ .bb-area-section-label {
32
+ padding: 0 10px 4px;
33
+ color: var(--bb-color-rail-muted, var(--bb-color-muted));
34
+ font-size: 11px;
35
+ font-weight: 700;
36
+ letter-spacing: .04em;
37
+ }
38
+
39
+ .bb-area-section-link {
40
+ display: flex;
41
+ align-items: center;
42
+ gap: 4px;
43
+ border-radius: 6px;
44
+ padding-top: 4px;
45
+ text-decoration: none;
46
+ }
47
+
48
+ /* A heading that opens a screen says so with a chevron (not part of its name). */
49
+ .bb-area-section-link::after {
50
+ content: "›";
51
+ font-size: 13px;
52
+ line-height: 1;
53
+ }
54
+
55
+ .bb-area-section-link:hover,
56
+ .bb-area-section-link[aria-current="page"] {
57
+ color: var(--bb-color-accent);
58
+ }
59
+
12
60
  .bb-area-link[hidden],
61
+ .bb-area-section[hidden],
62
+ .bb-area-section-label[hidden],
63
+ .bb-area-section-links[hidden],
13
64
  .bb-area-tabs[hidden] {
14
65
  display: none;
15
66
  }
@@ -67,7 +118,8 @@
67
118
  }
68
119
 
69
120
  .bb-area-tab:focus-visible,
70
- .bb-area-link:focus-visible {
121
+ .bb-area-link:focus-visible,
122
+ .bb-area-section-link:focus-visible {
71
123
  outline: 2px solid var(--bb-color-accent);
72
124
  outline-offset: -2px;
73
125
  }