@unson/brainbase-mcp 0.41.8 → 0.41.9
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/dist/foundation-graph-http.js +5 -1
- package/dist/graph-foundation-reader.d.ts +27 -2
- package/dist/graph-foundation-reader.js +97 -1
- package/package.json +2 -2
- package/ui/foundation-overview.js +70 -14
- package/ui/workspace-kit.css +42 -0
- package/ui/workspace-kit.js +165 -0
- package/ui/world/world-canvas-notes.js +2 -1
- package/ui/world/world-canvas-ui.css +48 -3
- package/ui/world/world-canvas-ui.js +17 -4
- package/ui/world/world-district.js +34 -5
- package/ui/world/world-view.css +6 -0
- package/ui/world/world-view.js +109 -19
|
@@ -253,13 +253,17 @@ function createFoundationCatalogRoute(readers, scopeId, context) {
|
|
|
253
253
|
const variables = await readers.store.list('variable', context);
|
|
254
254
|
const models = await readers.store.list('model', context);
|
|
255
255
|
const philosophies = await readers.listPhilosophies(context);
|
|
256
|
+
// Philosophies without recorded applicability are still read by the
|
|
257
|
+
// judgment frame catalog; report them separately rather than as absent.
|
|
258
|
+
const philosophiesWithoutApplicability = await readers.listUnscopedPhilosophies(context);
|
|
256
259
|
return jsonResponse(200, {
|
|
257
260
|
contractVersion: FOUNDATION_OVERVIEW_CONTRACT_VERSION,
|
|
258
261
|
scopeId,
|
|
259
262
|
objectives,
|
|
260
263
|
variables,
|
|
261
264
|
models,
|
|
262
|
-
philosophies
|
|
265
|
+
philosophies,
|
|
266
|
+
philosophies_without_applicability: philosophiesWithoutApplicability
|
|
263
267
|
});
|
|
264
268
|
}
|
|
265
269
|
};
|
|
@@ -25,7 +25,7 @@ export declare const GRAPH_FOUNDATION_LATEST_SQL = "\nSELECT\n history.entity_i
|
|
|
25
25
|
* project. The project filter is by canonical project code, which is the
|
|
26
26
|
* scope identifier selected by the organization BFF.
|
|
27
27
|
*/
|
|
28
|
-
export declare const GRAPH_FOUNDATION_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nLEFT JOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE ($1::text IS NULL OR current_entity.entity_type = $1)\n AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)\n AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')\nORDER BY current_entity.id, current_entity.entity_type\n";
|
|
28
|
+
export declare const GRAPH_FOUNDATION_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nLEFT JOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE ($1::text IS NULL OR current_entity.entity_type = $1)\n AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)\n AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')\n -- A list projects current records only. A retired row is not current, and\n -- reading it would make one replaced record fail the whole list.\n AND current_entity.lifecycle_status = 'active'\nORDER BY current_entity.id, current_entity.entity_type\n";
|
|
29
29
|
/**
|
|
30
30
|
* List the newest immutable revision for each philosophy attached to the
|
|
31
31
|
* selected project. The current Graph row is still the authorization
|
|
@@ -33,7 +33,29 @@ export declare const GRAPH_FOUNDATION_LIST_SQL = "\nSELECT\n history.entity_id,
|
|
|
33
33
|
* Unlike the Foundation list, this query requires a canonical project join so
|
|
34
34
|
* a missing project membership cannot be interpreted as a selected-scope hit.
|
|
35
35
|
*/
|
|
36
|
-
export declare const GRAPH_PHILOSOPHY_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nJOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE current_entity.entity_type = 'philosophy'\n AND projects.code = $1\n AND current_entity.payload ? 'judgmentApplicability'\nORDER BY current_entity.id\n";
|
|
36
|
+
export declare const GRAPH_PHILOSOPHY_LIST_SQL = "\nSELECT\n history.entity_id,\n history.entity_type,\n history.revision,\n history.payload,\n history.project_id,\n history.role_min,\n history.sensitivity,\n history.lifecycle_status,\n history.storage_digest,\n history.captured_at,\n (\n history.storage_digest = 'sha256:' ||\n encode(sha256(convert_to(history.payload::text, 'UTF8')), 'hex')\n ) AS storage_digest_valid,\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.role_min AS current_role_min,\n current_entity.sensitivity AS current_sensitivity,\n current_entity.lifecycle_status AS current_lifecycle_status,\n projects.code AS current_project_code,\n true AS current_visible\nFROM public.graph_entities AS current_entity\nJOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nLEFT JOIN LATERAL (\n SELECT history.*\n FROM public.graph_foundation_revisions AS history\n WHERE history.entity_id = current_entity.id\n AND history.entity_type = current_entity.entity_type\n ORDER BY history.revision::numeric DESC\n LIMIT 1\n) AS history ON true\nWHERE current_entity.entity_type = 'philosophy'\n AND projects.code = $1\n AND current_entity.lifecycle_status = 'active'\n AND current_entity.payload ? 'judgmentApplicability'\nORDER BY current_entity.id\n";
|
|
37
|
+
/**
|
|
38
|
+
* Current philosophies of the selected project that predate recorded
|
|
39
|
+
* applicability. They are excluded from the verified list above, but the
|
|
40
|
+
* judgment frame catalog still reads them, so the overview must show them as
|
|
41
|
+
* "applicability unrecorded" instead of reporting that none are registered.
|
|
42
|
+
*/
|
|
43
|
+
export declare const GRAPH_UNSCOPED_PHILOSOPHY_LIST_SQL = "\nSELECT\n current_entity.id AS current_entity_id,\n current_entity.entity_type AS current_entity_type,\n current_entity.payload AS current_payload,\n current_entity.project_id AS current_project_id,\n current_entity.lifecycle_status AS current_lifecycle_status,\n current_entity.version AS current_version,\n projects.code AS current_project_code\nFROM public.graph_entities AS current_entity\nJOIN public.projects AS projects\n ON projects.id = current_entity.project_id\nWHERE current_entity.entity_type = 'philosophy'\n AND projects.code = $1\n AND current_entity.lifecycle_status = 'active'\n AND NOT (current_entity.payload ? 'judgmentApplicability')\nORDER BY current_entity.id\n";
|
|
44
|
+
/** A current philosophy whose judgment applicability has never been recorded. */
|
|
45
|
+
export interface UnscopedPhilosophyRecord {
|
|
46
|
+
readonly kind: 'philosophy';
|
|
47
|
+
readonly id: string;
|
|
48
|
+
/** Current Graph version; there is no verified foundation revision. */
|
|
49
|
+
readonly version: number;
|
|
50
|
+
readonly applicability: null;
|
|
51
|
+
readonly applicability_state: 'unrecorded';
|
|
52
|
+
readonly payload: {
|
|
53
|
+
readonly title?: string;
|
|
54
|
+
readonly statement: string;
|
|
55
|
+
readonly summary?: string;
|
|
56
|
+
readonly status?: string;
|
|
57
|
+
};
|
|
58
|
+
}
|
|
37
59
|
/** Flat row shape returned by GRAPH_FOUNDATION_READ_SQL. */
|
|
38
60
|
export interface GraphFoundationHistoryRow {
|
|
39
61
|
readonly entity_id?: unknown;
|
|
@@ -55,6 +77,7 @@ export interface GraphFoundationHistoryRow {
|
|
|
55
77
|
readonly current_role_min?: unknown;
|
|
56
78
|
readonly current_sensitivity?: unknown;
|
|
57
79
|
readonly current_lifecycle_status?: unknown;
|
|
80
|
+
readonly current_version?: unknown;
|
|
58
81
|
readonly current_visible?: unknown;
|
|
59
82
|
}
|
|
60
83
|
export interface GraphFoundationQueryResult {
|
|
@@ -91,6 +114,8 @@ export interface GraphFoundationReaders {
|
|
|
91
114
|
readonly philosophyReader: PhilosophyRevisionReader;
|
|
92
115
|
/** List latest, current-visible philosophy revisions for the selected project. */
|
|
93
116
|
readonly listPhilosophies: (context: FoundationStoreContext) => Promise<PhilosophyRevisionRecord[]>;
|
|
117
|
+
/** List current philosophies of the selected project without recorded applicability. */
|
|
118
|
+
readonly listUnscopedPhilosophies: (context: FoundationStoreContext) => Promise<UnscopedPhilosophyRecord[]>;
|
|
94
119
|
}
|
|
95
120
|
/**
|
|
96
121
|
* Create both public read ports over one trusted, transaction-bound context.
|
|
@@ -141,6 +141,9 @@ LEFT JOIN LATERAL (
|
|
|
141
141
|
WHERE ($1::text IS NULL OR current_entity.entity_type = $1)
|
|
142
142
|
AND ($2::text IS NULL OR projects.code = $2 OR projects.id IS NULL)
|
|
143
143
|
AND current_entity.entity_type IN ('objective', 'variable', 'model', 'constraint')
|
|
144
|
+
-- A list projects current records only. A retired row is not current, and
|
|
145
|
+
-- reading it would make one replaced record fail the whole list.
|
|
146
|
+
AND current_entity.lifecycle_status = 'active'
|
|
144
147
|
ORDER BY current_entity.id, current_entity.entity_type
|
|
145
148
|
`;
|
|
146
149
|
/**
|
|
@@ -188,9 +191,34 @@ LEFT JOIN LATERAL (
|
|
|
188
191
|
) AS history ON true
|
|
189
192
|
WHERE current_entity.entity_type = 'philosophy'
|
|
190
193
|
AND projects.code = $1
|
|
194
|
+
AND current_entity.lifecycle_status = 'active'
|
|
191
195
|
AND current_entity.payload ? 'judgmentApplicability'
|
|
192
196
|
ORDER BY current_entity.id
|
|
193
197
|
`;
|
|
198
|
+
/**
|
|
199
|
+
* Current philosophies of the selected project that predate recorded
|
|
200
|
+
* applicability. They are excluded from the verified list above, but the
|
|
201
|
+
* judgment frame catalog still reads them, so the overview must show them as
|
|
202
|
+
* "applicability unrecorded" instead of reporting that none are registered.
|
|
203
|
+
*/
|
|
204
|
+
export const GRAPH_UNSCOPED_PHILOSOPHY_LIST_SQL = `
|
|
205
|
+
SELECT
|
|
206
|
+
current_entity.id AS current_entity_id,
|
|
207
|
+
current_entity.entity_type AS current_entity_type,
|
|
208
|
+
current_entity.payload AS current_payload,
|
|
209
|
+
current_entity.project_id AS current_project_id,
|
|
210
|
+
current_entity.lifecycle_status AS current_lifecycle_status,
|
|
211
|
+
current_entity.version AS current_version,
|
|
212
|
+
projects.code AS current_project_code
|
|
213
|
+
FROM public.graph_entities AS current_entity
|
|
214
|
+
JOIN public.projects AS projects
|
|
215
|
+
ON projects.id = current_entity.project_id
|
|
216
|
+
WHERE current_entity.entity_type = 'philosophy'
|
|
217
|
+
AND projects.code = $1
|
|
218
|
+
AND current_entity.lifecycle_status = 'active'
|
|
219
|
+
AND NOT (current_entity.payload ? 'judgmentApplicability')
|
|
220
|
+
ORDER BY current_entity.id
|
|
221
|
+
`;
|
|
194
222
|
const FOUNDATION_TYPES = ['objective', 'variable', 'model', 'constraint'];
|
|
195
223
|
const REVISION_PATTERN = /^[1-9]\d*$/u;
|
|
196
224
|
const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/u;
|
|
@@ -284,7 +312,75 @@ export function createGraphFoundationReaders(options) {
|
|
|
284
312
|
return createPhilosophyRevisionRecord(validated, id, revision, context.principal);
|
|
285
313
|
});
|
|
286
314
|
}
|
|
287
|
-
|
|
315
|
+
async function listUnscopedPhilosophies(suppliedContext) {
|
|
316
|
+
assertTrustedPrincipal(context, suppliedContext);
|
|
317
|
+
if (selectedProjectCode === undefined) {
|
|
318
|
+
throw scopeViolation('A selected project is required to list philosophies');
|
|
319
|
+
}
|
|
320
|
+
let result;
|
|
321
|
+
try {
|
|
322
|
+
result = await query(GRAPH_UNSCOPED_PHILOSOPHY_LIST_SQL, [selectedProjectCode]);
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
throw corrupt('Graph unscoped philosophy list query failed');
|
|
326
|
+
}
|
|
327
|
+
const rows = Array.isArray(result) ? result
|
|
328
|
+
: 'rows' in result && Array.isArray(result.rows) ? result.rows
|
|
329
|
+
: null;
|
|
330
|
+
if (!rows)
|
|
331
|
+
throw corrupt('Graph unscoped philosophy list query returned an invalid result');
|
|
332
|
+
const seen = new Set();
|
|
333
|
+
const records = [];
|
|
334
|
+
for (const row of rows) {
|
|
335
|
+
const id = requireString(row.current_entity_id, 'Current philosophy id');
|
|
336
|
+
if (row.current_entity_type !== 'philosophy')
|
|
337
|
+
throw corrupt('Graph philosophy list returned another entity type');
|
|
338
|
+
if (row.current_project_code !== selectedProjectCode) {
|
|
339
|
+
throw scopeViolation('The current philosophy project is outside the selected project scope');
|
|
340
|
+
}
|
|
341
|
+
if (seen.has(id))
|
|
342
|
+
throw corrupt('Philosophy list returned duplicate entities');
|
|
343
|
+
seen.add(id);
|
|
344
|
+
const version = Number(row.current_version);
|
|
345
|
+
if (!Number.isSafeInteger(version) || version < 1)
|
|
346
|
+
throw corrupt('Current philosophy version is invalid');
|
|
347
|
+
const payload = requireObject(row.current_payload, 'Current philosophy payload');
|
|
348
|
+
if (Object.hasOwn(payload, 'judgmentApplicability')) {
|
|
349
|
+
throw corrupt('Unscoped philosophy list returned a philosophy with applicability');
|
|
350
|
+
}
|
|
351
|
+
// Private philosophies always carry applicability; an owner-only ACL here
|
|
352
|
+
// is still enforced rather than silently shown.
|
|
353
|
+
assertPrivatePhilosophyRead(payload, context.principal);
|
|
354
|
+
// Mirror the judgment frame catalog: superseded or text-less records are
|
|
355
|
+
// not used for judgment, so they are not shown as usable either.
|
|
356
|
+
const status = optionalText(payload.status);
|
|
357
|
+
if (status && /^(superseded|retired|rejected)/u.test(status))
|
|
358
|
+
continue;
|
|
359
|
+
const statement = optionalText(payload.statement);
|
|
360
|
+
if (!statement)
|
|
361
|
+
continue;
|
|
362
|
+
const title = optionalText(payload.display_name) ?? optionalText(payload.title);
|
|
363
|
+
const summary = optionalText(payload.summary);
|
|
364
|
+
records.push({
|
|
365
|
+
kind: 'philosophy',
|
|
366
|
+
id,
|
|
367
|
+
version,
|
|
368
|
+
applicability: null,
|
|
369
|
+
applicability_state: 'unrecorded',
|
|
370
|
+
payload: {
|
|
371
|
+
...(title ? { title } : {}),
|
|
372
|
+
statement,
|
|
373
|
+
...(summary ? { summary } : {}),
|
|
374
|
+
...(status ? { status } : {})
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
}
|
|
378
|
+
return records;
|
|
379
|
+
}
|
|
380
|
+
return { store, philosophyReader, listPhilosophies, listUnscopedPhilosophies };
|
|
381
|
+
}
|
|
382
|
+
function optionalText(value) {
|
|
383
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
288
384
|
}
|
|
289
385
|
function createPhilosophyRevisionRecord(validated, id, revision, principal) {
|
|
290
386
|
return {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@unson/brainbase-mcp",
|
|
3
|
-
"version": "0.41.
|
|
3
|
+
"version": "0.41.9",
|
|
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",
|
|
@@ -483,5 +483,5 @@
|
|
|
483
483
|
"engines": {
|
|
484
484
|
"node": ">=20"
|
|
485
485
|
},
|
|
486
|
-
"gitHead": "
|
|
486
|
+
"gitHead": "76bf4ebe18b116379d4687272baecdc4a74dbb35"
|
|
487
487
|
}
|
|
@@ -148,6 +148,22 @@ function validatePhilosophyRecords(value) {
|
|
|
148
148
|
return value;
|
|
149
149
|
}
|
|
150
150
|
|
|
151
|
+
function validateUnscopedPhilosophyRecords(value) {
|
|
152
|
+
if (!Array.isArray(value)) throw error('invalid_schema', '適用範囲が未記録の哲学の形式を確認できません。');
|
|
153
|
+
for (const [index, item] of value.entries()) {
|
|
154
|
+
if (!isObject(item)
|
|
155
|
+
|| item.kind !== 'philosophy'
|
|
156
|
+
|| !nonEmpty(item.id)
|
|
157
|
+
|| !Number.isSafeInteger(item.version) || item.version < 1
|
|
158
|
+
|| item.applicability_state !== 'unrecorded'
|
|
159
|
+
|| !isObject(item.payload)
|
|
160
|
+
|| !nonEmpty(item.payload.statement)) {
|
|
161
|
+
throw error('invalid_schema', `適用範囲が未記録の哲学の${index + 1}件目の形式が不正です。`);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return value;
|
|
165
|
+
}
|
|
166
|
+
|
|
151
167
|
/** Validate the host contract without converting an unreadable response into an empty catalog. */
|
|
152
168
|
export function normalizeFoundationCatalog(value, expectedScopeId) {
|
|
153
169
|
if (!isObject(value) || value.contractVersion !== FOUNDATION_OVERVIEW_CONTRACT_VERSION) {
|
|
@@ -163,7 +179,10 @@ export function normalizeFoundationCatalog(value, expectedScopeId) {
|
|
|
163
179
|
const knownReadableStatuses = new Set(['complete', 'ready', 'resolved', 'empty']);
|
|
164
180
|
const unreadableStatus = (readStatus && !knownReadableStatuses.has(readStatus))
|
|
165
181
|
|| (catalogStatus && !knownReadableStatuses.has(catalogStatus));
|
|
166
|
-
|
|
182
|
+
// Older hosts omit this field; then it stays unknown instead of becoming [].
|
|
183
|
+
const unscopedPhilosophies = value.philosophies_without_applicability === undefined
|
|
184
|
+
? undefined : validateUnscopedPhilosophyRecords(value.philosophies_without_applicability);
|
|
185
|
+
const collections = [value.objectives, value.variables, value.models, value.philosophies, unscopedPhilosophies];
|
|
167
186
|
const hasRecords = collections.some((records) => Array.isArray(records) && records.length > 0);
|
|
168
187
|
const absenceConfirmed = value.absence_confirmed === true;
|
|
169
188
|
if (unreadableStatus || (!hasRecords && ('absence_confirmed' in value || readStatus || catalogStatus) && !absenceConfirmed)) {
|
|
@@ -180,6 +199,7 @@ export function normalizeFoundationCatalog(value, expectedScopeId) {
|
|
|
180
199
|
variables: value.variables,
|
|
181
200
|
models: value.models,
|
|
182
201
|
philosophies: value.philosophies,
|
|
202
|
+
...(unscopedPhilosophies ? { philosophies_without_applicability: unscopedPhilosophies } : {}),
|
|
183
203
|
...(readStatus ? { read_status: readStatus } : {}),
|
|
184
204
|
...(catalogStatus ? { status: catalogStatus } : {}),
|
|
185
205
|
...(typeof value.absence_confirmed === 'boolean' ? { absence_confirmed: value.absence_confirmed } : {}),
|
|
@@ -589,21 +609,53 @@ function philosophyCard(doc, record) {
|
|
|
589
609
|
return card;
|
|
590
610
|
}
|
|
591
611
|
|
|
612
|
+
function unscopedPhilosophyCard(doc, record, recordContexts) {
|
|
613
|
+
const payload = record.payload;
|
|
614
|
+
const card = makeElement(doc, 'article', { className: 'bb-fov-philosophy-card is-unscoped' });
|
|
615
|
+
card.append(makeElement(doc, 'h3', { text: formatAny(payload.title) ?? UNNAMED_RECORD }));
|
|
616
|
+
card.append(makeElement(doc, 'p', { className: 'bb-fov-philosophy-statement', text: payload.statement }));
|
|
617
|
+
if (formatAny(payload.summary) && payload.summary !== payload.statement) card.append(makeElement(doc, 'p', { text: payload.summary }));
|
|
618
|
+
// The section notice explains what an unrecorded applicability means.
|
|
619
|
+
card.append(workspaceDefinition(doc, [['適用範囲', '未記録']]));
|
|
620
|
+
const refs = makeElement(doc, 'details', { className: 'bb-fov-original' });
|
|
621
|
+
refs.append(makeElement(doc, 'summary', { text: '記録の詳細' }), workspaceDefinition(doc, [['出典', recordContexts?.get(record)?.label ?? '未記録'], ['状態', payload.status], ['Graphの版', record.version], ['記録ID', record.id]]));
|
|
622
|
+
card.append(refs);
|
|
623
|
+
return card;
|
|
624
|
+
}
|
|
625
|
+
|
|
592
626
|
function renderPhilosophies(doc, catalog, recordContexts) {
|
|
593
627
|
const section = makeElement(doc, 'section', { className: 'bb-fov-section', attrs: { 'aria-label': '哲学' } });
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
628
|
+
const unscoped = catalog.philosophies_without_applicability;
|
|
629
|
+
|
|
630
|
+
if (catalog.philosophies.length === 0 && !unscoped?.length) {
|
|
631
|
+
section.append(workspaceNotice(doc, {
|
|
632
|
+
label: '哲学',
|
|
633
|
+
text: unscoped
|
|
634
|
+
? 'この対象範囲には登録された哲学がありません。'
|
|
635
|
+
// Without the unscoped list, only the verified list is known to be empty.
|
|
636
|
+
: '適用範囲が記録された哲学はありません。適用範囲が未記録の哲学があるかは確認できていません。',
|
|
637
|
+
}));
|
|
597
638
|
return section;
|
|
598
639
|
}
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
const
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
640
|
+
if (catalog.philosophies.length) {
|
|
641
|
+
const grid = makeElement(doc, 'div', { className: 'bb-fov-philosophy-grid' });
|
|
642
|
+
for (const record of catalog.philosophies) {
|
|
643
|
+
const card = philosophyCard(doc, record);
|
|
644
|
+
const refs = makeElement(doc, 'details', { className: 'bb-fov-original' });
|
|
645
|
+
refs.append(makeElement(doc, 'summary', { text: '記録の詳細' }), workspaceDefinition(doc, [['出典', recordContexts?.get(record)?.label ?? '未記録'], ['状態', record.payload.status], ['版', record.revision], ['記録ID', record.id], ['digest', record.digest]]));
|
|
646
|
+
card.append(refs); grid.append(card);
|
|
647
|
+
}
|
|
648
|
+
section.append(grid);
|
|
649
|
+
}
|
|
650
|
+
if (unscoped?.length) {
|
|
651
|
+
section.append(workspaceNotice(doc, {
|
|
652
|
+
label: '適用範囲が未記録',
|
|
653
|
+
text: `次の${unscoped.length}件は、判断への適用範囲が記録されていない哲学です。判断では参照されますが、どの範囲に当てはまるかは確かめられていません。`,
|
|
654
|
+
}));
|
|
655
|
+
const grid = makeElement(doc, 'div', { className: 'bb-fov-philosophy-grid' });
|
|
656
|
+
for (const record of unscoped) grid.append(unscopedPhilosophyCard(doc, record, recordContexts));
|
|
657
|
+
section.append(grid);
|
|
605
658
|
}
|
|
606
|
-
section.append(grid);
|
|
607
659
|
return section;
|
|
608
660
|
}
|
|
609
661
|
|
|
@@ -829,10 +881,11 @@ export function renderFoundationCollectionOverview(root, sources, {
|
|
|
829
881
|
} = {}) {
|
|
830
882
|
const doc = getDocument(explicitDocument);
|
|
831
883
|
root.replaceChildren();
|
|
832
|
-
const catalog = { objectives: [], variables: [], models: [], philosophies: [] };
|
|
884
|
+
const catalog = { objectives: [], variables: [], models: [], philosophies: [], philosophies_without_applicability: [] };
|
|
833
885
|
const contexts = new WeakMap();
|
|
834
886
|
const seen = new Map();
|
|
835
887
|
let ready = 0;
|
|
888
|
+
let unscopedUnknown = false;
|
|
836
889
|
for (const source of sources) {
|
|
837
890
|
if (source.state.status !== 'ready') {
|
|
838
891
|
if (source.state.status === 'loading') {
|
|
@@ -846,8 +899,9 @@ export function renderFoundationCollectionOverview(root, sources, {
|
|
|
846
899
|
const value = normalizeFoundationCatalog(source.state.catalog, source.scopeId);
|
|
847
900
|
ready++;
|
|
848
901
|
const variableMap = new Map(value.variables.map((record) => [foundationKey(recordDefinition(record)), record]));
|
|
849
|
-
|
|
850
|
-
|
|
902
|
+
if (!value.philosophies_without_applicability) unscopedUnknown = true;
|
|
903
|
+
for (const kind of ['objectives', 'models', 'philosophies', 'philosophies_without_applicability']) {
|
|
904
|
+
for (const original of value[kind] ?? []) {
|
|
851
905
|
const definition = original.definition ?? original;
|
|
852
906
|
const scope = original.scope ?? original.currentScope ?? definition.scope;
|
|
853
907
|
// Scope-less/project records remain tied to their authenticated catalog.
|
|
@@ -868,6 +922,8 @@ export function renderFoundationCollectionOverview(root, sources, {
|
|
|
868
922
|
}
|
|
869
923
|
}
|
|
870
924
|
}
|
|
925
|
+
// A host that did not report unscoped philosophies leaves the absence unknown.
|
|
926
|
+
if (unscopedUnknown && catalog.philosophies_without_applicability.length === 0) delete catalog.philosophies_without_applicability;
|
|
871
927
|
if (ready) {
|
|
872
928
|
const mount = makeElement(doc, 'div');
|
|
873
929
|
renderFoundationOverview(mount, { status: 'ready', catalog }, {
|
package/ui/workspace-kit.css
CHANGED
|
@@ -573,3 +573,45 @@
|
|
|
573
573
|
.bb-ws-drawer[open], .bb-ws-drawer.is-closing, .bb-ws-drawer.is-closing::backdrop,
|
|
574
574
|
.bb-ws-filter-popover, .bb-ws-loading-spinner::before, .bb-ws-error-card.is-dismissing { animation: none; }
|
|
575
575
|
}
|
|
576
|
+
|
|
577
|
+
/* Searchable dropdown (workspaceCombobox) */
|
|
578
|
+
.bb-ws-combobox { position: relative; display: inline-grid; min-width: 0; }
|
|
579
|
+
.bb-ws-combobox-trigger {
|
|
580
|
+
display: flex; align-items: center; justify-content: space-between; gap: var(--bb-space-2);
|
|
581
|
+
width: 100%; min-width: 0; min-height: 38px; box-sizing: border-box; padding: 0 10px 0 11px;
|
|
582
|
+
border: 1px solid var(--bb-color-border-strong); border-radius: var(--bb-radius-sm);
|
|
583
|
+
background: var(--bb-color-panel); color: var(--bb-color-ink); font: inherit; font-size: 13px; text-align: left; cursor: pointer;
|
|
584
|
+
}
|
|
585
|
+
.bb-ws-combobox-trigger:hover { border-color: var(--bb-color-muted); }
|
|
586
|
+
.bb-ws-combobox-trigger:focus-visible { outline: 2px solid var(--bb-color-focus); outline-offset: 1px; }
|
|
587
|
+
.bb-ws-combobox-trigger[aria-expanded='true'] { border-color: var(--bb-color-accent); }
|
|
588
|
+
.bb-ws-combobox-trigger[disabled] { color: var(--bb-color-quiet); cursor: not-allowed; }
|
|
589
|
+
.bb-ws-combobox-trigger[data-empty='true'] .bb-ws-combobox-value { color: var(--bb-color-quiet); }
|
|
590
|
+
.bb-ws-combobox-value { overflow: hidden; min-width: 0; text-overflow: ellipsis; white-space: nowrap; }
|
|
591
|
+
.bb-ws-combobox-chevron::after { content: '⌄'; color: var(--bb-color-muted); }
|
|
592
|
+
.bb-ws-combobox-popup {
|
|
593
|
+
position: absolute; z-index: 45; top: calc(100% + 6px); left: 0; display: grid; gap: var(--bb-space-2);
|
|
594
|
+
box-sizing: border-box; width: max(100%, 260px); max-width: calc(100vw - 32px); padding: 8px;
|
|
595
|
+
border: 1px solid var(--bb-color-border); border-radius: var(--bb-radius-md); background: var(--bb-color-panel); color: var(--bb-color-ink);
|
|
596
|
+
box-shadow: var(--bb-shadow-popover); animation: bb-ws-filter-enter 140ms ease-out; transform-origin: top left;
|
|
597
|
+
}
|
|
598
|
+
.bb-ws-combobox-popup[hidden], .bb-ws-combobox-search[hidden] { display: none !important; }
|
|
599
|
+
.bb-ws-combobox-search {
|
|
600
|
+
box-sizing: border-box; width: 100%; min-height: 34px; padding: 0 10px;
|
|
601
|
+
border: 1px solid var(--bb-color-border-strong); border-radius: var(--bb-radius-sm); background: var(--bb-color-surface); color: var(--bb-color-ink); font: inherit; font-size: 13px;
|
|
602
|
+
}
|
|
603
|
+
.bb-ws-combobox-search:focus-visible { outline: 2px solid var(--bb-color-focus); outline-offset: 0; }
|
|
604
|
+
.bb-ws-combobox-list { max-height: 300px; margin: 0; padding: 0; overflow-y: auto; list-style: none; }
|
|
605
|
+
.bb-ws-combobox-list:focus-visible { outline: 2px solid var(--bb-color-focus); outline-offset: 0; }
|
|
606
|
+
.bb-ws-combobox-option {
|
|
607
|
+
display: grid; grid-template-columns: 16px minmax(0, 1fr) auto; align-items: center; gap: var(--bb-space-2);
|
|
608
|
+
padding: 7px 8px; border-radius: var(--bb-radius-sm); font-size: 13px; cursor: pointer;
|
|
609
|
+
}
|
|
610
|
+
.bb-ws-combobox-option::before { content: ''; color: var(--bb-color-accent-strong); font-weight: 700; }
|
|
611
|
+
.bb-ws-combobox-option[aria-selected='true']::before { content: '✓'; }
|
|
612
|
+
.bb-ws-combobox-option:hover, .bb-ws-combobox-option.is-active { background: var(--bb-color-accent-soft); }
|
|
613
|
+
.bb-ws-combobox-option.is-active { box-shadow: inset 2px 0 0 var(--bb-color-accent); }
|
|
614
|
+
.bb-ws-combobox-option-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
615
|
+
.bb-ws-combobox-option-hint { color: var(--bb-color-muted); font-size: 12px; white-space: nowrap; }
|
|
616
|
+
.bb-ws-combobox-status { margin: 0; padding: 2px 4px 0; color: var(--bb-color-muted); font-size: 12px; }
|
|
617
|
+
@media (prefers-reduced-motion:reduce) { .bb-ws-combobox-popup { animation: none; } }
|
package/ui/workspace-kit.js
CHANGED
|
@@ -422,3 +422,168 @@ export function workspaceDisclosure(doc, { title, help, content, open = false, o
|
|
|
422
422
|
if (onToggle) details.addEventListener('toggle', () => onToggle(details.open));
|
|
423
423
|
return details;
|
|
424
424
|
}
|
|
425
|
+
|
|
426
|
+
function comboboxText(value) {
|
|
427
|
+
return String(value ?? '').normalize('NFKC').toLocaleLowerCase('ja');
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Dropdown whose list can be searched. The trigger shows the chosen label;
|
|
432
|
+
* opening it moves focus to a search box (role=combobox) that narrows a
|
|
433
|
+
* listbox. Arrow keys move the active option, Enter chooses it, Escape closes
|
|
434
|
+
* and returns focus to the trigger, and typing on the trigger starts a search.
|
|
435
|
+
* Long lists render the first `limit` matches and say how many the search
|
|
436
|
+
* would still reveal. Options are `{ value, label, hint?, keywords? }`; the
|
|
437
|
+
* host owns the chosen value and receives it through `onChange`.
|
|
438
|
+
*/
|
|
439
|
+
export function workspaceCombobox(doc, {
|
|
440
|
+
label, options = [], value = '', placeholder = '選択してください', searchLabel, searchPlaceholder = '絞り込む',
|
|
441
|
+
emptyText = '一致する候補はありません', limit = 100, searchable = 'auto', className, onChange,
|
|
442
|
+
} = {}) {
|
|
443
|
+
const sequence = ++workspaceSequence;
|
|
444
|
+
const listId = `bb-ws-combobox-${sequence}-list`;
|
|
445
|
+
const root = el(doc, 'span', { className: ['bb-ws-combobox', className].filter(Boolean).join(' ') });
|
|
446
|
+
const trigger = el(doc, 'button', { className: 'bb-ws-combobox-trigger', attrs: { type: 'button', 'aria-haspopup': 'listbox', 'aria-expanded': 'false', 'aria-controls': listId } });
|
|
447
|
+
const triggerText = el(doc, 'span', { className: 'bb-ws-combobox-value' });
|
|
448
|
+
trigger.append(triggerText, el(doc, 'span', { className: 'bb-ws-combobox-chevron', attrs: { 'aria-hidden': 'true' } }));
|
|
449
|
+
const popup = el(doc, 'div', { className: 'bb-ws-combobox-popup' });
|
|
450
|
+
popup.hidden = true;
|
|
451
|
+
const search = el(doc, 'input', { className: 'bb-ws-combobox-search', attrs: {
|
|
452
|
+
type: 'text', role: 'combobox', 'aria-autocomplete': 'list', 'aria-expanded': 'true', 'aria-controls': listId,
|
|
453
|
+
'aria-label': searchLabel || `${label || '候補'}を絞り込む`, placeholder: searchPlaceholder, autocomplete: 'off', spellcheck: 'false',
|
|
454
|
+
} });
|
|
455
|
+
const list = el(doc, 'ul', { className: 'bb-ws-combobox-list', attrs: { id: listId, role: 'listbox', 'aria-label': label || '候補', tabindex: '-1' } });
|
|
456
|
+
const status = el(doc, 'p', { className: 'bb-ws-combobox-status', attrs: { role: 'status', 'aria-live': 'polite' } });
|
|
457
|
+
popup.append(search, list, status);
|
|
458
|
+
root.append(trigger, popup);
|
|
459
|
+
|
|
460
|
+
const state = { options: [], value: String(value ?? ''), query: '', active: -1, shown: [], open: false, disabled: false };
|
|
461
|
+
const usesSearch = () => searchable === true || (searchable === 'auto' && state.options.length > 8);
|
|
462
|
+
const focusTarget = () => (usesSearch() ? search : list);
|
|
463
|
+
const chosen = () => state.options.find(option => option.value === state.value);
|
|
464
|
+
|
|
465
|
+
function setOptions(next) {
|
|
466
|
+
state.options = (Array.isArray(next) ? next : []).map(option => ({
|
|
467
|
+
value: String(option?.value ?? ''), label: String(option?.label ?? option?.value ?? ''),
|
|
468
|
+
hint: option?.hint ? String(option.hint) : '', keywords: option?.keywords ? String(option.keywords) : '',
|
|
469
|
+
}));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function renderTrigger() {
|
|
473
|
+
const option = chosen();
|
|
474
|
+
// A value the list does not offer stays visible instead of looking unset.
|
|
475
|
+
const text = option ? option.label : state.value || placeholder;
|
|
476
|
+
triggerText.textContent = text;
|
|
477
|
+
trigger.setAttribute('aria-label', `${label ? `${label}: ` : ''}${text}`);
|
|
478
|
+
trigger.setAttribute('data-empty', String(!option && !state.value));
|
|
479
|
+
if (state.disabled) trigger.setAttribute('disabled', ''); else trigger.removeAttribute('disabled');
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function activeDescendant(node) {
|
|
483
|
+
for (const target of [search, list]) {
|
|
484
|
+
if (node) target.setAttribute('aria-activedescendant', node.getAttribute('id'));
|
|
485
|
+
else target.removeAttribute('aria-activedescendant');
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
function renderList() {
|
|
490
|
+
const terms = comboboxText(state.query).split(/\s+/u).filter(Boolean);
|
|
491
|
+
const matches = terms.length
|
|
492
|
+
? state.options.filter(option => { const text = comboboxText(`${option.label}\n${option.hint}\n${option.keywords}`); return terms.every(term => text.includes(term)); })
|
|
493
|
+
: state.options;
|
|
494
|
+
const max = Math.max(1, Number(limit) || 100);
|
|
495
|
+
state.shown = matches.slice(0, max);
|
|
496
|
+
if (state.active >= state.shown.length) state.active = state.shown.length - 1;
|
|
497
|
+
list.replaceChildren();
|
|
498
|
+
state.shown.forEach((option, index) => {
|
|
499
|
+
const selected = option.value === state.value;
|
|
500
|
+
const item = el(doc, 'li', { className: `bb-ws-combobox-option${index === state.active ? ' is-active' : ''}`, attrs: { id: `bb-ws-combobox-${sequence}-option-${index}`, role: 'option', 'aria-selected': String(selected), 'data-value': option.value } });
|
|
501
|
+
item.append(el(doc, 'span', { className: 'bb-ws-combobox-option-label', text: option.label }));
|
|
502
|
+
if (option.hint) item.append(el(doc, 'span', { className: 'bb-ws-combobox-option-hint', text: option.hint }));
|
|
503
|
+
item.addEventListener('pointerdown', event => event.preventDefault?.());
|
|
504
|
+
item.addEventListener('click', () => choose(option));
|
|
505
|
+
list.append(item);
|
|
506
|
+
});
|
|
507
|
+
const active = list.children?.[state.active];
|
|
508
|
+
activeDescendant(active);
|
|
509
|
+
active?.scrollIntoView?.({ block: 'nearest' });
|
|
510
|
+
const rest = matches.length - state.shown.length;
|
|
511
|
+
status.textContent = !matches.length ? emptyText : rest > 0 ? `${matches.length}件中${state.shown.length}件を表示。ほか${rest}件は絞り込むと出ます` : `${matches.length}件`;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function move(index) {
|
|
515
|
+
if (!state.shown.length) return;
|
|
516
|
+
state.active = Math.min(state.shown.length - 1, Math.max(0, index));
|
|
517
|
+
renderList();
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
function removeListeners() { doc.removeEventListener?.('pointerdown', outside); }
|
|
521
|
+
function outside(event) { if (!root.contains?.(event.target)) close(false); }
|
|
522
|
+
|
|
523
|
+
function open(query = '') {
|
|
524
|
+
if (state.disabled) return;
|
|
525
|
+
if (!state.open) { state.open = true; doc.addEventListener?.('pointerdown', outside); }
|
|
526
|
+
state.query = query; search.value = query; search.hidden = !usesSearch();
|
|
527
|
+
const selectedIndex = state.options.findIndex(option => option.value === state.value);
|
|
528
|
+
state.active = query ? 0 : Math.max(0, selectedIndex);
|
|
529
|
+
popup.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
|
530
|
+
renderList();
|
|
531
|
+
focusTarget().focus?.();
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
function close(returnFocus = true) {
|
|
535
|
+
if (!state.open) return;
|
|
536
|
+
state.open = false; removeListeners();
|
|
537
|
+
popup.hidden = true; trigger.setAttribute('aria-expanded', 'false'); activeDescendant(null);
|
|
538
|
+
if (returnFocus) trigger.focus?.();
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function choose(option) {
|
|
542
|
+
const changed = option.value !== state.value;
|
|
543
|
+
state.value = option.value; renderTrigger(); close(true);
|
|
544
|
+
if (changed) onChange?.(option.value, option);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
function keydown(event) {
|
|
548
|
+
const page = 10;
|
|
549
|
+
const keys = {
|
|
550
|
+
ArrowDown: () => move(state.active + 1), ArrowUp: () => move(state.active - 1),
|
|
551
|
+
PageDown: () => move(state.active + page), PageUp: () => move(state.active - page),
|
|
552
|
+
Enter: () => { const option = state.shown[state.active]; if (option) choose(option); },
|
|
553
|
+
Escape: () => close(true),
|
|
554
|
+
};
|
|
555
|
+
// Home and End move the caret while a search box is shown.
|
|
556
|
+
if (!usesSearch()) Object.assign(keys, { Home: () => move(0), End: () => move(state.shown.length - 1) });
|
|
557
|
+
if (event.key === 'Tab') { close(false); return; }
|
|
558
|
+
const action = keys[event.key];
|
|
559
|
+
if (!action) return;
|
|
560
|
+
event.preventDefault?.(); event.stopPropagation?.();
|
|
561
|
+
action();
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
trigger.addEventListener('click', () => (state.open ? close(true) : open()));
|
|
565
|
+
trigger.addEventListener('keydown', event => {
|
|
566
|
+
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { event.preventDefault?.(); open(); return; }
|
|
567
|
+
const printable = typeof event.key === 'string' && event.key.length === 1 && event.key !== ' ' && !event.ctrlKey && !event.metaKey && !event.altKey;
|
|
568
|
+
if (printable && usesSearch()) { event.preventDefault?.(); open(event.key); }
|
|
569
|
+
});
|
|
570
|
+
search.addEventListener('input', () => { state.query = search.value; state.active = 0; renderList(); });
|
|
571
|
+
search.addEventListener('keydown', keydown);
|
|
572
|
+
list.addEventListener('keydown', keydown);
|
|
573
|
+
|
|
574
|
+
setOptions(options); renderTrigger();
|
|
575
|
+
return {
|
|
576
|
+
root, trigger, popup, search, list,
|
|
577
|
+
get value() { return state.value; },
|
|
578
|
+
get isOpen() { return state.open; },
|
|
579
|
+
open, close,
|
|
580
|
+
update({ options: nextOptions, value: nextValue, disabled } = {}) {
|
|
581
|
+
if (nextOptions !== undefined) setOptions(nextOptions);
|
|
582
|
+
if (nextValue !== undefined) state.value = String(nextValue ?? '');
|
|
583
|
+
if (disabled !== undefined) { state.disabled = Boolean(disabled); if (state.disabled) close(false); }
|
|
584
|
+
renderTrigger();
|
|
585
|
+
if (state.open) { search.hidden = !usesSearch(); renderList(); }
|
|
586
|
+
},
|
|
587
|
+
destroy() { close(false); removeListeners(); },
|
|
588
|
+
};
|
|
589
|
+
}
|
|
@@ -23,7 +23,8 @@ export function workCanvasNotes(work) {
|
|
|
23
23
|
const summary = work.summary;
|
|
24
24
|
if (summary?.state === 'complete' || summary?.state === 'partial') {
|
|
25
25
|
const needs = summary.needs_check?.length;
|
|
26
|
-
|
|
26
|
+
// `opens: 'city'`: the host lets the quest open the city details, where these records are listed.
|
|
27
|
+
if (needs > 0) notes.push({ label: '記録の確認', tone: 'attention', opens: 'city', summary: `記録の要確認 ${needs}件`, text: `読めた未完了の仕事のうち${needs}件に、記録から把握できていないことがあります。仕事が止まっているという意味ではありません。詳細の絞り込みから根拠を確認できます。` });
|
|
27
28
|
}
|
|
28
29
|
const at = work.reads?.tasks?.read_at;
|
|
29
30
|
notes.push({ label: '区画の時点', tone: 'info', text: `仕事の読み込み:${shortTime(at) ?? '時点不明'}。表示は読み込んだ時点の記録です。` });
|
|
@@ -170,15 +170,60 @@
|
|
|
170
170
|
font-size: 11px;
|
|
171
171
|
font-weight: 700;
|
|
172
172
|
}
|
|
173
|
-
.bb-world-canvas-ui .bb-world-canvas-quest-text { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
174
|
-
/* A quest with a way down (
|
|
175
|
-
|
|
173
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
|
174
|
+
/* A quest with a way down (候補の確認, 区画の入口) is pressable as a whole row even though the quest list lets
|
|
175
|
+
the map take the pointer: its one link or button stretches over the row. */
|
|
176
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.has-way {
|
|
177
|
+
position: relative;
|
|
178
|
+
pointer-events: auto;
|
|
179
|
+
cursor: pointer;
|
|
180
|
+
transition: background-color 120ms ease, border-color 120ms ease;
|
|
181
|
+
}
|
|
182
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.has-way:hover,
|
|
183
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.has-way:focus-within {
|
|
184
|
+
border-color: rgb(155 232 145 / 55%);
|
|
185
|
+
background: linear-gradient(110deg, rgb(20 44 62 / 94%), rgb(20 44 62 / 84%));
|
|
186
|
+
}
|
|
187
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a,
|
|
188
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action {
|
|
176
189
|
flex: none;
|
|
190
|
+
margin: 0;
|
|
191
|
+
padding: 0;
|
|
192
|
+
border: 0;
|
|
193
|
+
background: none;
|
|
177
194
|
color: #b5f2ab;
|
|
195
|
+
font: inherit;
|
|
178
196
|
text-decoration: underline;
|
|
179
197
|
text-underline-offset: 2px;
|
|
198
|
+
cursor: pointer;
|
|
180
199
|
pointer-events: auto;
|
|
181
200
|
}
|
|
201
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a::after,
|
|
202
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action::after { content: " ›"; }
|
|
203
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a::before,
|
|
204
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action::before { content: ""; position: absolute; inset: 0; border-radius: 4px; }
|
|
205
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a:focus-visible,
|
|
206
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action:focus-visible { outline: none; }
|
|
207
|
+
.bb-world-canvas-ui .bb-world-canvas-quest a:focus-visible::before,
|
|
208
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-action:focus-visible::before { outline: 2px solid #9be891; outline-offset: 1px; }
|
|
209
|
+
/* A quest with nowhere to go: flat and dashed, so it reads as a sign, not a button, with its reason. */
|
|
210
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.is-static {
|
|
211
|
+
border-style: dashed;
|
|
212
|
+
border-left-style: solid;
|
|
213
|
+
background: rgb(13 25 46 / 58%);
|
|
214
|
+
box-shadow: none;
|
|
215
|
+
}
|
|
216
|
+
/* On a narrow canvas the reason gives way before the row's own words. */
|
|
217
|
+
.bb-world-canvas-ui .bb-world-canvas-quest.is-static .bb-world-canvas-quest-text { flex: none; }
|
|
218
|
+
.bb-world-canvas-ui .bb-world-canvas-quest-reason {
|
|
219
|
+
flex: 0 1 auto;
|
|
220
|
+
min-width: 0;
|
|
221
|
+
overflow: hidden;
|
|
222
|
+
color: rgb(255 255 255 / 68%);
|
|
223
|
+
font-size: 11px;
|
|
224
|
+
white-space: nowrap;
|
|
225
|
+
text-overflow: ellipsis;
|
|
226
|
+
}
|
|
182
227
|
|
|
183
228
|
.bb-world-canvas-ui .bb-world-canvas-edge {
|
|
184
229
|
position: absolute;
|
|
@@ -34,6 +34,8 @@ const nodeList = (nodes) => (Array.isArray(nodes) ? nodes : [nodes]).filter((nod
|
|
|
34
34
|
* focus unless its optional second argument is { focus: true }.
|
|
35
35
|
* addNote shows a quest only for an explicit nonempty summary; its full text
|
|
36
36
|
* remains in help. No summary is inferred from a long or nonurgent notice.
|
|
37
|
+
* A quest with a `link` or an `action` is pressable as a whole row; one without
|
|
38
|
+
* is drawn flat and may say its `reason` (why nothing opens from it).
|
|
37
39
|
*/
|
|
38
40
|
export function createWorldCanvasUI({
|
|
39
41
|
doc,
|
|
@@ -385,15 +387,17 @@ export function createWorldCanvasUI({
|
|
|
385
387
|
activate('help', help);
|
|
386
388
|
focus(helpTitle);
|
|
387
389
|
}
|
|
388
|
-
function addNote({ label = '', text = '', tone = '', summary, link = null } = {}) {
|
|
390
|
+
function addNote({ label = '', text = '', tone = '', summary, link = null, action = null, reason = null } = {}) {
|
|
389
391
|
if (disposed) return;
|
|
390
392
|
const note = element('section', 'bb-world-canvas-note');
|
|
391
393
|
if (label) note.append(element('h3', '', label));
|
|
392
394
|
if (text || summary) note.append(element('p', '', text || summary));
|
|
393
395
|
if (label || text || summary) notes.append(note);
|
|
394
396
|
if (typeof summary !== 'string' || !summary.trim()) return;
|
|
395
|
-
// A link
|
|
396
|
-
|
|
397
|
+
// A quest is a way down (a link already checked by the caller, or an action in this page) and then the
|
|
398
|
+
// whole row is pressable; without one it is drawn flat, so it never looks pressable (story-world-clickable-honesty-v1).
|
|
399
|
+
const way = link?.href ? 'link' : typeof action?.onClick === 'function' ? 'action' : null;
|
|
400
|
+
const quest = element('p', `bb-world-canvas-quest ${way ? 'has-way' : 'is-static'}`);
|
|
397
401
|
const icon = tone === 'attention' || tone === 'warning' ? '!' : 'i';
|
|
398
402
|
quest.append(
|
|
399
403
|
element('span', 'bb-world-canvas-quest-symbol', icon, { 'aria-hidden': 'true' }),
|
|
@@ -402,7 +406,16 @@ export function createWorldCanvasUI({
|
|
|
402
406
|
// The accessible name contains the unabridged summary even when visual text
|
|
403
407
|
// is ellipsized on a narrow canvas; the long explanation remains in help.
|
|
404
408
|
quest.setAttribute('aria-label', summary.trim());
|
|
405
|
-
if (
|
|
409
|
+
if (way === 'link') {
|
|
410
|
+
quest.append(element('a', link.className ?? '', link.label ?? summary.trim(), { href: link.href }));
|
|
411
|
+
} else if (way === 'action') {
|
|
412
|
+
const press = element('button', `bb-world-canvas-quest-action ${action.className ?? ''}`.trim(), action.label ?? summary.trim(), { type: 'button' });
|
|
413
|
+
listen(press, 'click', () => action.onClick());
|
|
414
|
+
quest.append(press);
|
|
415
|
+
} else if (typeof reason === 'string' && reason.trim()) {
|
|
416
|
+
// Why nothing opens from here (未接続 and the like), said in the row instead of only in help.
|
|
417
|
+
quest.append(element('span', 'bb-world-canvas-quest-reason', reason.trim()));
|
|
418
|
+
}
|
|
406
419
|
quests.append(quest);
|
|
407
420
|
}
|
|
408
421
|
function clearNotes() {
|
|
@@ -98,8 +98,11 @@ const STATION_YARD = Object.freeze({ xFrom: 6, xTo: 21.5, zFrom: 1.5, zTo: 12.5
|
|
|
98
98
|
* `exitLegend` (lines as DISTRICT_LEGEND) turns on the station yard for the tools of a business:
|
|
99
99
|
* stations come from `setExits`, and a picked station calls `onPickExit(id)`. Without it nothing of
|
|
100
100
|
* the yard is drawn or listed.
|
|
101
|
+
* `onPickStreet({ label, count })` makes each street's sign pressable (label null for the unlabelled work by
|
|
102
|
+
* the gate), and `onPickHall()` the hall, the plaza in front of it and the hall's name; without them they
|
|
103
|
+
* are scenery, as before (story-world-clickable-honesty-v1).
|
|
101
104
|
*/
|
|
102
|
-
export function createDistrictView({ doc, stage, reducedMotion = false, focusSelection = true, showChrome = true, onPick, onClear, onEscape, onProjectSelection, exitLegend = null, onPickExit }) {
|
|
105
|
+
export function createDistrictView({ doc, stage, reducedMotion = false, focusSelection = true, showChrome = true, onPick, onClear, onEscape, onProjectSelection, exitLegend = null, onPickExit, onPickStreet, onPickHall }) {
|
|
103
106
|
const stationsEnabled = Array.isArray(exitLegend);
|
|
104
107
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
105
108
|
renderer.setPixelRatio(Math.min(globalThis.devicePixelRatio ?? 1, 2));
|
|
@@ -349,6 +352,8 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
349
352
|
// The tools of the business (from the host), and their stations once the district is built.
|
|
350
353
|
let exitsData = [];
|
|
351
354
|
let stations = new Map();
|
|
355
|
+
/** The hall and its plaza, pressable when the host listens (`onPickHall`). */
|
|
356
|
+
let civic = [];
|
|
352
357
|
let stationLayer = null;
|
|
353
358
|
let stationLabels = [];
|
|
354
359
|
let gateAt = 0;
|
|
@@ -380,6 +385,17 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
380
385
|
return label;
|
|
381
386
|
}
|
|
382
387
|
|
|
388
|
+
/** A label that is a way in: pointer, a title saying where it goes, and a press. */
|
|
389
|
+
function enterable(label, title, onPress, enter) {
|
|
390
|
+
label.node.classList.add('is-enterable');
|
|
391
|
+
label.node.title = title;
|
|
392
|
+
label.node.setAttribute('data-enter', enter);
|
|
393
|
+
label.node.addEventListener('click', (event) => {
|
|
394
|
+
event.stopPropagation?.();
|
|
395
|
+
onPress();
|
|
396
|
+
});
|
|
397
|
+
}
|
|
398
|
+
|
|
383
399
|
// --- people (records: worker, person named in the text; atmosphere: walkers) ---------------------
|
|
384
400
|
function person(clothes, { helmet = false } = {}) {
|
|
385
401
|
const group = new THREE.Group();
|
|
@@ -613,6 +629,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
613
629
|
labels = [];
|
|
614
630
|
lots = new Map();
|
|
615
631
|
stations = new Map();
|
|
632
|
+
civic = [];
|
|
616
633
|
stationLayer = null;
|
|
617
634
|
stationLabels = [];
|
|
618
635
|
walkers = [];
|
|
@@ -705,7 +722,9 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
705
722
|
root.add(mesh(new THREE.CylinderGeometry(0.06, 0.08, 2.5, 8), material(0x3f4844, { roughness: 0.6, metalness: 0.4 }), { x: -DISTRICT_STREETS.main - 0.5, y: 1.25, z: signZ }));
|
|
706
723
|
root.add(mesh(new THREE.BoxGeometry(1.1, 0.34, 0.05), material(block.label ? 0x2f6e4f : 0xbfb8a8, { roughness: 0.6 }), { x: -DISTRICT_STREETS.main - 0.5, y: 2.2, z: signZ }));
|
|
707
724
|
const text = block.label ? `${block.label}(${block.count}件)` : `未分類の空き地:何のための仕事か未記録(${block.count}件)`;
|
|
708
|
-
addLabel(text, new THREE.Vector3(-DISTRICT_STREETS.main - 0.5, 2.9, signZ), `is-street${block.label ? '' : ' is-unlabelled'}`, { priority: 2 });
|
|
725
|
+
const sign = addLabel(text, new THREE.Vector3(-DISTRICT_STREETS.main - 0.5, 2.9, signZ), `is-street${block.label ? '' : ' is-unlabelled'}`, { priority: 2 });
|
|
726
|
+
// The count is a way down: pressing the sign lists the work of this street (AC-05).
|
|
727
|
+
if (onPickStreet) enterable(sign, `${block.label ?? '何のための仕事か未記録の空き地'}の仕事${block.count}件を見る`, () => onPickStreet({ label: block.label, count: block.count }), '仕事を見る');
|
|
709
728
|
});
|
|
710
729
|
// Trees along the main street and lamps between them; a belt of trees round the district (scenery).
|
|
711
730
|
const trees = [];
|
|
@@ -754,10 +773,17 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
754
773
|
root.add(box(0.4, 0.9, 0.4, 0x9c7a62, { x: 2.8, y: 4.3, z: hallZ - 1.2 }));
|
|
755
774
|
const hallChimney = new THREE.Vector3(2.8, 5.2, hallZ - 1.2);
|
|
756
775
|
const purposeText = work?.status === 'ok' ? (work.purpose.text ?? '目的は未登録') : '目的を読めません';
|
|
757
|
-
addLabel(`庁舎:${business.name}(${purposeText})`, new THREE.Vector3(0, 5.6, hallZ), 'is-plaza', { priority: 1 });
|
|
776
|
+
const hallLabel = addLabel(`庁舎:${business.name}(${purposeText})`, new THREE.Vector3(0, 5.6, hallZ), 'is-plaza', { priority: 1 });
|
|
758
777
|
const plaza = mesh(new THREE.CircleGeometry(plazaRadius - 0.25, 48), material(0xf1ece1, { roughness: 1 }), { y: 0.24, z: plazaZ, shadow: false });
|
|
759
778
|
plaza.rotation.x = -Math.PI / 2;
|
|
760
779
|
root.add(plaza);
|
|
780
|
+
if (onPickHall) {
|
|
781
|
+
// The hall, its plaza and its name open what this business is for and its policies (AC-06).
|
|
782
|
+
hall.userData = { kind: 'hall' };
|
|
783
|
+
plaza.userData = { kind: 'hall' };
|
|
784
|
+
civic = [hall, plaza];
|
|
785
|
+
enterable(hallLabel, `${business.name}の目的と方針を見る`, () => onPickHall(), '目的と方針を見る');
|
|
786
|
+
}
|
|
761
787
|
const ring = mesh(new THREE.RingGeometry(plazaRadius - 0.25, plazaRadius, 48), material(0xcfc8b8, { roughness: 1 }), { y: 0.25, z: plazaZ, shadow: false });
|
|
762
788
|
ring.rotation.x = -Math.PI / 2;
|
|
763
789
|
root.add(ring);
|
|
@@ -897,7 +923,7 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
897
923
|
pointer.x = ((clientX - rect.left) / rect.width) * 2 - 1;
|
|
898
924
|
pointer.y = -((clientY - rect.top) / rect.height) * 2 + 1;
|
|
899
925
|
raycaster.setFromCamera(pointer, camera);
|
|
900
|
-
const targets = [...lots.values(), ...stations.values()];
|
|
926
|
+
const targets = [...lots.values(), ...stations.values(), ...civic];
|
|
901
927
|
for (const hit of raycaster.intersectObjects(targets, true)) {
|
|
902
928
|
let object = hit.object;
|
|
903
929
|
while (object && !targets.includes(object)) object = object.parent;
|
|
@@ -915,7 +941,10 @@ export function createDistrictView({ doc, stage, reducedMotion = false, focusSel
|
|
|
915
941
|
renderer.domElement.addEventListener('pointerup', (event) => {
|
|
916
942
|
if (!downAt || Math.hypot(event.clientX - downAt.x, event.clientY - downAt.y) > 5) return;
|
|
917
943
|
const group = pickAt(event.clientX, event.clientY);
|
|
918
|
-
if (group?.userData.kind === '
|
|
944
|
+
if (group?.userData.kind === 'hall') {
|
|
945
|
+
select(null);
|
|
946
|
+
onPickHall?.();
|
|
947
|
+
} else if (group?.userData.kind === 'exit') {
|
|
919
948
|
// A station selects its tool in the city details; it never leaves the world.
|
|
920
949
|
select(group, { focus: false });
|
|
921
950
|
onPickExit?.(group.userData.exit.id);
|
package/ui/world/world-view.css
CHANGED
|
@@ -159,6 +159,12 @@
|
|
|
159
159
|
color: #ffffff;
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/* Other names that are ways in (the plaza, a street's sign, the hall) say where they go (story-world-clickable-honesty-v1). */
|
|
163
|
+
.bb-world-label.is-enterable[data-enter]:hover::after,
|
|
164
|
+
.bb-world-label.is-enterable[data-enter].is-hover::after {
|
|
165
|
+
content: ' ' attr(data-enter) ' ›';
|
|
166
|
+
}
|
|
167
|
+
|
|
162
168
|
/* With nothing chosen, the rail lists the ways in (AC-19). */
|
|
163
169
|
.bb-world-entrances {
|
|
164
170
|
list-style: none;
|
package/ui/world/world-view.js
CHANGED
|
@@ -672,7 +672,7 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
|
|
|
672
672
|
const highlightedMaterials = new Set();
|
|
673
673
|
|
|
674
674
|
// --- plaza ---------------------------------------------------------------
|
|
675
|
-
function buildPlaza(rows, rowItems, waitingOf, now) {
|
|
675
|
+
function buildPlaza(rows, rowItems, waitingOf, now, { state = null } = {}) {
|
|
676
676
|
const plaza = new THREE.Group();
|
|
677
677
|
plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS + 2.2, PLAZA_RADIUS + 2.6, 0.3, 64), standard(0x9fbf9d, { roughness: 1 }), { y: 0.15 }));
|
|
678
678
|
plaza.add(mesh(new THREE.CylinderGeometry(PLAZA_RADIUS, PLAZA_RADIUS + 0.4, 0.7, 64), standard(0xf3efe6, { roughness: 0.9 }), { y: 0.35 }));
|
|
@@ -694,7 +694,16 @@ function createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear,
|
|
|
694
694
|
}
|
|
695
695
|
scene.add(plaza);
|
|
696
696
|
register(plaza, { kind: 'plaza' });
|
|
697
|
-
|
|
697
|
+
// The name says when the judgments are not there to stand (未接続), so an empty plaza is not read as zero.
|
|
698
|
+
const plazaLabel = addLabel(state ? `広場(判断:${state})` : '広場(判断)', new THREE.Vector3(0, 0.8, PLAZA_RADIUS + 2.8), 'is-plaza', { priority: LABEL_PRIORITY.plaza, owner: plaza });
|
|
699
|
+
// The name is a way in too, as a city's name is: pressing it opens what the plaza is.
|
|
700
|
+
plazaLabel.node.classList.add('is-enterable');
|
|
701
|
+
plazaLabel.node.title = '広場が何かを見る';
|
|
702
|
+
plazaLabel.node.setAttribute('data-enter', '広場を見る');
|
|
703
|
+
plazaLabel.node.addEventListener('click', () => {
|
|
704
|
+
select(plaza);
|
|
705
|
+
onPick(plaza.userData, new THREE.Vector3(0, 0, 0));
|
|
706
|
+
});
|
|
698
707
|
for (let i = 0; i < 10; i += 1) {
|
|
699
708
|
const angle = (i / 10) * Math.PI * 2 + 0.3;
|
|
700
709
|
tree(Math.cos(angle) * (PLAZA_RADIUS + 1.3), Math.sin(angle) * (PLAZA_RADIUS + 1.3), 0.7);
|
|
@@ -1413,6 +1422,10 @@ function renderFallbackList(doc, root, businesses, rows, exitSlot = null) {
|
|
|
1413
1422
|
const JUDGMENT_UNAVAILABLE_TEXT = Object.freeze({
|
|
1414
1423
|
judgment_journal_not_connected: '判断の記録は各メンバーのMacにあり、組織版には送らないため未接続です',
|
|
1415
1424
|
});
|
|
1425
|
+
/** The same, short enough to stand in the row of the waiting list (story-world-clickable-honesty-v1 AC-03). */
|
|
1426
|
+
const JUDGMENT_UNAVAILABLE_SHORT = Object.freeze({
|
|
1427
|
+
judgment_journal_not_connected: '各メンバーのMacにあり、組織版には送っていません',
|
|
1428
|
+
});
|
|
1416
1429
|
|
|
1417
1430
|
/**
|
|
1418
1431
|
* Where the selected city and work site are kept: the page address (`world_business`, `world_site`),
|
|
@@ -1602,9 +1615,25 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1602
1615
|
};
|
|
1603
1616
|
function updateDistrictNotes(work) {
|
|
1604
1617
|
for (const key of canvasNotes.keys()) if (key.startsWith('district:')) canvasNotes.delete(key);
|
|
1605
|
-
if (work)
|
|
1618
|
+
if (work) {
|
|
1619
|
+
for (const [index, { opens, ...note }] of workCanvasNotes(work).entries()) {
|
|
1620
|
+
canvasNotes.set(`district:${index}`, opens === 'city' ? { ...note, action: { label: '詳細を開く', onClick: openCurrentCityRail } } : note);
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1606
1623
|
renderCanvasNotes();
|
|
1607
1624
|
}
|
|
1625
|
+
/** The details of the city the viewer is in, in the opened rail (a way down from its quests and its hall). */
|
|
1626
|
+
function openCurrentCityRail() {
|
|
1627
|
+
const business = current.code ? businessOf(current.code) : null;
|
|
1628
|
+
if (!business) return;
|
|
1629
|
+
// The whole city, not a street or a gap chosen before.
|
|
1630
|
+
if (current.focus) {
|
|
1631
|
+
current.focus = null;
|
|
1632
|
+
district?.focus(null);
|
|
1633
|
+
}
|
|
1634
|
+
renderCityRail(business, { revealFocus: true });
|
|
1635
|
+
canvasUI?.openRail();
|
|
1636
|
+
}
|
|
1608
1637
|
root.append(wrap);
|
|
1609
1638
|
let scene = null;
|
|
1610
1639
|
|
|
@@ -1742,6 +1771,13 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1742
1771
|
// The businesses are not known yet here (the list is drawn again once the world is built).
|
|
1743
1772
|
showRail([workspaceDetailEmpty(doc, { mark: '◇', title: '都市や建物を選ぶと、ここに出ます', text: 'ドラッグで移動、ホイールで寄る・離れる、右ドラッグで回転します。Escで全体に戻ります。' })]);
|
|
1744
1773
|
|
|
1774
|
+
/** The entrances of the districts, the ones with the most work to check first, in the opened rail. */
|
|
1775
|
+
function showEntrances() {
|
|
1776
|
+
if (current.level !== 'world') goToWorld();
|
|
1777
|
+
clearAll();
|
|
1778
|
+
canvasUI?.openRail();
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1745
1781
|
function enterFromRail(business) {
|
|
1746
1782
|
const picked = scene?.selectKey({ code: business.code });
|
|
1747
1783
|
openCity(business, picked?.center ?? null);
|
|
@@ -1753,6 +1789,8 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1753
1789
|
let withheld = 0;
|
|
1754
1790
|
/** Whether the host gave judgments at all (an organization web does not: they stay on each Mac). */
|
|
1755
1791
|
let judgmentsConnected = false;
|
|
1792
|
+
/** Why the judgments could not be read, for the plaza: `{ title, text, short }`, or null when read. */
|
|
1793
|
+
let judgmentsUnavailable = null;
|
|
1756
1794
|
|
|
1757
1795
|
function judgmentText(item, fallback) {
|
|
1758
1796
|
const textValue = item?.proof?.interruption?.question_display_text ?? item?.proof?.decision?.summary ?? fallback;
|
|
@@ -1760,6 +1798,11 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1760
1798
|
return `${textValue}${suffix}`;
|
|
1761
1799
|
}
|
|
1762
1800
|
|
|
1801
|
+
/** 判断の履歴 itself, by the host's own route (`#value-proofs` here, `?screen=value-proofs` in an organization web). */
|
|
1802
|
+
function historyLink() {
|
|
1803
|
+
return el(doc, 'a', { className: 'bb-world-rail-link', text: '「判断の履歴」で開く', attrs: { href: judgmentHref(null) } });
|
|
1804
|
+
}
|
|
1805
|
+
|
|
1763
1806
|
/** Descends to the same judgment in the host's 判断の履歴. */
|
|
1764
1807
|
function decisionLink(decisionAttemptId) {
|
|
1765
1808
|
const link = el(doc, 'a', { className: 'bb-world-rail-link', text: '「判断の履歴」でこの判断を開く', attrs: { href: judgmentHref(decisionAttemptId) } });
|
|
@@ -1930,7 +1973,17 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1930
1973
|
/** Brings a rail block the viewer just asked for into view (the rail may be scrolled down, or below the city). */
|
|
1931
1974
|
function reveal(node) {
|
|
1932
1975
|
try {
|
|
1933
|
-
|
|
1976
|
+
// Only the rail's own scroller moves: scrollIntoView also scrolled the stage (overflow: hidden) and
|
|
1977
|
+
// shifted the whole map up. Without such a scroller (a host rail in the page) the page scrolls.
|
|
1978
|
+
const style = (box) => globalThis.getComputedStyle?.(box)?.overflowY ?? '';
|
|
1979
|
+
let box = node?.parentElement ?? null;
|
|
1980
|
+
while (box && !/^(auto|scroll)$/u.test(style(box))) box = box.parentElement;
|
|
1981
|
+
if (!box) {
|
|
1982
|
+
node?.scrollIntoView?.({ block: 'start', behavior: reducedMotion ? 'auto' : 'smooth' });
|
|
1983
|
+
return;
|
|
1984
|
+
}
|
|
1985
|
+
const top = node.getBoundingClientRect().top - box.getBoundingClientRect().top + box.scrollTop;
|
|
1986
|
+
box.scrollTo({ top, behavior: reducedMotion ? 'auto' : 'smooth' });
|
|
1934
1987
|
} catch {
|
|
1935
1988
|
// Without scrolling the block is still in the rail.
|
|
1936
1989
|
}
|
|
@@ -1941,13 +1994,13 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1941
1994
|
let focusBlock = null;
|
|
1942
1995
|
const work = workCache.get(business.code);
|
|
1943
1996
|
if (current.focus && work?.status === 'ok') {
|
|
1944
|
-
const ids = new Set(current.focus
|
|
1945
|
-
? work.summary.gaps?.[current.focus.kind] ?? []
|
|
1946
|
-
: work.sites.filter((site) => site.work.open && site.work.status === current.focus.status).map((site) => site.task_id));
|
|
1997
|
+
const ids = new Set(focusIdsFor(work, current.focus));
|
|
1947
1998
|
const sites = work.sites.filter((site) => ids.has(site.task_id));
|
|
1948
|
-
const label = current.focus
|
|
1949
|
-
?
|
|
1950
|
-
:
|
|
1999
|
+
const label = 'purpose' in current.focus
|
|
2000
|
+
? current.focus.purpose ? `通り「${current.focus.purpose}」の仕事` : '何のための仕事か未記録の仕事'
|
|
2001
|
+
: current.focus.kind
|
|
2002
|
+
? sites[0]?.gaps.find((gap) => gap.kind === current.focus.kind)?.label ?? current.focus.kind
|
|
2003
|
+
: `記録上の状態「${WORK_STATES.find((state) => state.key === current.focus.status)?.label ?? current.focus.status}」`;
|
|
1951
2004
|
blocks.push(focusBlock = workPickBlock(doc, {
|
|
1952
2005
|
title: `${label} ${sites.length}件`,
|
|
1953
2006
|
sites,
|
|
@@ -1982,11 +2035,18 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
1982
2035
|
/** The open tasks a focus picks: a kind of gap, or a recorded state; null for all. */
|
|
1983
2036
|
function focusIdsFor(work, focus) {
|
|
1984
2037
|
if (!focus || work?.status !== 'ok') return null;
|
|
2038
|
+
// A street's sign (story-world-clickable-honesty-v1 AC-05): the work the sign counts, done or not.
|
|
2039
|
+
if ('purpose' in focus) return work.sites.filter((site) => site.work.status !== 'cancelled' && purposeOf(site) === focus.purpose).map((site) => site.task_id);
|
|
1985
2040
|
return focus.kind
|
|
1986
2041
|
? work.summary.gaps?.[focus.kind] ?? []
|
|
1987
2042
|
: work.sites.filter((site) => site.work.open && site.work.status === focus.status).map((site) => site.task_id);
|
|
1988
2043
|
}
|
|
1989
2044
|
|
|
2045
|
+
/** What a work is for, as the district groups it into streets (null for the unlabelled by the gate). */
|
|
2046
|
+
function purposeOf(site) {
|
|
2047
|
+
return typeof site.purpose_label === 'string' && site.purpose_label.trim() ? site.purpose_label.trim() : null;
|
|
2048
|
+
}
|
|
2049
|
+
|
|
1990
2050
|
function showFocus(business, focus) {
|
|
1991
2051
|
current.focus = focus;
|
|
1992
2052
|
district?.focus(focusIdsFor(workCache.get(business.code), focus));
|
|
@@ -2088,6 +2148,18 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2088
2148
|
if (anchor.taskId === current.siteId && current.detachedSiteId !== current.siteId) canvasUI?.setTaskAnchor(anchor);
|
|
2089
2149
|
else if (!anchor.visible && !current.siteId) canvasUI?.setTaskAnchor({ visible: false });
|
|
2090
2150
|
},
|
|
2151
|
+
// A street's sign lists its work; the hall and its plaza open what the business is for.
|
|
2152
|
+
onPickStreet: ({ label }) => {
|
|
2153
|
+
const owner = businessOf(current.code);
|
|
2154
|
+
if (!owner) return;
|
|
2155
|
+
if (current.level === 'site') goUp();
|
|
2156
|
+
showFocus(owner, { purpose: label });
|
|
2157
|
+
canvasUI?.openRail();
|
|
2158
|
+
},
|
|
2159
|
+
onPickHall: () => {
|
|
2160
|
+
if (current.level === 'site') goUp();
|
|
2161
|
+
openCurrentCityRail();
|
|
2162
|
+
},
|
|
2091
2163
|
...(exitsEnabled ? {
|
|
2092
2164
|
exitLegend: exitLegend(worldVocabulary),
|
|
2093
2165
|
onPickExit: (exitId) => {
|
|
@@ -2325,7 +2397,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2325
2397
|
if (!openKnown) note('仕事の件数', '読めた仕事の要約に未完了件数の記録がありません。0件とは確認できません。', 'warning', '未完了の件数:未確認');
|
|
2326
2398
|
if (unreadable) note('仕事の読込', `仕事を読めなかった事業${unreadable}件。0件とは確認できません。`, 'warning', `仕事が読めない事業 ${unreadable}件`);
|
|
2327
2399
|
if (partial) note('仕事の範囲', `一部だけ読めた事業${partial}件。件数は読めた範囲です。`, 'warning', `仕事が一部だけ読めた事業 ${partial}件`);
|
|
2328
|
-
if (needs) note('仕事の記録', `読めた未完了の仕事${needs}件(${cities}事業)に、記録から把握できていないことがあります。仕事の失敗や停止を意味しません。`, 'attention', `記録の要確認 ${needs}件・${cities}
|
|
2400
|
+
if (needs) note('仕事の記録', `読めた未完了の仕事${needs}件(${cities}事業)に、記録から把握できていないことがあります。仕事の失敗や停止を意味しません。`, 'attention', `記録の要確認 ${needs}件・${cities}事業`, null, { action: { label: '区画を選ぶ', onClick: showEntrances } });
|
|
2329
2401
|
}
|
|
2330
2402
|
}
|
|
2331
2403
|
|
|
@@ -2359,6 +2431,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2359
2431
|
workspaceRailBlock(doc, { title: '', content: workspaceButton(doc, { text: `${business.name}の区画に入る`, variant: 'primary', onClick: () => openCity(business, position) }) }),
|
|
2360
2432
|
cityJudgmentBlock(business),
|
|
2361
2433
|
]);
|
|
2434
|
+
canvasUI?.openRail();
|
|
2362
2435
|
return;
|
|
2363
2436
|
}
|
|
2364
2437
|
if (data.kind === 'cityJudgments') {
|
|
@@ -2368,8 +2441,9 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2368
2441
|
showRail([
|
|
2369
2442
|
workspaceRailHead(doc, { kicker: `${business.name} の判断所`, title: `判断 ${data.judgments.length}件`, lead: business.purpose ?? undefined }),
|
|
2370
2443
|
cityJudgmentBlock(business),
|
|
2371
|
-
workspaceRailBlock(doc, { title: '詳しく見る', content:
|
|
2444
|
+
workspaceRailBlock(doc, { title: '詳しく見る', content: historyLink() }),
|
|
2372
2445
|
]);
|
|
2446
|
+
canvasUI?.openRail();
|
|
2373
2447
|
return;
|
|
2374
2448
|
}
|
|
2375
2449
|
if (data.kind === 'judgment') {
|
|
@@ -2399,9 +2473,10 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2399
2473
|
blocks.push(workspaceRailBlock(doc, { title: '判断(新しい順)', content: items.length ? ul : { text: '判断はありません' } }));
|
|
2400
2474
|
blocks.push(workspaceRailBlock(doc, {
|
|
2401
2475
|
title: '詳しく見る',
|
|
2402
|
-
content:
|
|
2476
|
+
content: historyLink(),
|
|
2403
2477
|
}));
|
|
2404
2478
|
showRail(blocks);
|
|
2479
|
+
canvasUI?.openRail();
|
|
2405
2480
|
return;
|
|
2406
2481
|
}
|
|
2407
2482
|
if (data.kind === 'plaza') {
|
|
@@ -2410,8 +2485,18 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2410
2485
|
const blocks = [workspaceRailHead(doc, {
|
|
2411
2486
|
kicker: '広場',
|
|
2412
2487
|
title: '判断の種類(全体)',
|
|
2413
|
-
lead: '
|
|
2488
|
+
lead: '広場には、判断の種類1つにつき建物が1つ建ちます。全事業の判断をまとめたもので、高さは件数、色は任せ方の状態です。事業ごとの判断は、各都市の判断所にあります。',
|
|
2414
2489
|
})];
|
|
2490
|
+
if (judgmentsUnavailable) {
|
|
2491
|
+
// Not connected or unreadable: an empty plaza here is not zero judgments (P9).
|
|
2492
|
+
blocks.push(workspaceRailBlock(doc, {
|
|
2493
|
+
title: judgmentsUnavailable.title,
|
|
2494
|
+
content: { text: `${judgmentsUnavailable.text}広場に建物が無いのは、判断が0件だからではありません。` },
|
|
2495
|
+
}));
|
|
2496
|
+
showRail(blocks);
|
|
2497
|
+
canvasUI?.openRail();
|
|
2498
|
+
return;
|
|
2499
|
+
}
|
|
2415
2500
|
blocks.push(workspaceRailBlock(doc, {
|
|
2416
2501
|
title: `どの事業にも置けない判断(${placement.unplaced.length}件)`,
|
|
2417
2502
|
content: placement.unplaced.length
|
|
@@ -2424,7 +2509,9 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2424
2509
|
content: { text: 'この組織の事業で作業したと確かめられない判断です。ほかの会社の仕事が混ざるため、内容も作業場所も出しません。「判断の履歴」では見られます。' },
|
|
2425
2510
|
}));
|
|
2426
2511
|
}
|
|
2512
|
+
blocks.push(workspaceRailBlock(doc, { title: '詳しく見る', content: historyLink() }));
|
|
2427
2513
|
showRail(blocks);
|
|
2514
|
+
canvasUI?.openRail();
|
|
2428
2515
|
}
|
|
2429
2516
|
}
|
|
2430
2517
|
|
|
@@ -2453,9 +2540,9 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2453
2540
|
}
|
|
2454
2541
|
if (disposed) return;
|
|
2455
2542
|
notices.replaceChildren();
|
|
2456
|
-
const note = (label, textValue, tone = 'info', summary = null, link = null) => {
|
|
2543
|
+
const note = (label, textValue, tone = 'info', summary = null, link = null, { action = null, reason = null } = {}) => {
|
|
2457
2544
|
if (canvasUI) {
|
|
2458
|
-
setCanvasNote(`${label}:${summary ?? 'detail'}`, { label, text: textValue, tone, summary, link });
|
|
2545
|
+
setCanvasNote(`${label}:${summary ?? 'detail'}`, { label, text: textValue, tone, summary, link, action, reason });
|
|
2459
2546
|
return;
|
|
2460
2547
|
}
|
|
2461
2548
|
const line = el(doc, 'p', { className: `bb-world-hud-line is-${tone}`, attrs: tone === 'info' ? {} : { role: 'alert' } });
|
|
@@ -2489,8 +2576,11 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2489
2576
|
const rows = readable ? home.delegation_map.rows : [];
|
|
2490
2577
|
if (!homeResult.ok || !readable) {
|
|
2491
2578
|
const reason = homeResult.ok ? home?.reason ?? home?.status ?? '状態不明' : homeResult.error;
|
|
2492
|
-
|
|
2493
|
-
|
|
2579
|
+
judgmentsUnavailable = JUDGMENT_UNAVAILABLE_TEXT[reason]
|
|
2580
|
+
? { title: '判断の記録:未接続', text: `${JUDGMENT_UNAVAILABLE_TEXT[reason]}。`, short: JUDGMENT_UNAVAILABLE_SHORT[reason] }
|
|
2581
|
+
: { title: '判断の記録:読めない', text: `判断の記録を読めません(${reason})。判断の記録の件数は未確認です。0件とは確認できません。`, short: `理由:${reason}` };
|
|
2582
|
+
if (JUDGMENT_UNAVAILABLE_TEXT[reason]) note('判断', `${JUDGMENT_UNAVAILABLE_TEXT[reason]}。事業だけを描いています。`, 'warning', '判断の記録:未接続', null, { reason: judgmentsUnavailable.short });
|
|
2583
|
+
else note('判断', judgmentsUnavailable.text, 'warning', '判断の記録:読めない', null, { reason: judgmentsUnavailable.short });
|
|
2494
2584
|
} else {
|
|
2495
2585
|
proofs = proofIndex(home);
|
|
2496
2586
|
judgmentsConnected = true;
|
|
@@ -2539,7 +2629,7 @@ export function createWorldView({ root, rail, page, presentation = 'standard', d
|
|
|
2539
2629
|
}
|
|
2540
2630
|
scene = createScene({ doc, stage, labelsLayer, reducedMotion, onPick, onClear: clearAll, onEscape: goUp });
|
|
2541
2631
|
scene.setSky(SKY_MODES[skyIndex][0]);
|
|
2542
|
-
scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now());
|
|
2632
|
+
scene.buildPlaza(rows, rowItems, (row) => (rowItems.get(row.key) ?? []).filter((ref) => isWaiting(proofs.get(ref.decision_attempt_id))).length, Date.now(), { state: judgmentsUnavailable ? judgmentsUnavailable.title.replace('判断の記録:', '') : null });
|
|
2543
2633
|
scene.buildCities(layoutWorld(businesses), placement.byBusiness);
|
|
2544
2634
|
showChangesSinceLastVisit(note, businesses, rows);
|
|
2545
2635
|
knownBusinesses = businesses;
|