@chrisdudek/yg 5.3.0 → 5.4.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (29) hide show
  1. package/dist/bin.js +1985 -397
  2. package/dist/structure.d.ts +7 -0
  3. package/dist/structure.js +10 -5
  4. package/dist/templates/portal/js/bootstrap.js +206 -0
  5. package/dist/templates/portal/js/consumer.js +128 -0
  6. package/dist/templates/portal/js/dispatch.js +181 -0
  7. package/dist/templates/portal/js/export.js +151 -0
  8. package/dist/templates/portal/js/glossary.js +72 -0
  9. package/dist/templates/portal/js/namespace.js +60 -0
  10. package/dist/templates/portal/js/palette-overlay.js +176 -0
  11. package/dist/templates/portal/js/palette.js +133 -0
  12. package/dist/templates/portal/js/router.js +169 -0
  13. package/dist/templates/portal/js/shell.js +264 -0
  14. package/dist/templates/portal/js/state-model.js +142 -0
  15. package/dist/templates/portal/js/tree.js +190 -0
  16. package/dist/templates/portal/js/views/coverage-view.js +247 -0
  17. package/dist/templates/portal/js/views/flows-view.js +204 -0
  18. package/dist/templates/portal/js/views/overview-view.js +167 -0
  19. package/dist/templates/portal/js/views/panel-aspect.js +141 -0
  20. package/dist/templates/portal/js/views/panel-view.js +314 -0
  21. package/dist/templates/portal/js/views/relations-matrix.js +185 -0
  22. package/dist/templates/portal/js/views/relations-view.js +185 -0
  23. package/dist/templates/portal/js/views/rulebook-view.js +174 -0
  24. package/dist/templates/portal/js/views/start-view.js +287 -0
  25. package/dist/templates/portal/js/views/suppressions-view.js +187 -0
  26. package/dist/templates/portal/js/views/tree-view.js +68 -0
  27. package/dist/templates/portal/js/views/types-view.js +154 -0
  28. package/dist/templates/portal/vendor/d3-hierarchy.js +350 -0
  29. package/package.json +5 -2
