@rse/specbook 1.2.2 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -1
- package/dst/specbook-api.js +7 -3
- package/dst/specbook-cmd-describe.js +2 -2
- package/dst/specbook-cmd-lint.js +10 -2
- package/dst/specbook-cmd-preview.js +7 -10
- package/dst/specbook-config.d.ts +4 -0
- package/dst/specbook-config.js +32 -9
- package/dst/specbook-diagram.js +13 -7
- package/dst/specbook-export-common.js +33 -22
- package/dst/specbook-export-html.css +62 -0
- package/dst/specbook-export-html.js +108 -45
- package/dst/specbook-export-md.js +9 -7
- package/dst/specbook-export-pdf.js +4 -2
- package/dst/specbook-format-schema.js +2 -0
- package/dst/specbook-format-spec.js +2 -0
- package/dst/specbook-format.yaml +200 -216
- package/dst/specbook-link.js +4 -4
- package/dst/specbook-parse-common.d.ts +1 -0
- package/dst/specbook-parse-common.js +5 -0
- package/dst/specbook-parse-semantic.js +9 -9
- package/dst/specbook-parse-syntax.js +16 -21
- package/dst/specbook-parse.d.ts +5 -0
- package/dst/specbook-parse.js +20 -1
- package/package.json +3 -3
package/README.md
CHANGED
|
@@ -70,7 +70,8 @@ CLI with commands `specbook <xxx>`, and an MCP service with tools
|
|
|
70
70
|
- **HTML Export for Developers**:
|
|
71
71
|
The HTML export is a self-contained single document with a
|
|
72
72
|
table-of-contents (also as a slide-in side panel for quick navigation),
|
|
73
|
-
full-text search, embedded images,
|
|
73
|
+
full-text search, embedded images, a scroll progress meter (which
|
|
74
|
+
also scrolls back to the top on click), and a light/dark
|
|
74
75
|
theme toggle. It is intended for the day-to-day online reading during
|
|
75
76
|
development. The HTML export can map all object kinds to nested
|
|
76
77
|
sections or compact tables, or an automatic mixture which collapses
|
package/dst/specbook-api.js
CHANGED
|
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
|
|
|
7
7
|
import * as path from "node:path";
|
|
8
8
|
import { fileURLToPath } from "node:url";
|
|
9
9
|
import { glob } from "glob";
|
|
10
|
-
import { loadConfig } from "./specbook-config.js";
|
|
10
|
+
import { loadConfig, configStatistics } from "./specbook-config.js";
|
|
11
11
|
import { renderDiagnostic, renderDiagnosticVerbose } from "./specbook-diagnostic.js";
|
|
12
12
|
import { initSpecification } from "./specbook-cmd-init.js";
|
|
13
13
|
import { lint } from "./specbook-cmd-lint.js";
|
|
@@ -69,6 +69,9 @@ export class SpecBook {
|
|
|
69
69
|
if (config === undefined)
|
|
70
70
|
throw new Error("invalid configuration:\n" +
|
|
71
71
|
diagnostics.map(renderDiagnostic).join("\n"));
|
|
72
|
+
const stats = configStatistics(config);
|
|
73
|
+
verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
|
|
74
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
72
75
|
return config;
|
|
73
76
|
}
|
|
74
77
|
/* initialize the configured specification artifact files
|
|
@@ -112,8 +115,9 @@ export class SpecBook {
|
|
|
112
115
|
export fail before the specification is even parsed */
|
|
113
116
|
if (requested.includes("pdf"))
|
|
114
117
|
await requireBrowser(verbose);
|
|
115
|
-
|
|
116
|
-
basedir: options.basedir ?? ".", verbose })
|
|
118
|
+
const result = lint({ config: await this.configFiles(options.config),
|
|
119
|
+
basedir: options.basedir ?? ".", verbose });
|
|
120
|
+
return this.renderFormats(result, requested, verbose, options.realtime === true);
|
|
117
121
|
}
|
|
118
122
|
/* keep an export in sync with its sources (the shared core of
|
|
119
123
|
"watch" and "preview"): every change of a configuration file, of an
|
|
@@ -78,13 +78,13 @@ const schemaSection = (config, schema, embed, standard, compress) => {
|
|
|
78
78
|
yaml = compressYaml(yaml, compress);
|
|
79
79
|
return textframe(`
|
|
80
80
|
The **SpecBook SCHEMA Model** is the following YAML schema configuration:
|
|
81
|
-
`) + "\n```yaml\n" + yaml.replace(/\n*$/, "\n") + "```\n
|
|
81
|
+
`) + "\n```yaml\n" + yaml.replace(/\n*$/, "\n") + "```\n";
|
|
82
82
|
};
|
|
83
83
|
/* render the reference to the specification Markdown files */
|
|
84
84
|
const specSection = (basedir) => textframe(`
|
|
85
85
|
The **SpecBook SPEC Model** is the set of Markdown specification files in directory:
|
|
86
86
|
@${basedir}
|
|
87
|
-
`)
|
|
87
|
+
`);
|
|
88
88
|
/* describe the generic SpecBook models and formats as Markdown,
|
|
89
89
|
optionally extended by the references to (or the embeddings of) the
|
|
90
90
|
artifacts of the particular project instantiation, and optionally
|
package/dst/specbook-cmd-lint.js
CHANGED
|
@@ -5,9 +5,9 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import * as fs from "node:fs";
|
|
7
7
|
import * as path from "node:path";
|
|
8
|
-
import { loadConfig } from "./specbook-config.js";
|
|
8
|
+
import { loadConfig, configStatistics } from "./specbook-config.js";
|
|
9
9
|
import { literal } from "./specbook-verbose.js";
|
|
10
|
-
import { parseSpecification, resolveArtifact } from "./specbook-parse.js";
|
|
10
|
+
import { parseSpecification, specStatistics, resolveArtifact } from "./specbook-parse.js";
|
|
11
11
|
/* collect the distinct artifact files the schema configuration
|
|
12
12
|
references, in their declaration order, each mapped onto whether
|
|
13
13
|
all of its artifacts are optional and hence the file may be absent */
|
|
@@ -30,6 +30,11 @@ export const lint = (options) => {
|
|
|
30
30
|
const loaded = loadConfig(options.config);
|
|
31
31
|
diagnostics.push(...loaded.diagnostics);
|
|
32
32
|
const config = loaded.config;
|
|
33
|
+
if (config !== undefined) {
|
|
34
|
+
const stats = configStatistics(config);
|
|
35
|
+
options.verbose(`loaded schema configuration specifying ${literal(stats.objects)} object kind(s) ` +
|
|
36
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
37
|
+
}
|
|
33
38
|
/* read the artifact files the configuration references, resolved
|
|
34
39
|
against the base directory and tolerating an absent file if all
|
|
35
40
|
its artifacts are optional */
|
|
@@ -64,6 +69,9 @@ export const lint = (options) => {
|
|
|
64
69
|
const result = parseSpecification(sources, config);
|
|
65
70
|
diagnostics.push(...result.diagnostics);
|
|
66
71
|
watched.push(...result.assets);
|
|
72
|
+
const stats = specStatistics(result.specification);
|
|
73
|
+
options.verbose(`parsed specification defining ${literal(stats.objects)} object(s) ` +
|
|
74
|
+
`and ${literal(stats.links)} link relationship(s)`);
|
|
67
75
|
/* report the non-optional artifacts absent from the specification,
|
|
68
76
|
against their loaded artifact file (an absent or unreadable file
|
|
69
77
|
is already reported above) or else against the configuration */
|
|
@@ -10,11 +10,6 @@ import { literal } from "./specbook-verbose.js";
|
|
|
10
10
|
/* the default listening address and port of the preview server */
|
|
11
11
|
export const previewAddr = "127.0.0.1";
|
|
12
12
|
export const previewPort = 12345;
|
|
13
|
-
/* the placeholder page answering a GET before the first successful
|
|
14
|
-
export: it still carries the live preview client, so the browser
|
|
15
|
-
subscribes to the "RELOAD" command right away and replaces itself
|
|
16
|
-
with the document once the first export arrives */
|
|
17
|
-
const placeholder = renderPlaceholder("no specification export available yet");
|
|
18
13
|
/* serve the live HTML preview: a plain GET on "/" answers with the
|
|
19
14
|
most recent HTML export (or with the 503 placeholder page before the
|
|
20
15
|
first successful one), while a WebSocket upgrade on "/" subscribes
|
|
@@ -24,16 +19,18 @@ export const servePreview = async (options) => {
|
|
|
24
19
|
const clients = new Set();
|
|
25
20
|
const fastify = Fastify();
|
|
26
21
|
await fastify.register(fastifyWebsocket);
|
|
22
|
+
/* the placeholder page answering a GET before the first successful
|
|
23
|
+
export: it still carries the live preview client, so the browser
|
|
24
|
+
subscribes to the "RELOAD" command right away and replaces itself
|
|
25
|
+
with the document once the first export arrives */
|
|
26
|
+
const placeholder = renderPlaceholder("no specification export available yet");
|
|
27
27
|
/* answer a plain GET with the document and subscribe a WebSocket upgrade */
|
|
28
28
|
fastify.route({
|
|
29
29
|
method: "GET",
|
|
30
30
|
url: "/",
|
|
31
31
|
handler: (_request, reply) => {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
.header("cache-control", "no-store").send(placeholder);
|
|
35
|
-
return reply.type("text/html; charset=utf-8")
|
|
36
|
-
.header("cache-control", "no-store").send(html);
|
|
32
|
+
reply.type("text/html; charset=utf-8").header("cache-control", "no-store");
|
|
33
|
+
return html === undefined ? reply.code(503).send(placeholder) : reply.send(html);
|
|
37
34
|
},
|
|
38
35
|
wsHandler: (socket, request) => {
|
|
39
36
|
/* identify the client by its remote address and port */
|
package/dst/specbook-config.d.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Schema } from "./specbook-format-schema.js";
|
|
2
2
|
import { type Diagnostic } from "./specbook-diagnostic.js";
|
|
3
|
+
export declare const configStatistics: (config: Schema) => {
|
|
4
|
+
objects: number;
|
|
5
|
+
links: number;
|
|
6
|
+
};
|
|
3
7
|
export declare const loadConfig: (files: string[]) => {
|
|
4
8
|
config?: Schema;
|
|
5
9
|
diagnostics: Diagnostic[];
|
package/dst/specbook-config.js
CHANGED
|
@@ -48,14 +48,15 @@ const mergeConfig = (target, source, list) => {
|
|
|
48
48
|
if (source === null || source === undefined)
|
|
49
49
|
return target;
|
|
50
50
|
else if (Array.isArray(target) && Array.isArray(source)) {
|
|
51
|
+
const items = target;
|
|
51
52
|
for (const item of source) {
|
|
52
|
-
const i =
|
|
53
|
+
const i = items.findIndex((existing) => identityOf(existing, list) === identityOf(item, list));
|
|
53
54
|
if (i < 0)
|
|
54
|
-
|
|
55
|
+
items.push(item);
|
|
55
56
|
else
|
|
56
|
-
|
|
57
|
+
items[i] = mergeConfig(items[i], item, list);
|
|
57
58
|
}
|
|
58
|
-
return
|
|
59
|
+
return items;
|
|
59
60
|
}
|
|
60
61
|
else if (isPlainObject(target) && isPlainObject(source))
|
|
61
62
|
return mergeWith(target, source, (t, s, key) => Array.isArray(t) && Array.isArray(s) ? mergeConfig(t, s, key) : undefined);
|
|
@@ -90,7 +91,10 @@ const posOfMergedPath = (docs, merged, path) => {
|
|
|
90
91
|
for (const segment of path) {
|
|
91
92
|
const key = typeof segment === "number" && Array.isArray(own) ?
|
|
92
93
|
own.findIndex((item) => identityOf(item, list) === identityOf(at(node, segment), list)) : segment;
|
|
93
|
-
|
|
94
|
+
const reaches = typeof key === "number" ?
|
|
95
|
+
Array.isArray(own) && key >= 0 :
|
|
96
|
+
isPlainObject(own) && key in own;
|
|
97
|
+
if (!reaches)
|
|
94
98
|
break;
|
|
95
99
|
translated.push(key);
|
|
96
100
|
node = at(node, segment);
|
|
@@ -242,6 +246,25 @@ const checkConstraints = (config, posOfPath) => {
|
|
|
242
246
|
check(config, [], 1);
|
|
243
247
|
return diagnostics;
|
|
244
248
|
};
|
|
249
|
+
/* the statistics of a schema configuration: the number of specified
|
|
250
|
+
object kinds (recursively) and of relationship kinds, i.e. the
|
|
251
|
+
reference-valued properties (whose expressions are valid, as the
|
|
252
|
+
configuration is validated before use) */
|
|
253
|
+
export const configStatistics = (config) => {
|
|
254
|
+
let objects = 0;
|
|
255
|
+
let links = 0;
|
|
256
|
+
const walk = (object) => {
|
|
257
|
+
objects++;
|
|
258
|
+
for (const prop of object.props ?? [])
|
|
259
|
+
if (prop.value !== undefined && admitsReferences(compileValueExpr(prop.value)))
|
|
260
|
+
links++;
|
|
261
|
+
for (const child of object.childs ?? [])
|
|
262
|
+
walk(child);
|
|
263
|
+
};
|
|
264
|
+
for (const object of config)
|
|
265
|
+
walk(object);
|
|
266
|
+
return { objects, links };
|
|
267
|
+
};
|
|
245
268
|
/* load and validate the YAML schema configuration, merged in order out
|
|
246
269
|
of its files (the later ones into the earlier ones), where every file
|
|
247
270
|
has to be readable and syntactically valid on its own, while the
|
|
@@ -282,10 +305,10 @@ export const loadConfig = (files) => {
|
|
|
282
305
|
}
|
|
283
306
|
if (diagnostics.length > 0)
|
|
284
307
|
return { diagnostics };
|
|
285
|
-
/* merge the documents in order, out of
|
|
286
|
-
conversions, as the merge mutates its target while the raw
|
|
287
|
-
have to stay intact for the path translation */
|
|
288
|
-
const merged = docs.map(({
|
|
308
|
+
/* merge the documents in order, out of unshared deep copies of
|
|
309
|
+
the conversions, as the merge mutates its target while the raw
|
|
310
|
+
ones have to stay intact for the path translation */
|
|
311
|
+
const merged = docs.map(({ raw }) => unshared(raw))
|
|
289
312
|
.reduce((target, source) => mergeConfig(target, source));
|
|
290
313
|
/* semantically validate the merged result against the schema of the configuration */
|
|
291
314
|
const posOfPath = (path) => posOfMergedPath(docs, merged, path);
|
package/dst/specbook-diagram.js
CHANGED
|
@@ -213,9 +213,12 @@ const deriveEdges = (diagram, type, nodes, nodeSet, edgeObjects, index, anchors,
|
|
|
213
213
|
if (diagram.onlyConnected === true)
|
|
214
214
|
errors.push({ reason: "\"grid\" diagram cannot carry an \"onlyConnected\" configuration" });
|
|
215
215
|
}
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
216
|
+
return edges;
|
|
217
|
+
};
|
|
218
|
+
/* deduplicate the edges (a containment, object, or center edge can
|
|
219
|
+
coincide with a reference edge, and several edge objects can
|
|
220
|
+
describe the very same edge) */
|
|
221
|
+
const dedupEdges = (edges, anchors) => {
|
|
219
222
|
const seenEdges = new Set();
|
|
220
223
|
return edges.filter((edge) => {
|
|
221
224
|
const key = `${anchors.get(edge.source) ?? edge.source.id}` +
|
|
@@ -368,7 +371,7 @@ const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) =>
|
|
|
368
371
|
const edgeSet = new Set(edgeObjects);
|
|
369
372
|
nodes = nodes.filter((node) => !edgeSet.has(node));
|
|
370
373
|
const nodeSet = new Set(nodes);
|
|
371
|
-
/* derive the
|
|
374
|
+
/* derive the edges of the diagram */
|
|
372
375
|
let edges = deriveEdges(diagram, type, nodes, nodeSet, edgeObjects, index, anchors, parents, parenProps, errors);
|
|
373
376
|
/* determine the center of a "hub" diagram (the only shape
|
|
374
377
|
carrying a center at all) */
|
|
@@ -384,9 +387,12 @@ const deriveDiagram = (object, diagram, index, anchors, parents, parenProps) =>
|
|
|
384
387
|
return { errors };
|
|
385
388
|
const { center, centerUrl } = derived;
|
|
386
389
|
/* synthesize the center edges of a "hub" diagram, as the nodes
|
|
387
|
-
carry no "[[...]]" reference to a synthetic center
|
|
390
|
+
carry no "[[...]]" reference to a synthetic center, and only
|
|
391
|
+
then deduplicate all edges, as a center edge of a real center
|
|
392
|
+
can coincide with a reference edge of a node onto it */
|
|
388
393
|
if (type === "hub" && diagram.centerEdges !== undefined)
|
|
389
394
|
edges.push(...deriveCenterEdges(diagram.centerEdges, nodes, center, index, parenProps, errors));
|
|
395
|
+
edges = dedupEdges(edges, anchors);
|
|
390
396
|
/* a "hub" diagram is the hub-projection onto its center object:
|
|
391
397
|
only the edges incident to the center and only the nodes
|
|
392
398
|
connected through them remain, with self-loops dropped, as
|
|
@@ -513,11 +519,11 @@ export const validateDiagrams = (ctx, specification, config) => {
|
|
|
513
519
|
object itself and report it just once, as a wildcard
|
|
514
520
|
"edges" pattern lets every diagram walk the very same
|
|
515
521
|
edge objects again */
|
|
516
|
-
const meta = ctx.
|
|
522
|
+
const meta = ctx.metaOf(error.object ?? object);
|
|
517
523
|
const message = error.object !== undefined ?
|
|
518
524
|
`invalid diagram edge on ${error.object.kind} "${error.object.name}": ${error.reason}` :
|
|
519
525
|
`invalid diagram on ${object.kind} "${object.name}": ${error.reason}`;
|
|
520
|
-
const key = `${meta.file}
|
|
526
|
+
const key = `${meta.file}\u0000${meta.line}\u0000${message}`;
|
|
521
527
|
if (seen.has(key))
|
|
522
528
|
continue;
|
|
523
529
|
seen.add(key);
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
5
|
*/
|
|
6
6
|
import fs from "node:fs";
|
|
7
|
+
import { plainText } from "./specbook-link.js";
|
|
7
8
|
/* escape a text for embedding into template HTML (text and attributes) */
|
|
8
9
|
export const escapeHtml = (text) => text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
9
10
|
.replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -128,24 +129,16 @@ const typographyGlyphs = [
|
|
|
128
129
|
0x00A0, 0x00AB, 0x00BB, 0x2013, 0x2014, 0x2018, 0x2019,
|
|
129
130
|
0x201A, 0x201C, 0x201D, 0x201E, 0x2022, 0x2026, 0x2039, 0x203A
|
|
130
131
|
];
|
|
131
|
-
/* the memoized subsetted stylesheets
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
the
|
|
132
|
+
/* the memoized subsetted stylesheets (as pending or settled promises,
|
|
133
|
+
so concurrent requests share a single subsetting), keyed by the
|
|
134
|
+
(shared) codepoint set of the charset, as the font subsetting is
|
|
135
|
+
expensive and the formats of a single export, the watch and preview
|
|
136
|
+
re-exports, and the MCP service request the very same subset over
|
|
137
|
+
and over again */
|
|
135
138
|
const subsetCache = new Map();
|
|
136
|
-
/*
|
|
137
|
-
|
|
138
|
-
glyphs (no charset or a full Unicode charset keeps the fonts complete) */
|
|
139
|
-
export const subsetStylesheet = async (charset) => {
|
|
140
|
-
const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
|
|
141
|
-
if (codepoints === undefined)
|
|
142
|
-
return stylesheet();
|
|
143
|
-
const cached = subsetCache.get(codepoints);
|
|
144
|
-
if (cached !== undefined)
|
|
145
|
-
return cached;
|
|
146
|
-
const css = stylesheet();
|
|
139
|
+
/* subset the embedded fonts of a stylesheet to the glyphs of a text */
|
|
140
|
+
const subsetFonts = async (css, text) => {
|
|
147
141
|
const { default: subsetFont } = await import("subset-font");
|
|
148
|
-
const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
|
|
149
142
|
let result = "";
|
|
150
143
|
let last = 0;
|
|
151
144
|
for (const m of css.matchAll(/url\("data:font\/woff2;base64,([^"]+)"\)/g)) {
|
|
@@ -154,18 +147,36 @@ export const subsetStylesheet = async (charset) => {
|
|
|
154
147
|
`url("data:font/woff2;base64,${subset.toString("base64")}")`;
|
|
155
148
|
last = m.index + m[0].length;
|
|
156
149
|
}
|
|
157
|
-
|
|
158
|
-
|
|
150
|
+
return result + css.slice(last);
|
|
151
|
+
};
|
|
152
|
+
/* provide the stylesheet with its embedded fonts subsetted to the
|
|
153
|
+
codepoints of a charset plus the always-used symbol and typography
|
|
154
|
+
glyphs (no charset or a full Unicode charset keeps the fonts complete),
|
|
155
|
+
where a failed subsetting leaves the cache, so the next request retries */
|
|
156
|
+
export const subsetStylesheet = async (charset) => {
|
|
157
|
+
const codepoints = charset !== undefined ? charsetCodepoints(charset) : undefined;
|
|
158
|
+
if (codepoints === undefined)
|
|
159
|
+
return stylesheet();
|
|
160
|
+
let subsetted = subsetCache.get(codepoints);
|
|
161
|
+
if (subsetted === undefined) {
|
|
162
|
+
const text = String.fromCodePoint(...codepoints, ...symbolGlyphs, ...typographyGlyphs);
|
|
163
|
+
subsetted = subsetFonts(stylesheet(), text);
|
|
164
|
+
subsetted.catch(() => { subsetCache.delete(codepoints); });
|
|
165
|
+
subsetCache.set(codepoints, subsetted);
|
|
166
|
+
}
|
|
159
167
|
return subsetted;
|
|
160
168
|
};
|
|
161
169
|
/* determine the document title and subtitle from the title object (an
|
|
162
|
-
empty TITLE counts as absent, exactly like for the title page)
|
|
170
|
+
empty TITLE counts as absent, exactly like for the title page), with
|
|
171
|
+
the inline code markup stripped, as the plain-text targets (the HTML
|
|
172
|
+
<title>, the PDF metadata and page header) render no Markdown */
|
|
163
173
|
export const documentTitle = (specification) => {
|
|
164
174
|
const title = titleProperty(specification, "TITLE")?.trim();
|
|
175
|
+
const subtitle = titleProperty(specification, "SUBTITLE")?.trim();
|
|
165
176
|
return {
|
|
166
|
-
title: title !== undefined && title !== "" ? title :
|
|
167
|
-
titleObject(specification)?.name ?? "Specification",
|
|
168
|
-
subtitle:
|
|
177
|
+
title: plainText(title !== undefined && title !== "" ? title :
|
|
178
|
+
titleObject(specification)?.name ?? "Specification"),
|
|
179
|
+
subtitle: subtitle !== undefined ? plainText(subtitle) : undefined
|
|
169
180
|
};
|
|
170
181
|
};
|
|
171
182
|
/* determine the document logo as a self-contained data: URL, taken from the
|
|
@@ -81,6 +81,8 @@ html.theme-light {
|
|
|
81
81
|
--theme-color-specbook-popup-schema-desc: var(--theme-color-base-32);
|
|
82
82
|
--theme-color-specbook-popup-spec-path: var(--theme-color-accent-12);
|
|
83
83
|
--theme-color-specbook-popup-spec-desc: var(--theme-color-base-5);
|
|
84
|
+
--theme-color-specbook-progress-done: var(--theme-color-accent-12);
|
|
85
|
+
--theme-color-specbook-progress-todo: var(--theme-color-accent-31);
|
|
84
86
|
--gradia-font-family: "Source Sans 3";
|
|
85
87
|
--gradia-color-node-regular-name: var(--theme-color-accent-12);
|
|
86
88
|
--gradia-color-node-regular-box: var(--theme-color-accent-30);
|
|
@@ -122,6 +124,8 @@ html.theme-light {
|
|
|
122
124
|
--theme-color-specbook-popup-schema-desc: var(--theme-color-base-7);
|
|
123
125
|
--theme-color-specbook-popup-spec-path: var(--theme-color-accent-24);
|
|
124
126
|
--theme-color-specbook-popup-spec-desc: var(--theme-color-base-29);
|
|
127
|
+
--theme-color-specbook-progress-done: var(--theme-color-accent-24);
|
|
128
|
+
--theme-color-specbook-progress-todo: var(--theme-color-accent-4);
|
|
125
129
|
--gradia-color-node-regular-name: var(--theme-color-accent-21);
|
|
126
130
|
--gradia-color-node-regular-box: var(--theme-color-accent-3);
|
|
127
131
|
--gradia-color-node-regular-border: var(--theme-color-accent-7);
|
|
@@ -437,6 +441,64 @@ div.realtime-status.reloaded svg.realtime-icon {
|
|
|
437
441
|
display: none;
|
|
438
442
|
}
|
|
439
443
|
}
|
|
444
|
+
/* the scroll progress meter (screen-only): a ring at the bottom right
|
|
445
|
+
corner of the viewport (fixed independent of the content scrolling),
|
|
446
|
+
whose DONE part (the arc drawn clockwise from the top, together with
|
|
447
|
+
the upward arrow in its center, drawn like the tab icons) reflects
|
|
448
|
+
the scrolled fraction of the document over the TODO part (the full
|
|
449
|
+
circle in the lighter progress color beneath), which stays hidden
|
|
450
|
+
while the page is scrolled near the top (fading in once scrolled
|
|
451
|
+
beyond 400px) and scrolls the page back to the top on click, where
|
|
452
|
+
the DONE part and the arrow brighten on hover like the tabs */
|
|
453
|
+
div.scroll-progress {
|
|
454
|
+
position: fixed;
|
|
455
|
+
right: 1.5rem;
|
|
456
|
+
bottom: 1.5rem;
|
|
457
|
+
z-index: 30;
|
|
458
|
+
width: 3rem;
|
|
459
|
+
height: 3rem;
|
|
460
|
+
line-height: 0;
|
|
461
|
+
color: var(--theme-color-specbook-progress-done);
|
|
462
|
+
opacity: 0;
|
|
463
|
+
pointer-events: none;
|
|
464
|
+
transition: opacity 0.3s ease;
|
|
465
|
+
cursor: pointer;
|
|
466
|
+
-webkit-user-select: none;
|
|
467
|
+
user-select: none;
|
|
468
|
+
}
|
|
469
|
+
div.scroll-progress.shown {
|
|
470
|
+
opacity: 1;
|
|
471
|
+
pointer-events: auto;
|
|
472
|
+
}
|
|
473
|
+
div.scroll-progress:hover {
|
|
474
|
+
color: var(--theme-color-specbook-accent-soft);
|
|
475
|
+
}
|
|
476
|
+
div.scroll-progress svg.scroll-ring {
|
|
477
|
+
position: absolute;
|
|
478
|
+
inset: 0;
|
|
479
|
+
width: 100%;
|
|
480
|
+
height: 100%;
|
|
481
|
+
transform: rotate(-90deg);
|
|
482
|
+
}
|
|
483
|
+
div.scroll-progress svg.scroll-ring circle.scroll-todo {
|
|
484
|
+
stroke: var(--theme-color-specbook-progress-todo);
|
|
485
|
+
}
|
|
486
|
+
div.scroll-progress svg.scroll-ring circle.scroll-done {
|
|
487
|
+
stroke: currentColor;
|
|
488
|
+
}
|
|
489
|
+
div.scroll-progress svg.scroll-icon {
|
|
490
|
+
position: absolute;
|
|
491
|
+
top: 50%;
|
|
492
|
+
left: 50%;
|
|
493
|
+
width: 1.1rem;
|
|
494
|
+
height: 1.1rem;
|
|
495
|
+
transform: translate(-50%, -50%);
|
|
496
|
+
}
|
|
497
|
+
@media print {
|
|
498
|
+
div.scroll-progress {
|
|
499
|
+
display: none;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
440
502
|
/* the side panel of the table of contents (screen-only): a fixed
|
|
441
503
|
full-height drawer at the left viewport edge, carrying a replica of
|
|
442
504
|
the brand bar on its right edge, which exactly covers the brand bar
|