anentrypoint-design 0.0.477 → 0.0.478

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.
@@ -71,5 +71,39 @@
71
71
  .ds-live-log { height: auto; max-height: none; overflow: visible; }
72
72
  .ds-stat, .kpi-card { border: 1px solid var(--rule); }
73
73
  .ds-session-row { break-inside: avoid; }
74
+
75
+ /* Receipt table (Receipt() in src/components/content/panel.js, `.kv`) — the
76
+ project_page kit's "receipt" section, and any other kv-table consumer.
77
+ On screen it inherits panel/token colors that can render as near-white
78
+ text on a printer's assumed-white page background if the browser's
79
+ "background graphics" print option is off (the common default); force
80
+ the LIGHT-theme ink/paper tokens (real print-page colors, not a raw
81
+ literal — --ink/--paper are the same "true black text on true white
82
+ page" values the light theme already defines) and visible cell borders
83
+ so it stays legible without relying on background-graphics printing at
84
+ all. break-inside:avoid keeps a receipt from splitting a row across a
85
+ page boundary. */
86
+ .kv { border-collapse: collapse; width: 100%; color: var(--ink); background: var(--paper); }
87
+ .kv tr { break-inside: avoid; }
88
+ .kv td { border: 1px solid var(--paper-3); padding: 4px 8px; color: var(--ink); }
89
+
90
+ /* Any button/interactive-only affordance still present inside otherwise-
91
+ printable content (copy buttons, expand/collapse toggles, filter pills,
92
+ the receipt/doc section's own action row) is chrome, not content. */
93
+ .btn, .btn-primary, .btn-ghost, .row-act, .ds-filter-pills,
94
+ .app-search, input[type="search"] { display: none; }
95
+
96
+ /* Force real ink-on-paper for body copy: panel/fg tokens are tuned for
97
+ screen surfaces (including dark theme, where --panel-text can resolve
98
+ near-white) and a printer defaults to a white page regardless of
99
+ data-theme, so an unconverted dark-theme page would print near-invisible
100
+ text without this override. Same --ink/--paper light-theme pair as the
101
+ receipt table above, not a second raw literal. */
102
+ /* No !important: source order already wins here (this rule is the last
103
+ word inside its own @media print block, same as every other override in
104
+ this file), and an unconditional !important on `body` would beat even a
105
+ future print-specific consumer override with higher specificity. */
106
+ html body, .app-main, .page-body, .ds-lede { color: var(--ink); background: var(--paper); }
107
+ a { color: var(--ink); text-decoration: underline; }
74
108
  }
75
109
 
@@ -825,3 +825,45 @@ input[type="password"]:not(:placeholder-shown) + .input-clear {
825
825
  max-width: none;
826
826
  }
827
827
  }
828
+
829
+ /* Stacking host for the imperative toast()/<Toast> component
830
+ (src/components/editor-primitives/toast.js). One fixed-position column;
831
+ individual toasts are appended/removed as siblings inside it rather than
832
+ each toast fixed-positioning itself, so more than one toast in flight
833
+ stacks instead of overlapping at the same bottom-right point. */
834
+ .ds-ep-toast-host {
835
+ position: fixed; z-index: var(--z-toast);
836
+ bottom: 24px; right: 24px;
837
+ display: flex; flex-direction: column-reverse; gap: var(--space-2);
838
+ pointer-events: none;
839
+ }
840
+ .ds-ep-toast {
841
+ pointer-events: auto;
842
+ display: flex; align-items: center; gap: var(--space-3);
843
+ background: var(--bg-2); color: var(--fg);
844
+ border: 1px solid var(--rule);
845
+ border-radius: var(--r-2);
846
+ padding: var(--space-3) var(--space-4);
847
+ max-width: 320px;
848
+ box-shadow: var(--shadow-overlay);
849
+ font-size: var(--fs-sm);
850
+ }
851
+ @media (prefers-reduced-motion: no-preference) {
852
+ .ds-ep-toast { animation: toast-slide-in 0.3s ease-out; }
853
+ .ds-ep-toast.leaving { animation: toast-slide-in 0.2s ease-in reverse; }
854
+ }
855
+ .ds-ep-toast.kind-error { border-color: var(--danger, var(--warn)); }
856
+ .ds-ep-toast.kind-success { border-color: var(--green-2); }
857
+ .ds-ep-toast.kind-warning { border-color: var(--amber); }
858
+ .ds-ep-toast-msg { flex: 1; }
859
+ .ds-ep-toast-action {
860
+ flex-shrink: 0;
861
+ background: transparent; border: 0; color: var(--accent-ink);
862
+ font: inherit; font-weight: 600; cursor: pointer; padding: 0;
863
+ }
864
+ .ds-ep-toast-action:hover { text-decoration: underline; }
865
+
866
+ @media (max-width: 480px) {
867
+ .ds-ep-toast-host { bottom: 16px; right: 16px; left: 16px; }
868
+ .ds-ep-toast { max-width: none; }
869
+ }
@@ -80,6 +80,7 @@ body.canvas-host { background: transparent !important; }
80
80
  .app-topbar > .brand { flex: 0 0 auto; }