@@ -0,0 +1,169 @@
1
+ /*
2
+ * URL-hash router — the deep-link grammar for every view and entity.
3
+ *
4
+ * The portal is a single page, but EVERY view and EVERY entity (node / aspect / flow,
5
+ * down to a per-verdict node→aspect→file-unit) is deep-linkable: a hash routes straight
6
+ * to it, and reloading the page reopens exactly what the hash names (an entity reopens
7
+ * its panel). The grammar:
8
+ *
9
+ * (empty) -> { view: 'overview' } (default)
10
+ * #/view/<viewId> -> { view: <viewId> }
11
+ * #/node/<path> -> { view: 'tree', node: <path> }
12
+ * #/node/<path>/<aspectId>/<file-unit> -> { ..., aspect, file } (per-verdict)
13
+ * #/aspect/<id> -> { view: 'rulebook', aspect: <id> }
14
+ * #/flow/<name> -> { view: 'flows', flow: <name> }
15
+ *
16
+ * Each path segment is percent-encoded, so a node path with slashes, or a file unit with
17
+ * its own slashes, round-trips losslessly. parse() and serialize() are PURE string
18
+ * functions (no DOM, no window) so they are testable directly; start() wires them to the
19
+ * live `hashchange` event. Browser globals only — no network, no Node.
20
+ */
21
+ (function () {
22
+ 'use strict';
23
+
24
+ var Yg = (window.YgPortal = window.YgPortal || {});
25
+
26
+ // The known full-view ids. An unknown view id falls back to the overview so a stale or
27
+ // hand-typed hash never lands on a blank page.
28
+ var VIEWS = [
29
+ 'overview',
30
+ 'coverage',
31
+ 'tree',
32
+ 'relations',
33
+ 'rulebook',
34
+ 'types',
35
+ 'flows',
36
+ 'suppressions',
37
+ 'start',
38
+ ];
39
+
40
+ // Which view an entity kind opens into (the entity's natural home surface).
41
+ var ENTITY_VIEW = { node: 'tree', aspect: 'rulebook', flow: 'flows' };
42
+
43
+ function isKnownView(id) {
44
+ return VIEWS.indexOf(id) !== -1;
45
+ }
46
+
47
+ /** Split a hash into clean, decoded segments (drops the leading '#', '/' and empties). */
48
+ function segments(hash) {
49
+ var raw = String(hash || '');
50
+ if (raw.charAt(0) === '#') raw = raw.slice(1);
51
+ if (raw.charAt(0) === '/') raw = raw.slice(1);
52
+ if (raw.length === 0) return [];
53
+ return raw.split('/').map(decodeSegment);
54
+ }
55
+
56
+ function decodeSegment(seg) {
57
+ try {
58
+ return decodeURIComponent(seg);
59
+ } catch (_e) {
60
+ return seg;
61
+ }
62
+ }
63
+
64
+ /**
65
+ * Parse a hash string into a normalized route object. Always returns a valid route — an
66
+ * empty or malformed hash resolves to the default overview, never null. The route always
67
+ * carries a `view`; entity fields (node / aspect / flow / file) are present only when the
68
+ * hash names them.
69
+ */
70
+ function parse(hash) {
71
+ var segs = segments(hash);
72
+ if (segs.length === 0) return { view: 'overview' };
73
+
74
+ var kind = segs[0];
75
+
76
+ if (kind === 'view') {
77
+ var id = segs[1];
78
+ if (isKnownView(id)) return { view: id };
79
+ return { view: 'overview', notFound: id || '(none)' };
80
+ }
81
+
82
+ if (kind === 'node' && segs[1]) {
83
+ var route = { view: ENTITY_VIEW.node, node: segs[1] };
84
+ // Per-verdict file unit: node / aspectId / file-unit (file may itself contain '/').
85
+ if (segs.length >= 4) {
86
+ route.aspect = segs[2];
87
+ route.file = segs.slice(3).join('/');
88
+ }
89
+ return route;
90
+ }
91
+
92
+ if (kind === 'aspect' && segs[1]) {
93
+ return { view: ENTITY_VIEW.aspect, aspect: segs[1] };
94
+ }
95
+
96
+ if (kind === 'flow' && segs[1]) {
97
+ return { view: ENTITY_VIEW.flow, flow: segs[1] };
98
+ }
99
+
100
+ // Bare `#/view-id` shorthand (no `view/` prefix) — accept a known view id directly.
101
+ if (isKnownView(kind)) return { view: kind };
102
+
103
+ // An unrecognized hash — degrade to overview but carry the requested token so the page can
104
+ // say "nothing here" honestly, instead of silently presenting overview as if it resolved.
105
+ return { view: 'overview', notFound: segs.join('/') };
106
+ }
107
+
108
+ function enc(seg) {
109
+ return encodeURIComponent(String(seg));
110
+ }
111
+
112
+ /**
113
+ * Serialize a route object back into a canonical hash string. The inverse of parse for
114
+ * any route parse() produces, so a round-trip is lossless. A node route with a per-verdict
115
+ * file unit encodes node / aspect / file as separate segments (the file's own slashes are
116
+ * percent-encoded inside its single segment).
117
+ */
118
+ function serialize(route) {
119
+ if (!route || !route.view) return '#/view/overview';
120
+
121
+ if (route.node) {
122
+ var parts = ['#', 'node', enc(route.node)];
123
+ if (route.aspect && route.file) {
124
+ parts.push(enc(route.aspect));
125
+ parts.push(enc(route.file));
126
+ }
127
+ return parts.join('/');
128
+ }
129
+ if (route.aspect) return '#/aspect/' + enc(route.aspect);
130
+ if (route.flow) return '#/flow/' + enc(route.flow);
131
+ return '#/view/' + enc(route.view);
132
+ }
133
+
134
+ /**
135
+ * Create a router bound to the live location. `onRoute(route)` fires on every hash change
136
+ * and once at start with the current route. `go(route)` updates the hash (which triggers
137
+ * onRoute via the hashchange listener — one code path, no double render). Returns a small
138
+ * controller; `current()` reads the live route without navigating.
139
+ */
140
+ function create(onRoute) {
141
+ function emit() {
142
+ onRoute(parse(window.location.hash));
143
+ }
144
+ function go(route) {
145
+ var next = serialize(route);
146
+ if (window.location.hash === next) {
147
+ emit(); // same hash — re-emit so a repeat selection still re-renders
148
+ } else {
149
+ window.location.hash = next;
150
+ }
151
+ }
152
+ function current() {
153
+ return parse(window.location.hash);
154
+ }
155
+ function start() {
156
+ window.addEventListener('hashchange', emit);
157
+ emit();
158
+ }
159
+ return { go: go, current: current, start: start };
160
+ }
161
+
162
+ Yg.router = {
163
+ VIEWS: VIEWS,
164
+ parse: parse,
165
+ serialize: serialize,
166
+ create: create,
167
+ isKnownView: isKnownView,
168
+ };
169
+ })();
@@ -0,0 +1,264 @@
1
+ /*
2
+ * Shell — the persistent three-region chrome rendered on every view.
3
+ *
4
+ * Builds and owns the frame the whole portal lives in:
5
+ * - LEFT nav rail: brand (-> overview), a ⌘K trigger, and the grouped view links
6
+ * ("Get oriented" / "Inspect & audit"), the active one highlighted.
7
+ * - TOP bar: a live-status pill + Refresh + Approve control cluster, a breadcrumb, the
8
+ * ⌘K search trigger, a write/view-only indicator, and the theme toggle.
9
+ * - RIGHT panel slot: an empty mount the Node Attestation panel slides into on selection.
10
+ * - CENTER stage: the mount the view dispatcher renders the current view into.
11
+ *
12
+ * The shell is built ONCE; navigation only swaps the center stage and updates the rail
13
+ * highlight + breadcrumb, so the chrome is stable. Browser globals only — Refresh/Approve
14
+ * are wired by the boot to the server (or disabled on a static page); no network here.
15
+ */
16
+ (function () {
17
+ 'use strict';
18
+
19
+ var Yg = (window.YgPortal = window.YgPortal || {});
20
+ var dom = Yg.dom;
21
+
22
+ // Nav rail groups -> view ids, mirroring §3a primary navigation.
23
+ var NAV_GROUPS = [
24
+ {
25
+ title: 'Get oriented',
26
+ items: [
27
+ { view: 'overview', glyph: '◧', label: 'Overview' },
28
+ { view: 'start', glyph: '◆', label: 'Start here' },
29
+ { view: 'flows', glyph: '⤳', label: 'Flows' },
30
+ { view: 'rulebook', glyph: '▤', label: 'Rulebook' },
31
+ { view: 'types', glyph: '◇', label: 'Type model' },
32
+ ],
33
+ },
34
+ {
35
+ title: 'Inspect & audit',
36
+ items: [
37
+ { view: 'coverage', glyph: '▦', label: 'Coverage & audit' },
38
+ { view: 'tree', glyph: '⌗', label: 'Structure' },
39
+ { view: 'relations', glyph: '↔', label: 'Relations & boundaries' },
40
+ { view: 'suppressions', glyph: '⛉', label: 'Suppressions' },
41
+ ],
42
+ },
43
+ ];
44
+
45
+ /**
46
+ * Build the shell into `root` for a given PortalData. `handlers` carries the user actions
47
+ * the chrome triggers: { onNavigate(view), onSearch(), onRefresh(), onApprove(), onTheme() }.
48
+ * Returns a controller exposing the live region mounts and the per-navigation updaters.
49
+ */
50
+ function build(root, data, handlers) {
51
+ dom.clear(root);
52
+ var app = dom.el('div', 'app-grid');
53
+
54
+ var rail = buildRail(data, handlers);
55
+ var main = dom.el('main', 'app-main');
56
+ var topbar = buildTopbar(data, handlers);
57
+ var stage = dom.el('div', 'app-stage');
58
+ main.appendChild(topbar.el);
59
+ main.appendChild(stage);
60
+
61
+ var panel = dom.el('aside', 'app-panel');
62
+ panel.setAttribute('aria-label', 'Node attestation panel');
63
+
64
+ app.appendChild(rail.el);
65
+ app.appendChild(main);
66
+ app.appendChild(panel);
67
+
68
+ // The honest key as ONE pinned, collapsible bottom bar — built once here, never inside a
69
+ // view's scrolling stage, so it can be pinned and the stage clears it via its bottom padding.
70
+ if (Yg.dispatch && typeof Yg.dispatch.buildLegendBar === 'function') {
71
+ app.appendChild(Yg.dispatch.buildLegendBar());
72
+ }
73
+
74
+ root.appendChild(app);
75
+
76
+ return {
77
+ stage: stage,
78
+ panel: panel,
79
+ setActive: function (view) {
80
+ rail.setActive(view);
81
+ },
82
+ setBreadcrumb: function (route) {
83
+ topbar.setBreadcrumb(route);
84
+ },
85
+ setFreshness: function (text, tone) {
86
+ topbar.setFreshness(text, tone);
87
+ },
88
+ };
89
+ }
90
+
91
+ function buildRail(data, handlers) {
92
+ var rail = dom.el('aside', 'app-rail');
93
+ rail.setAttribute('aria-label', 'Primary navigation');
94
+
95
+ var brand = dom.el('button', 'rail-brand');
96
+ brand.type = 'button';
97
+ // The brand mark: reuse the inline data: URI the serializer injected into the favicon
98
+ // link (single source, no network). Guarded so the non-DOM test sandbox (no querySelector)
99
+ // and any environment lacking the link simply omit the mark rather than break. The mark is
100
+ // decorative (alt=''), as the adjacent text already names the product.
101
+ var favicon = document.querySelector && document.querySelector('link[rel~="icon"]');
102
+ if (favicon && favicon.getAttribute('href')) {
103
+ var logo = dom.el('img', 'rail-logo');
104
+ logo.src = favicon.getAttribute('href');
105
+ logo.alt = '';
106
+ brand.appendChild(logo);
107
+ }
108
+ brand.appendChild(dom.el('b', null, 'Yggdrasil'));
109
+ // A static export is a frozen snapshot (no server), not a live view — say so honestly.
110
+ var brandSub = !Yg.consumer.isServed() ? 'snapshot' : data.meta.writeEnabled ? 'live view' : 'view-only';
111
+ brand.appendChild(dom.el('span', 'rail-brand-sub', brandSub));
112
+ brand.addEventListener('click', function () {
113
+ handlers.onNavigate('overview');
114
+ });
115
+ rail.appendChild(brand);
116
+
117
+ var cmdk = dom.el('button', 'rail-cmdk');
118
+ cmdk.type = 'button';
119
+ cmdk.appendChild(dom.el('span', null, '⌕ Search…'));
120
+ cmdk.appendChild(dom.el('kbd', null, '⌘K'));
121
+ cmdk.addEventListener('click', function () {
122
+ handlers.onSearch();
123
+ });
124
+ rail.appendChild(cmdk);
125
+
126
+ var links = {};
127
+ for (var g = 0; g < NAV_GROUPS.length; g += 1) {
128
+ var grp = NAV_GROUPS[g];
129
+ rail.appendChild(dom.el('div', 'rail-h', grp.title));
130
+ var nav = dom.el('nav', 'rail-nav');
131
+ for (var i = 0; i < grp.items.length; i += 1) {
132
+ nav.appendChild(buildLink(grp.items[i], handlers, links));
133
+ }
134
+ rail.appendChild(nav);
135
+ }
136
+
137
+ return {
138
+ el: rail,
139
+ setActive: function (view) {
140
+ for (var key in links) {
141
+ if (!Object.prototype.hasOwnProperty.call(links, key)) continue;
142
+ if (key === view) links[key].classList.add('on');
143
+ else links[key].classList.remove('on');
144
+ }
145
+ },
146
+ };
147
+ }
148
+
149
+ function buildLink(item, handlers, links) {
150
+ var a = dom.el('button', 'rail-link');
151
+ a.type = 'button';
152
+ a.appendChild(dom.el('span', 'rail-glyph', item.glyph));
153
+ a.appendChild(dom.el('span', null, item.label));
154
+ a.addEventListener('click', function () {
155
+ handlers.onNavigate(item.view);
156
+ });
157
+ links[item.view] = a;
158
+ return a;
159
+ }
160
+
161
+ function buildTopbar(data, handlers) {
162
+ var bar = dom.el('div', 'app-topbar');
163
+
164
+ var live = dom.el('span', 'topbar-live');
165
+ var dot = dom.el('span', 'topbar-dot');
166
+ live.appendChild(dot);
167
+ var liveText = dom.el(
168
+ 'span',
169
+ null,
170
+ !Yg.consumer.isServed()
171
+ ? 'Exported snapshot — re-run yg portal for a live view'
172
+ : data.meta.writeEnabled
173
+ ? 'Live · re-checks free on refresh'
174
+ : 'View-only',
175
+ );
176
+ live.appendChild(liveText);
177
+ bar.appendChild(live);
178
+
179
+ var crumb = dom.el('span', 'topbar-crumb', '');
180
+ bar.appendChild(crumb);
181
+
182
+ var spacer = dom.el('span', 'topbar-spacer');
183
+ bar.appendChild(spacer);
184
+
185
+ // Refresh and Approve are live ONLY when the page is served by `yg portal`. On a static
186
+ // export they cannot reach the server, so they are disabled UP FRONT with a plain reason
187
+ // (never click-to-discover), and the page never presents a write it cannot perform.
188
+ var served = Yg.consumer.isServed();
189
+ var staticReason = 'Available only when the portal is served by `yg portal` — this is a read-only export.';
190
+
191
+ var refresh = dom.el('button', 'btn topbar-refresh');
192
+ refresh.type = 'button';
193
+ refresh.appendChild(dom.el('span', null, '↻ Refresh'));
194
+ refresh.appendChild(dom.el('span', 'btn-free', 'free'));
195
+ refresh.addEventListener('click', function () {
196
+ handlers.onRefresh();
197
+ });
198
+ if (!served) {
199
+ refresh.disabled = true;
200
+ refresh.title = staticReason;
201
+ refresh.setAttribute('aria-label', 'Refresh — ' + staticReason);
202
+ }
203
+ bar.appendChild(refresh);
204
+
205
+ var approve = dom.el('button', 'btn primary topbar-approve', '✓ Approve');
206
+ approve.type = 'button';
207
+ approve.disabled = !data.meta.writeEnabled || !served;
208
+ if (approve.disabled) {
209
+ var approveReason = !served ? staticReason : 'View-only mode (--no-write) — approving is disabled.';
210
+ approve.title = approveReason;
211
+ approve.setAttribute('aria-label', '✓ Approve — ' + approveReason);
212
+ }
213
+ approve.addEventListener('click', function () {
214
+ handlers.onApprove();
215
+ });
216
+ bar.appendChild(approve);
217
+
218
+ var search = dom.el('button', 'btn topbar-search', '⌕');
219
+ search.type = 'button';
220
+ search.setAttribute('aria-label', 'Open command palette');
221
+ search.addEventListener('click', function () {
222
+ handlers.onSearch();
223
+ });
224
+ bar.appendChild(search);
225
+
226
+ var theme = dom.el('button', 'btn topbar-theme', '◐');
227
+ theme.type = 'button';
228
+ theme.setAttribute('aria-label', 'Toggle light / dark theme');
229
+ theme.addEventListener('click', function () {
230
+ handlers.onTheme();
231
+ });
232
+ bar.appendChild(theme);
233
+
234
+ return {
235
+ el: bar,
236
+ setBreadcrumb: function (route) {
237
+ crumb.textContent = breadcrumbText(route);
238
+ },
239
+ setFreshness: function (text, tone) {
240
+ liveText.textContent = text;
241
+ dot.className = 'topbar-dot' + (tone ? ' ' + tone : '');
242
+ },
243
+ };
244
+ }
245
+
246
+ function breadcrumbText(route) {
247
+ if (!route) return '';
248
+ if (route.node) return 'Structure / ' + route.node;
249
+ if (route.aspect) return 'Rulebook / ' + route.aspect;
250
+ if (route.flow) return 'Flows / ' + route.flow;
251
+ return labelForView(route.view);
252
+ }
253
+
254
+ function labelForView(view) {
255
+ for (var g = 0; g < NAV_GROUPS.length; g += 1) {
256
+ for (var i = 0; i < NAV_GROUPS[g].items.length; i += 1) {
257
+ if (NAV_GROUPS[g].items[i].view === view) return NAV_GROUPS[g].items[i].label;
258
+ }
259
+ }
260
+ return view || '';
261
+ }
262
+
263
+ Yg.shell = { build: build, NAV_GROUPS: NAV_GROUPS, _labelForView: labelForView };
264
+ })();
@@ -0,0 +1,142 @@
1
+ /*
2
+ * The honest-state taxonomy — the spine of the whole portal.
3
+ *
4
+ * Eight distinct render states, each carried by color + GLYPH + label + border-style
5
+ * (never color alone — accessibility), exactly as the visual-foundations reference
6
+ * defines them. "verified" is the ONLY green: a reviewer ran, approved, AND the stored
7
+ * hash still matches the current inputs. The others are deliberately, visibly distinct
8
+ * and must NEVER be collapsed into one "green". This module is the single source of
9
+ * truth a renderer reads — no view re-invents a glyph or a color.
10
+ *
11
+ * Five of these (verified / refused / unverified / no-rule / warning) are the
12
+ * PortalNode.state values the contract enumerates; the remaining three
13
+ * (not-applicable / suppressed / draft) are pair/aspect-level honest states, plus
14
+ * "boundary" for a live undeclared-dependency violation. All eight render here so the
15
+ * legend, the panels, and the cells share one honest vocabulary.
16
+ */
17
+ (function () {
18
+ 'use strict';
19
+
20
+ var Yg = (window.YgPortal = window.YgPortal || {});
21
+
22
+ // Glyph + border + plain-language label for every honest state. The plain text is the
23
+ // same wording as the glossary key card, so a tooltip and the legend never diverge.
24
+ var STATE_META = {
25
+ verified: {
26
+ glyph: '✓', // check
27
+ label: 'verified',
28
+ border: 'solid',
29
+ plain: 'A reviewer actually checked this against the current code and it passed. The only green.',
30
+ },
31
+ refused: {
32
+ glyph: '✕', // cross
33
+ label: 'refused',
34
+ border: 'solid',
35
+ plain: 'A reviewer checked it and it broke a rule — with a reason you can read.',
36
+ },
37
+ unverified: {
38
+ glyph: '◌', // dashed circle
39
+ label: 'unverified',
40
+ border: 'dashed',
41
+ plain: "The code changed, so no reviewer has confirmed it yet. Not a pass — just “we don’t know”.",
42
+ },
43
+ 'no-rule': {
44
+ glyph: '⊖', // minus-circle
45
+ label: 'no rule',
46
+ border: 'dotted',
47
+ plain: 'Nothing is checking this part. Not broken — just unguarded. Absence of red is not a pass.',
48
+ },
49
+ warning: {
50
+ glyph: '▲', // triangle (advisory)
51
+ label: 'warning',
52
+ border: 'dashed',
53
+ plain: 'An advisory rule flagged this. It does not block — it is signal worth a look, not a failure.',
54
+ },
55
+ 'not-applicable': {
56
+ glyph: '–', // en-dash
57
+ label: 'not applicable',
58
+ border: 'none',
59
+ plain: 'A rule was filtered out here on purpose — an empty cell, distinct from unverified.',
60
+ },
61
+ suppressed: {
62
+ glyph: '⛉', // shield-like waiver mark
63
+ label: 'waived',
64
+ border: 'dashed',
65
+ plain: 'Someone told the reviewer to skip a rule here, on purpose, with a reason. Not the same as verified.',
66
+ },
67
+ draft: {
68
+ glyph: '‖', // double bar (paused)
69
+ label: 'draft',
70
+ border: 'dotted',
71
+ plain: 'Parked — removed from the expected set. It verifies nothing while it is a draft.',
72
+ },
73
+ boundary: {
74
+ glyph: '⚡', // zap
75
+ label: 'live boundary',
76
+ border: 'hazard',
77
+ plain: 'An undeclared code dependency, recomputed live right now — never read from the stored lock.',
78
+ },
79
+ };
80
+
81
+ // The canonical order the legend renders in (mirrors the foundations reference).
82
+ var STATE_ORDER = [
83
+ 'verified',
84
+ 'refused',
85
+ 'unverified',
86
+ 'no-rule',
87
+ 'warning',
88
+ 'not-applicable',
89
+ 'suppressed',
90
+ 'draft',
91
+ 'boundary',
92
+ ];
93
+
94
+ /** Metadata for a state, falling back to no-rule for an unknown value (never throws). */
95
+ function meta(state) {
96
+ return STATE_META[state] || STATE_META['no-rule'];
97
+ }
98
+
99
+ /** The glyph for a state (color is carried by the CSS class, never alone). */
100
+ function glyph(state) {
101
+ return meta(state).glyph;
102
+ }
103
+
104
+ /** The plain-language label for a state. */
105
+ function label(state) {
106
+ return meta(state).label;
107
+ }
108
+
109
+ /** The plain-language one-line explanation (tooltip text) for a state. */
110
+ function plain(state) {
111
+ return meta(state).plain;
112
+ }
113
+
114
+ /** The CSS state class a renderer attaches so color + border come from one place. */
115
+ function cssClass(state) {
116
+ return 'state-' + (STATE_META[state] ? state : 'no-rule');
117
+ }
118
+
119
+ /**
120
+ * Build an accessible state badge: a glyph element carrying the state color/border (from
121
+ * the CSS class) plus an aria-label of the plain wording, so the state is conveyed by
122
+ * shape AND text, never color alone. Returns a <span> ready to insert.
123
+ */
124
+ function badge(state) {
125
+ var m = meta(state);
126
+ var node = Yg.dom.el('span', 'state-glyph ' + cssClass(state), m.glyph);
127
+ node.setAttribute('role', 'img');
128
+ node.setAttribute('aria-label', m.label);
129
+ node.setAttribute('title', m.plain);
130
+ return node;
131
+ }
132
+
133
+ Yg.states = {
134
+ ORDER: STATE_ORDER,
135
+ meta: meta,
136
+ glyph: glyph,
137
+ label: label,
138
+ plain: plain,
139
+ cssClass: cssClass,
140
+ badge: badge,
141
+ };
142
+ })();