81
81
  .app-topbar > .app-search { flex: 1 1 auto; }
82
82
  .app-topbar > nav { margin-left: auto; }
83
+ .app-topbar > .app-topbar-theme { flex: 0 0 auto; }
83
84
  .app-topbar nav { display: flex; gap: 4px; font-size: var(--fs-sm); flex-wrap: wrap; }
84
85
  .app-topbar nav a { flex: 0 0 auto; }
85
86
  .app-topbar nav a {
@@ -58,6 +58,8 @@ html, body {
58
58
  color: var(--os-fg);
59
59
  font: var(--fs-tiny) var(--os-font);
60
60
  height: var(--os-bar-h);
61
+ /* prefers-reduced-transparency handled below (near bottom of this file):
62
+ drops the blur + backing translucency to a solid --os-bg-2 fill. */
61
63
  padding: 0 var(--space-2-75);
62
64
  gap: var(--space-1-75);
63
65
  box-shadow: none;
@@ -1099,6 +1101,25 @@ html.ds-247420 { touch-action: pan-x pan-y; overscroll-behavior: none; -webkit-t
1099
1101
  .ds-247420 .tb-sess-overlay { backdrop-filter: none; -webkit-backdrop-filter: none; }
1100
1102
  }
1101
1103
 
1104
+ /* -----------------------------------------------------------------------
1105
+ prefers-reduced-transparency — distinct from prefers-reduced-motion
1106
+ above: a user can want full animation but zero see-through/blur panel
1107
+ chrome (vestibular-motion sensitivity vs. low-vision/legibility need are
1108
+ different accessibility needs, so this is its own media query, not folded
1109
+ into the motion one). Every backdrop-blur/translucent-panel effect in this
1110
+ kit drops to a solid, fully-opaque backing fill — the blur adds nothing
1111
+ once nothing shows through it, so it is dropped alongside the alpha for
1112
+ one less GPU compositing pass, not just left as dead blur-on-opaque.
1113
+ ----------------------------------------------------------------------- */
1114
+ @media (prefers-reduced-transparency: reduce) {
1115
+ .os-menubar, .os-taskbar {
1116
+ background: var(--os-bg-2);
1117
+ backdrop-filter: none;
1118
+ -webkit-backdrop-filter: none;
1119
+ }
1120
+ .ds-247420 .tb-sess-overlay { background: var(--scrim-strong); backdrop-filter: none; -webkit-backdrop-filter: none; }
1121
+ }
1122
+
1102
1123
  /* -----------------------------------------------------------------------
1103
1124
  * Freddie chat — jank pass 3 (2026-05-31)
1104
1125
  * Pins layout, spacing, focus, scrollbar, role-distinct bubbles, focus ring,
package/src/kits/os/wm.js CHANGED
@@ -11,6 +11,38 @@
11
11
 
12
12
  import { icons } from './icons.js';
13
13
 
14
+ // Shared aria-live announcer for window open/close/focus-change events.
15
+ // Visually hidden, one instance per document, lazily created so importing
16
+ // this module has no side effect until a window actually renders. Screen
17
+ // readers get no other signal that a floating, non-modal window opened,
18
+ // closed, or changed focus -- there is no page navigation or route change
19
+ // to announce it implicitly, unlike a normal document flow.
20
+ let _announcer = null;
21
+ function getAnnouncer() {
22
+ if (_announcer && _announcer.isConnected) return _announcer;
23
+ _announcer = document.getElementById('wm-announcer');
24
+ if (_announcer) return _announcer;
25
+ _announcer = document.createElement('div');
26
+ _announcer.id = 'wm-announcer';
27
+ _announcer.setAttribute('aria-live', 'polite');
28
+ _announcer.setAttribute('aria-atomic', 'true');
29
+ _announcer.className = 'sr-only';
30
+ // Inline fallback in case the consuming page's stylesheet doesn't define
31
+ // .sr-only (this module has no guaranteed CSS import of its own) --
32
+ // standard clip-based visually-hidden-but-AT-visible technique.
33
+ _announcer.style.cssText = 'position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0;';
34
+ document.body.appendChild(_announcer);
35
+ return _announcer;
36
+ }
37
+ function announce(text) {
38
+ const a = getAnnouncer();
39
+ // Clear-then-set on a microtask forces a re-announcement even if the
40
+ // text is identical to what's already there (e.g. focusing the same
41
+ // window twice in a row) -- aria-live only fires on a DOM mutation.
42
+ a.textContent = '';
43
+ requestAnimationFrame(() => { a.textContent = text; });
44
+ }
45
+
14
46
  export function renderWindow(opts = {}) {
15
47
  const {
16
48
  title = 'window',
@@ -127,11 +159,15 @@ export function renderWindow(opts = {}) {
127
159
  return;
128
160
  }
129
161
  disarmClose();
162
+ announce('closed ' + titleEl.textContent);
130
163
  callbacks.onClose && callbacks.onClose();
131
164
  });
132
165
  closeBtn.addEventListener('blur', disarmClose);
133
166
 
134
- const focus = () => callbacks.onFocus && callbacks.onFocus();
167
+ const focus = () => {
168
+ if (!el.classList.contains('wm-focused')) announce(titleEl.textContent + ' focused');
169
+ callbacks.onFocus && callbacks.onFocus();
170
+ };
135
171
 
136
172
  el.addEventListener('pointerdown', () => focus());
137
173
 
@@ -172,6 +208,7 @@ export function renderWindow(opts = {}) {
172
208
  applyFocused(el, focused);
173
209
  applyMaximized(el, maximized);
174
210
  applyMinimized(el, minimized);
211
+ announce('opened ' + title);
175
212
 
176
213
  return {
177
214
  el,
@@ -80,9 +80,64 @@ function examplesNode(examples) {
80
80
  });
81
81
  }
82
82
 
83
- function panelNode(panel, idx) {
84
- const items = Array.isArray(panel.items) ? panel.items : [];
85
- if (!items.length) return null;
83
+ // Category glyph per homepage panel id — the same line-icon vocabulary
84
+ // icons.js already provides, so kits/previews/decks/docs read as distinct
85
+ // categories at a glance instead of by text label alone. Falls back to no
86
+ // icon for panel ids outside this table (e.g. one-off feature panels).
87
+ const PANEL_ICON = {
88
+ kits: 'grid',
89
+ file_browser: 'folder',
90
+ desktop_os: 'square',
91
+ web_components: 'page',
92
+ api_exports: 'link',
93
+ decks: 'screen',
94
+ docs: 'file-text',
95
+ previews: 'eye',
96
+ features: 'info',
97
+ };
98
+
99
+ // Filter state for the kits search box above the "ui kits" panel. Reuses
100
+ // ui_kits/search's own query-bar pattern (plain module-level state object,
101
+ // mutated on input, driving a re-render) rather than inventing a second
102
+ // filtering mechanism -- this homepage already ships that kit, so the
103
+ // homepage's own kit listing gets the same affordance instead of being the
104
+ // one surface on the site without a way to search kits by name.
105
+ const kitsFilterState = { q: '' };
106
+
107
+ function panelNode(panel, idx, rerender) {
108
+ let items = Array.isArray(panel.items) ? panel.items : [];
109
+ const isKits = panel.id === 'kits';
110
+ const q = isKits ? kitsFilterState.q.trim().toLowerCase() : '';
111
+ if (isKits && q) {
112
+ items = items.filter((it) => {
113
+ const hay = (String(it.title || it.name || '') + ' ' + String(it.sub || it.desc || '')).toLowerCase();
114
+ return hay.includes(q);
115
+ });
116
+ }
117
+ const iconName = panel.id && PANEL_ICON[panel.id];
118
+ const titleText = panel.title || panel.name || '';
119
+ const titleNode = iconName && titleText
120
+ ? h('span', { class: 'ds-panel-title-glyph' }, C.Icon(iconName, { size: 15 }), h('span', {}, titleText))
121
+ : titleText;
122
+ const filterInput = isKits && rerender ? h('div', { class: 'ds-kits-filter' },
123
+ h('input', {
124
+ type: 'search', class: 'input ds-kits-filter-input',
125
+ placeholder: 'filter kits by name or description…',
126
+ value: kitsFilterState.q,
127
+ 'aria-label': 'filter ui kits',
128
+ oninput: (e) => { kitsFilterState.q = e.target.value; rerender(); },
129
+ })) : null;
130
+ if (!items.length) {
131
+ if (isKits && q) {
132
+ return h('div', { class: 'ds-kits-panel-wrap' }, filterInput,
133
+ C.Panel({ id: panel.id || null, title: titleNode, count: 0, children:
134
+ h('div', { class: 'ds-empty-state' },
135
+ h('div', { class: 'ds-empty-state-glyph' }, '( )'),
136
+ h('p', { class: 'ds-empty-state-msg' }, 'no kits match ', h('code', {}, '"' + kitsFilterState.q.trim() + '"')),
137
+ ) }));
138
+ }
139
+ return filterInput ? h('div', { class: 'ds-kits-panel-wrap' }, filterInput) : null;
140
+ }
86
141
  const rows = items.map((it, i) => {
87
142
  const code = it.code == null ? '' : String(it.code).trim();
88
143
  const kids = [];
@@ -92,7 +147,8 @@ function panelNode(panel, idx) {
92
147
  kids.push(h('span', { key: 'a', class: 'ds-row-arrow' }, it.meta || '->'));
93
148
  return h('a', { key: i, class: 'row', href: it.href || '#' }, ...kids);
94
149
  });
95
- return C.Panel({ id: panel.id || null, title: panel.title || panel.name || '', count: panel.count || items.length, children: rows });
150
+ const panelEl = C.Panel({ id: panel.id || null, title: titleNode, count: items.length, children: rows });
151
+ return filterInput ? h('div', { class: 'ds-kits-panel-wrap' }, filterInput, panelEl) : panelEl;
96
152
  }
97
153
 
98
154
  function marqueeNode(marquee) {
@@ -196,8 +252,14 @@ const bodyNode = data.bodyHtml ? C.Section({ children: h('div', { class: 'page-b
196
252
  function footerNode() {
197
253
  const year = new Date().getFullYear();
198
254
  const links = (data.navItems || []).filter(([, href]) => /^https?:/.test(String(href)));
255
+ // Names the actual maintaining entity, not just the project's numeric
256
+ // brand. This is a personal/community project (MIT, copyright holder
257
+ // "AnEntrypoint" per LICENSE) with no separate legal org behind it, so the
258
+ // footer says that plainly instead of leaving "247420" to stand in for an
259
+ // entity it isn't.
260
+ const entity = (data.seoAuthor || '247420 · a design system by AnEntrypoint').toLowerCase();
199
261
  return h('footer', { class: 'ds-page-footer' },
200
- h('span', { class: 'ds-page-footer-copy' }, String(year) + ' · ' + data.siteName),
262
+ h('span', { class: 'ds-page-footer-copy' }, String(year) + ' · ' + entity),
201
263
  links.length ? h('nav', { class: 'ds-page-footer-links', 'aria-label': 'footer' },
202
264
  ...links.map(([label, href], i) => h('a', { key: i, href }, String(label).replace(' ->', '')))
203
265
  ) : null,
@@ -215,40 +277,46 @@ function tierNode(tier, children) {
215
277
  return h('section', { key: tier.key, class: 'ds-tier ds-tier-' + tier.key, id: tier.key, 'aria-labelledby': labelId }, head, ...kids);
216
278
  }
217
279
 
218
- const panelsById = new Map((data.panels || []).map((p) => [p.id || p.title || p.name || '', p]));
219
- const takePanel = (id) => { const p = panelsById.get(id); if (p) panelsById.delete(id); return p ? panelNode(p) : null; };
220
-
221
280
  const TIERS = [
222
281
  { key: 'open', label: 'open', lede: 'browse and try the system running.', ids: ['kits', 'previews', 'decks'], extra: () => [examplesNode(data.examples)] },
223
282
  { key: 'ships', label: 'ships', lede: 'what the package contains.', ids: ['file_browser', 'desktop_os', 'web_components', 'api_exports'], extra: () => [] },
224
283
  { key: 'read', label: 'read', lede: 'understand the rules behind it.', ids: ['docs', 'features'], extra: () => [quickstartNode(data.quickstart)] },
225
284
  ];
226
285
 
227
- const tierNodes = TIERS.map((t) => {
228
- const kids = [...t.ids.map(takePanel), ...t.extra()].filter(Boolean);
229
- if (t.key === 'open' && kids.length) {
230
- const lead = kids[0];
231
- if (lead.props) lead.props.class = (lead.props.class || '') + ' ds-tier-lead';
232
- }
233
- return tierNode(t, kids);
234
- });
235
- const leftoverPanels = [...panelsById.values()].map(panelNode);
286
+ // Builds the whole main-content tree fresh each render so the kits filter
287
+ // (module-level kitsFilterState) can drive a real re-render via mount()'s
288
+ // own returned render callback -- same reactive shape mountKit() gives every
289
+ // other kit on the site, applied here to the static-page renderer.
290
+ function buildMainChildren(rerender) {
291
+ const panelsById = new Map((data.panels || []).map((p) => [p.id || p.title || p.name || '', p]));
292
+ const takePanel = (id) => { const p = panelsById.get(id); if (p) panelsById.delete(id); return p ? panelNode(p, 0, rerender) : null; };
293
+
294
+ const tierNodes = TIERS.map((t) => {
295
+ const kids = [...t.ids.map(takePanel), ...t.extra()].filter(Boolean);
296
+ if (t.key === 'open' && kids.length) {
297
+ const lead = kids[0];
298
+ if (lead.props) lead.props.class = (lead.props.class || '') + ' ds-tier-lead';
299
+ }
300
+ return tierNode(t, kids);
301
+ });
302
+ const leftoverPanels = [...panelsById.values()].map((p) => panelNode(p, 0, rerender));
236
303
 
237
- const mainChildren = [
238
- heroNode(data.hero),
239
- marqueeNode(data.marquee),
240
- ...data.sections.map(sectionNode),
241
- ...tierNodes,
242
- ...leftoverPanels,
243
- bodyNode,
244
- footerNode(),
245
- ].filter(Boolean);
304
+ return [
305
+ heroNode(data.hero),
306
+ marqueeNode(data.marquee),
307
+ ...data.sections.map(sectionNode),
308
+ ...tierNodes,
309
+ ...leftoverPanels,
310
+ bodyNode,
311
+ footerNode(),
312
+ ].filter(Boolean);
313
+ }
246
314
 
247
- mount(document.getElementById('app'), () => C.AppShell({
315
+ mount(document.getElementById('app'), (rerender) => C.AppShell({
248
316
  topbar: C.Topbar({ brand: data.siteName, items: data.navItems, active: data.title }),
249
317
  crumb: C.Crumb({ leaf: data.title }),
250
318
  side: sideNode(data.sidebar),
251
- main: h('div', { class: 'app-stage' }, ...mainChildren),
319
+ main: h('div', { class: 'app-stage' }, ...buildMainChildren(rerender)),
252
320
  status: C.Status({
253
321
  left: data.statusLeft || [data.siteName.toLowerCase(), data.slug],
254
322
  right: data.statusRight || ['live'],
package/src/page-html.js CHANGED
@@ -82,13 +82,14 @@ export function renderPageHtml({
82
82
  quickstart: quickstart || null,
83
83
  statusLeft: Array.isArray(statusLeft) ? statusLeft : null,
84
84
  statusRight: Array.isArray(statusRight) ? statusRight : null,
85
+ seoAuthor: seo && seo.author ? seo.author : null,
85
86
  };
86
87
 
87
88
  const seoTags = seo ? renderSeoTags({ title, siteName, seo }) : '';
88
89
  const faviconTags = renderFaviconTags({ faviconHref, faviconGlyph });
89
90
 
90
91
  return `<!doctype html>
91
- <html lang="en" class="ds-247420">
92
+ <html lang="en" class="ds-247420" data-theme="${theme}">
92
93
  <head>
93
94
  <meta charset="utf-8">
94
95
  <meta name="viewport" content="width=device-width, initial-scale=1">
@@ -105,7 +106,7 @@ ${PAGE_INLINE_STYLES}
105
106
  <script id="__site__" type="application/json">${JSON.stringify(pageData).replace(/</g, '\\u003c')}</script>
106
107
  ${headExtra}
107
108
  </head>
108
- <body data-theme="${theme}">
109
+ <body>
109
110
  <div id="app"></div>
110
111
  <script type="module">
111
112
  ${CLIENT_SCRIPT}${clientScriptExtra}
package/src/theme.js CHANGED
@@ -43,7 +43,11 @@ function writeStored(mode) {
43
43
 
44
44
  function writeAttr(mode) {
45
45
  if (!isBrowser()) return;
46
- document.body.setAttribute('data-theme', mode);
46
+ // data-theme must live on the same element carrying the .ds-247420 scope
47
+ // class (<html>, per every shipped page template) -- the bundled CSS
48
+ // selectors are `.ds-247420[data-theme="ink"]`, which cannot match if
49
+ // the attribute sits on <body> instead of <html>.
50
+ document.documentElement.setAttribute('data-theme', mode);
47
51
  }
48
52
 
49
53
  function ensureMq() {
@@ -169,7 +173,7 @@ export function initDirection() {
169
173
  export function initTheme() {
170
174
  if (!isBrowser()) return 'auto';
171
175
  const stored = readStored();
172
- const fromAttr = document.body.getAttribute('data-theme');
176
+ const fromAttr = document.documentElement.getAttribute('data-theme');
173
177
  const initial = stored || (VALID.has(fromAttr) ? fromAttr : 'auto');
174
178
  applyTheme(initial);
175
179
  // Restore persisted accent/density (no-op if none stored — keeps the
@@ -28,6 +28,7 @@ class DsChat extends HTMLElement {
28
28
  this._placeholder = val || '';
29
29
  } else if (name === 'title') {
30
30
  this._title = val || 'chat';
31
+ if (!this.hasAttribute('aria-label') || this._ariaLabelAuto) { this.setAttribute('aria-label', this._title); this._ariaLabelAuto = true; }
31
32
  } else if (name === 'sub') {
32
33
  this._sub = val || '';
33
34
  } else if (name === 'disabled') {
@@ -47,6 +48,14 @@ class DsChat extends HTMLElement {
47
48
  get disabled() { return this._disabled; }
48
49
  connectedCallback() {
49
50
  this.classList.add('ds-247420');
51
+ // Accessible name for the custom element itself: <ds-chat> has no
52
+ // implicit ARIA role/name, so a screen reader landmark/element list
53
+ // shows an unlabeled item without this. role="region" + aria-label
54
+ // (falling back to the chat title) makes it identifiable on its own,
55
+ // separate from the inner .chat-thread's role="log"/aria-live, which
56
+ // announces individual streamed messages rather than naming the widget.
57
+ if (!this.hasAttribute('role')) this.setAttribute('role', 'region');
58
+ if (!this.hasAttribute('aria-label')) this.setAttribute('aria-label', this._title || 'chat');
50
59
  this._render();
51
60
  }
52
61
  _send(text) {
@@ -141,6 +141,8 @@ export interface TopbarProps {
141
141
  active?: string;
142
142
  onNav?: (...args: any[]) => any;
143
143
  search?: any;
144
+ /** @default true */
145
+ themeToggle?: boolean;
144
146
  }
145
147
  export declare function Topbar(props?: TopbarProps): VNode;
146
148