anentrypoint-design 0.0.414 → 0.0.415

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 (102) hide show
  1. package/dist/247420.js +62 -12
  2. package/dist/app-shell.css +40 -594
  3. package/dist/colors_and_type.css +607 -47
  4. package/dist/index.html +227 -277
  5. package/dist/preview/buttons.html +39 -28
  6. package/dist/preview/colors-core.html +18 -8
  7. package/dist/preview/colors-lore.html +26 -17
  8. package/dist/preview/colors-semantic.html +24 -18
  9. package/dist/preview/dateline.html +6 -5
  10. package/dist/preview/dropzone.html +4 -3
  11. package/dist/preview/file-grid.html +10 -9
  12. package/dist/preview/file-row.html +14 -13
  13. package/dist/preview/file-toolbar.html +4 -3
  14. package/dist/preview/file-viewer.html +4 -3
  15. package/dist/preview/header.html +26 -24
  16. package/dist/preview/icons-unicode.html +8 -5
  17. package/dist/preview/index-row.html +17 -9
  18. package/dist/preview/inputs.html +4 -3
  19. package/dist/preview/manifesto.html +34 -17
  20. package/dist/preview/motion-default.js +77 -57
  21. package/dist/preview/rules.html +4 -3
  22. package/dist/preview/spacing.html +13 -12
  23. package/dist/preview/stamps-lore.html +5 -4
  24. package/dist/preview/stamps.html +4 -3
  25. package/dist/preview/theme-ink.html +7 -6
  26. package/dist/preview/type-display.html +14 -5
  27. package/dist/preview/type-mono.html +6 -5
  28. package/dist/preview/type-prose.html +12 -10
  29. package/dist/preview/type-scale.html +4 -3
  30. package/dist/preview/wordmarks.html +10 -7
  31. package/dist/site/content/globals/site.yaml +5 -3
  32. package/dist/site/content/pages/freddie.yaml +2 -2
  33. package/dist/site/content/pages/home.yaml +55 -55
  34. package/dist/site/theme.mjs +96 -348
  35. package/dist/slides/index.html +13 -13
  36. package/dist/src/bootstrap.js +10 -1
  37. package/dist/src/components/chat.js +43 -195
  38. package/dist/src/components/community.js +18 -166
  39. package/dist/src/components/content.js +30 -210
  40. package/dist/src/components/files-modals.js +14 -106
  41. package/dist/src/components/files.js +15 -116
  42. package/dist/src/components/freddie/helpers.js +53 -8
  43. package/dist/src/components/freddie.js +35 -25
  44. package/dist/src/components/shell.js +16 -114
  45. package/dist/src/components/theme-toggle.js +69 -25
  46. package/dist/src/components.js +92 -13
  47. package/dist/src/deck-stage.js +6 -4
  48. package/dist/src/highlight.js +51 -7
  49. package/dist/src/index.js +58 -12
  50. package/dist/src/kits/os/about-app.js +2 -3
  51. package/dist/src/kits/os/app-panes.css +46 -70
  52. package/dist/src/kits/os/browser-app.js +28 -1
  53. package/dist/src/kits/os/files-app.js +7 -0
  54. package/dist/src/kits/os/freddie/pages-chat.js +90 -75
  55. package/dist/src/kits/os/freddie/pages-core.js +9 -9
  56. package/dist/src/kits/os/freddie/pages-os.js +8 -8
  57. package/dist/src/kits/os/freddie/pages-tools.js +14 -14
  58. package/dist/src/kits/os/freddie/routes.js +21 -19
  59. package/dist/src/kits/os/freddie-dashboard.css +42 -21
  60. package/dist/src/kits/os/freddie-dashboard.js +6 -6
  61. package/dist/src/kits/os/icons.js +38 -16
  62. package/dist/src/kits/os/index.js +11 -0
  63. package/dist/src/kits/os/launcher.css +26 -10
  64. package/dist/src/kits/os/launcher.js +3 -0
  65. package/dist/src/kits/os/shell.js +72 -108
  66. package/dist/src/kits/os/terminal-app.js +1 -1
  67. package/dist/src/kits/os/theme.css +1330 -150
  68. package/dist/src/kits/os/wm.css +68 -15
  69. package/dist/src/kits/os/wm.js +93 -17
  70. package/dist/src/markdown.js +121 -12
  71. package/dist/src/motion.js +18 -2
  72. package/dist/src/page-html.js +62 -134
  73. package/dist/src/theme.js +106 -13
  74. package/dist/src/web-components/ds-chat.js +80 -9
  75. package/dist/ui_kits/aicat/app.js +54 -6
  76. package/dist/ui_kits/aicat/index.html +2 -0
  77. package/dist/ui_kits/blog/index.html +33 -17
  78. package/dist/ui_kits/chat/app.js +90 -22
  79. package/dist/ui_kits/chat/index.html +2 -0
  80. package/dist/ui_kits/community/app.js +86 -24
  81. package/dist/ui_kits/community/index.html +1 -0
  82. package/dist/ui_kits/dashboard/app.js +123 -34
  83. package/dist/ui_kits/dashboard/index.html +1 -0
  84. package/dist/ui_kits/docs/index.html +43 -33
  85. package/dist/ui_kits/error_404/app.js +78 -23
  86. package/dist/ui_kits/file_browser/README.md +1 -1
  87. package/dist/ui_kits/file_browser/app.js +45 -10
  88. package/dist/ui_kits/file_browser/index.html +1 -0
  89. package/dist/ui_kits/gallery/app.js +130 -51
  90. package/dist/ui_kits/homepage/app.js +156 -138
  91. package/dist/ui_kits/project_page/app.js +142 -125
  92. package/dist/ui_kits/project_page/index.html +2 -1
  93. package/dist/ui_kits/search/app.js +52 -15
  94. package/dist/ui_kits/settings/app.js +166 -39
  95. package/dist/ui_kits/signin/app.js +175 -49
  96. package/dist/ui_kits/signin/index.html +1 -0
  97. package/dist/ui_kits/slide_deck/app.js +83 -49
  98. package/dist/ui_kits/system_primer/app.js +57 -40
  99. package/dist/ui_kits/terminal/app.js +160 -43
  100. package/package.json +1 -1
  101. package/src/page-html/client-script.js +40 -11
  102. package/src/page-html/page-styles.js +22 -1
@@ -15,57 +15,53 @@
15
15
  // theme: 'auto' | 'light' | 'ink',
16
16
  // cssHref, headExtra,
17
17
  // })
18
+ //
19
+ // This module is a barrel over ./page-html/: the server-side markdown+href
20
+ // helpers, the <head> tag builders, the inline <style> block, and the client
21
+ // mount script string. The public export surface here is unchanged — no
22
+ // consumer import needs to move.
18
23
 
19
- export function escape(s) {
20
- return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
21
- }
22
-
23
- export function inlineMd(s) {
24
- return s
25
- .replace(/`([^`]+)`/g, '<code>$1</code>')
26
- .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
27
- .replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
28
- }
24
+ import { escape, inlineMd, slugify, renderMarkdown, joinHref } from './page-html/markdown.js';
25
+ import { renderSeoTags, renderFaviconTags, renderCssLink } from './page-html/head-tags.js';
26
+ import { PAGE_INLINE_STYLES } from './page-html/page-styles.js';
27
+ import { CLIENT_SCRIPT } from './page-html/client-script.js';
29
28
 
30
- export function renderMarkdown(md) {
31
- const lines = String(md || '').split('\n');
32
- const out = [];
33
- let inCode = false, inList = false;
34
- for (const line of lines) {
35
- if (line.startsWith('```')) { if (inCode) { out.push('</pre>'); inCode = false; } else { out.push('<pre>'); inCode = true; } continue; }
36
- if (inCode) { out.push(escape(line)); continue; }
37
- if (line.startsWith('# ')) out.push(`<h1>${escape(line.slice(2))}</h1>`);
38
- else if (line.startsWith('## ')) out.push(`<h2>${escape(line.slice(3))}</h2>`);
39
- else if (line.startsWith('### ')) out.push(`<h3>${escape(line.slice(4))}</h3>`);
40
- else if (line.startsWith('- ')) { if (!inList) { out.push('<ul>'); inList = true; } out.push(`<li>${inlineMd(escape(line.slice(2)))}</li>`); }
41
- else { if (inList) { out.push('</ul>'); inList = false; } if (line.trim()) out.push(`<p>${inlineMd(escape(line))}</p>`); }
42
- }
43
- if (inList) out.push('</ul>');
44
- if (inCode) out.push('</pre>');
45
- return out.join('\n');
46
- }
47
-
48
- // Join a basePath prefix to a nav href. Absolute URLs and hash links pass
49
- // through unchanged; leading-slash paths get the prefix.
50
- function joinHref(basePath, href) {
51
- if (!href) return '#';
52
- const h = String(href);
53
- if (/^([a-z]+:|#|\/\/)/i.test(h)) return h;
54
- if (!basePath) return h;
55
- const base = basePath.replace(/\/+$/, '');
56
- if (h.startsWith('/')) return base + h;
57
- return base + '/' + h.replace(/^\.?\//, '');
58
- }
29
+ export { escape, inlineMd, slugify, renderMarkdown };
59
30
 
60
31
  export function renderPageHtml({
61
32
  title = '247420', slug = 'index', siteName = '247420',
62
33
  navItems = [], basePath = '',
63
34
  hero, sections, examples, body,
64
- theme = 'auto', cssHref, headExtra = ''
35
+ theme = 'auto', cssHref, headExtra = '',
36
+ // Extended affordances (all optional, all backward compatible — a call
37
+ // site that omits them gets byte-identical output to before these were
38
+ // added). See design/site/theme.mjs and thebird/site/theme.mjs for
39
+ // consumers of the full surface.
40
+ seo = null, // { description, keywords, author, twitter, locale, lang, image, url, glyph, ldJson:boolean }
41
+ sidebar = null, // { sections: [{ group, items: [{glyph,label,href}] }] } -> C.Side
42
+ marquee = null, // { items: [...strings], sep }
43
+ panels = null, // [{ id, title, count, items: [{code,title,sub,meta,href}] }] -> C.Panel + RowLink rows
44
+ quickstart = null, // { heading, lines: [{ kind, text }] } -> cli block panel
45
+ statusLeft = null, // override the default [siteName.toLowerCase(), slug] status-bar left cluster
46
+ statusRight = null, // override the default ['live'] status-bar right cluster
47
+ faviconHref = null, // static favicon URL (e.g. './favicon.svg'); takes precedence over faviconGlyph
48
+ faviconGlyph = null, // single-character/emoji favicon rendered as an inline data: SVG
49
+ clientScriptExtra = '', // raw JS appended after the mount() call in the client <script type="module">
50
+ version = null, // pin BOTH the CSS href and the JS importmap to this exact version
51
+ // instead of @latest (e.g. '0.0.320'); omitted -> default @latest behavior.
52
+ // Fleet policy is @latest everywhere so a published fix reaches every
53
+ // consumer without redeploying it: passing this pins EVERY page this
54
+ // call generates, and a pinned page silently stops receiving fixes.
65
55
  } = {}) {
66
- const cssLink = cssHref
67
- ? `<link rel="stylesheet" href="${cssHref}">`
68
- : `<link rel="stylesheet" href="https://unpkg.com/anentrypoint-design@latest/dist/247420.css">`;
56
+ if (version != null && process.env.ANENTRYPOINT_ALLOW_PIN !== '1') {
57
+ throw new Error(
58
+ `renderPageHtml({version: '${version}'}) pins every generated page to one release, ` +
59
+ 'which opts the whole surface out of published fixes. Fleet policy is @latest. ' +
60
+ 'Set ANENTRYPOINT_ALLOW_PIN=1 to override deliberately.'
61
+ );
62
+ }
63
+ const pkgVersion = version || 'latest';
64
+ const cssLink = renderCssLink({ cssHref, pkgVersion });
69
65
 
70
66
  // Resolve nav hrefs server-side against basePath. Client receives final URLs.
71
67
  const navResolved = (Array.isArray(navItems) ? navItems : []).map(([label, href]) =>
@@ -80,22 +76,40 @@ export function renderPageHtml({
80
76
  sections: Array.isArray(sections) ? sections : [],
81
77
  examples: Array.isArray(examples) ? examples : [],
82
78
  bodyHtml: body ? renderMarkdown(body) : '',
79
+ sidebar: sidebar || null,
80
+ marquee: marquee || null,
81
+ panels: Array.isArray(panels) ? panels : [],
82
+ quickstart: quickstart || null,
83
+ statusLeft: Array.isArray(statusLeft) ? statusLeft : null,
84
+ statusRight: Array.isArray(statusRight) ? statusRight : null,
83
85
  };
84
86
 
87
+ const seoTags = seo ? renderSeoTags({ title, siteName, seo }) : '';
88
+ const faviconTags = renderFaviconTags({ faviconHref, faviconGlyph });
89
+
90
+ // Theme attribute co-location is CORRECT here: dist/247420.css keys every
91
+ // theme block off the COMPOUND selector `.ds-247420[data-theme="X"]`
92
+ // (verified in dist/247420.css ~L229). That selector requires BOTH the class
93
+ // and the data-theme on the SAME node, so `<html class="ds-247420"
94
+ // data-theme=...>` is what the CSS expects for SSR. The AGENTS.md
95
+ // "descendant-selector" warning is about the dashboard's RUNTIME controller,
96
+ // which splits them (.ds-247420 on <html>, data-theme on <body>) and relies
97
+ // on inheritance — a different mechanism. Do NOT move data-theme to <body>
98
+ // here or the theme blocks stop matching.
85
99
  return `<!doctype html>
86
100
  <html lang="en" class="ds-247420" data-theme="${theme}">
87
101
  <head>
88
102
  <meta charset="utf-8">
89
103
  <meta name="viewport" content="width=device-width, initial-scale=1">
90
104
  <title>${escape(title)} — ${escape(siteName)}</title>
105
+ ${seoTags}
106
+ ${faviconTags}
91
107
  ${cssLink}
92
108
  <script type="importmap">
93
- { "imports": { "anentrypoint-design": "https://unpkg.com/anentrypoint-design@latest/dist/247420.js" } }
109
+ { "imports": { "anentrypoint-design": "https://unpkg.com/anentrypoint-design@${pkgVersion}/dist/247420.js" } }
94
110
  </script>
95
111
  <style>
96
- .app-stage { max-width: 1100px; margin: 0 auto; padding: 24px; display: grid; gap: 24px }
97
- .page-body h1 { margin-top: 0 } .page-body h2 { margin-top: 32px } .page-body h3 { margin-top: 24px }
98
- .page-body pre { margin: 12px 0; background: var(--panel-2); padding: 12px; border-radius: 8px; overflow-x: auto }
112
+ ${PAGE_INLINE_STYLES}
99
113
  </style>
100
114
  <script id="__site__" type="application/json">${JSON.stringify(pageData).replace(/</g, '\\u003c')}</script>
101
115
  ${headExtra}
@@ -103,93 +117,7 @@ ${headExtra}
103
117
  <body>
104
118
  <div id="app"></div>
105
119
  <script type="module">
106
- import { mount, components as C, h } from 'anentrypoint-design';
107
- const data = JSON.parse(document.getElementById('__site__').textContent);
108
- const RAILS = ['rail-green', 'rail-purple', 'rail-mascot', 'rail-sun', 'rail-flame', 'rail-sky'];
109
-
110
- function heroNode(hero) {
111
- if (!hero) return null;
112
- return C.Hero({
113
- eyebrow: hero.eyebrow,
114
- title: hero.heading || hero.title || data.title,
115
- body: hero.body || hero.subheading || '',
116
- accent: hero.accent,
117
- badge: Array.isArray(hero.badges) && hero.badges[0] ? hero.badges[0].label : undefined,
118
- actions: Array.isArray(hero.ctas) ? hero.ctas.map((c, i) => h('a', { key: i, class: i === 0 ? 'btn btn-accent' : 'btn btn-ghost', href: c.href || '#' }, c.label || c.cta || 'go')) : null,
119
- });
120
- }
121
-
122
- function sectionNode(sec, idx) {
123
- const rail = RAILS[idx % RAILS.length];
124
- const features = sec.features || sec.items || [];
125
- const rows = features.map((f, i) => {
126
- const kids = [h('span', { key: 't', class: 'title' }, String(f.name || ''))];
127
- if (f.desc) kids.push(h('div', { key: 'd', class: 'sub', innerHTML: String(f.desc).replace(/\`([^\`]+)\`/g, '<code>$1</code>') }));
128
- if (f.benefit) kids.push(h('div', { key: 'b', class: 'row-benefit' }, String(f.benefit)));
129
- return h('div', { key: i, class: 'row ' + rail }, ...kids);
130
- });
131
- return C.Section({
132
- title: sec.name || sec.title || sec.id,
133
- children: [
134
- sec.lede ? h('p', { class: 'ds-lede' }, sec.lede) : null,
135
- ...rows,
136
- sec.body && sec.body.length >= 240 ? h('div', { class: 'page-body', innerHTML: __md(sec.body) }) : null,
137
- ].filter(Boolean),
138
- });
139
- }
140
-
141
- function examplesNode(examples) {
142
- if (!examples || !examples.length) return null;
143
- return C.Section({
144
- title: 'explore',
145
- children: examples.map((e, i) => {
146
- const rail = RAILS[(i + 1) % RAILS.length];
147
- const kids = [
148
- h('span', { key: 'c', class: 'code' }, String(i + 1).padStart(2, '0')),
149
- h('span', { key: 't', class: 'title' }, String(e.label || e.name || e.href || '')),
150
- ];
151
- if (e.desc) kids.push(h('span', { key: 'm', class: 'meta dim' }, ' — ' + e.desc));
152
- kids.push(h('span', { key: 'a', class: 'ds-row-arrow' }, '↗'));
153
- return h('a', { key: i, class: 'row ' + rail, href: e.href || '#' }, ...kids);
154
- }),
155
- });
156
- }
157
-
158
- // minimal client-side markdown renderer matching server-side renderer (idempotent for already-html bodies)
159
- function __md(md) {
160
- const lines = String(md || '').split('\\n');
161
- const out = []; let inCode = false, inList = false;
162
- const esc = (s) => String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
163
- const inl = (s) => s.replace(/\`([^\`]+)\`/g, '<code>$1</code>').replace(/\\*\\*([^*]+)\\*\\*/g, '<strong>$1</strong>').replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g, '<a href="$2">$1</a>');
164
- for (const line of lines) {
165
- if (line.startsWith('\`\`\`')) { if (inCode) { out.push('</pre>'); inCode = false; } else { out.push('<pre>'); inCode = true; } continue; }
166
- if (inCode) { out.push(esc(line)); continue; }
167
- if (line.startsWith('# ')) out.push('<h1>' + esc(line.slice(2)) + '</h1>');
168
- else if (line.startsWith('## ')) out.push('<h2>' + esc(line.slice(3)) + '</h2>');
169
- else if (line.startsWith('### ')) out.push('<h3>' + esc(line.slice(4)) + '</h3>');
170
- else if (line.startsWith('- ')) { if (!inList) { out.push('<ul>'); inList = true; } out.push('<li>' + inl(esc(line.slice(2))) + '</li>'); }
171
- else { if (inList) { out.push('</ul>'); inList = false; } if (line.trim()) out.push('<p>' + inl(esc(line)) + '</p>'); }
172
- }
173
- if (inList) out.push('</ul>');
174
- if (inCode) out.push('</pre>');
175
- return out.join('\\n');
176
- }
177
-
178
- const bodyNode = data.bodyHtml ? C.Section({ children: h('div', { class: 'page-body', innerHTML: data.bodyHtml }) }) : null;
179
-
180
- const mainChildren = [
181
- heroNode(data.hero),
182
- ...data.sections.map(sectionNode),
183
- examplesNode(data.examples),
184
- bodyNode,
185
- ].filter(Boolean);
186
-
187
- mount(document.getElementById('app'), () => C.AppShell({
188
- topbar: C.Topbar({ brand: data.siteName, items: data.navItems, active: data.title }),
189
- crumb: C.Crumb({ leaf: data.title }),
190
- main: h('div', { class: 'app-stage' }, ...mainChildren),
191
- status: C.Status({ left: [data.siteName.toLowerCase(), data.slug], right: ['live'] }),
192
- }));
120
+ ${CLIENT_SCRIPT}${clientScriptExtra}
193
121
  </script>
194
122
  </body>
195
123
  </html>`;
package/dist/src/theme.js CHANGED
@@ -1,17 +1,27 @@
1
1
  // 247420 design system — theme controller.
2
2
  //
3
- // Three modes:
4
- // 'auto' — follow OS (prefers-color-scheme). Live-updates on OS change.
5
- // 'paper' — force light.
6
- // 'ink' — force dark.
3
+ // Theme modes (data-theme):
4
+ // 'auto' — follow OS (prefers-color-scheme). Live-updates on OS change.
5
+ // 'paper' — force light.
6
+ // 'ink' — force dark.
7
+ // 'thebird' — warm-paper brand preset (named theme).
8
+ // Accents (data-accent): 'green' | 'purple' | 'mascot'.
9
+ // Density (data-density): 'compact' | 'comfortable' | 'spacious'.
7
10
  //
8
- // Writes the chosen mode to <html data-theme="..."> so CSS rules in
9
- // system.css / colors_and_type.css resolve correctly. Persists to
10
- // localStorage under '247420:theme'. Auto-initializes on import in a
11
- // browser context; safe no-op on server.
11
+ // Each is one attribute on <html> the canonical theme (colors_and_type.css)
12
+ // reads. Adding a theme = one [data-theme="X"] block in colors_and_type.css
13
+ // plus its name in THEMES below. Persists to localStorage; auto-inits on
14
+ // browser import; safe no-op on server.
12
15
 
13
16
  const KEY = '247420:theme';
14
- const VALID = new Set(['auto', 'paper', 'ink']);
17
+ const ACCENT_KEY = '247420:accent';
18
+ const DENSITY_KEY = '247420:density';
19
+ const LOCALE_KEY = '247420:locale';
20
+ // 'auto' is a mode, not a [data-theme] preset block — it stays in VALID for the
21
+ // controller but is the OS-follow path. The named presets are the rest.
22
+ const VALID = new Set(['auto', 'paper', 'ink', 'thebird', 'github-dark']);
23
+ const VALID_ACCENT = new Set(['green', 'purple', 'mascot']);
24
+ const VALID_DENSITY = new Set(['compact', 'comfortable', 'spacious']);
15
25
  const listeners = new Set();
16
26
  let _mq = null;
17
27
  let _current = 'auto';
@@ -28,7 +38,7 @@ function readStored() {
28
38
  }
29
39
 
30
40
  function writeStored(mode) {
31
- try { window.localStorage.setItem(KEY, mode); } catch {}
41
+ try { window.localStorage.setItem(KEY, mode); } catch { /* swallow: persistence is best-effort, theme still applies in-memory */ }
32
42
  }
33
43
 
34
44
  function writeAttr(mode) {
@@ -44,7 +54,7 @@ function ensureMq() {
44
54
  // Re-emit so listeners can re-render derived UI even though
45
55
  // data-theme stays "auto" — the CSS @media handles the swap.
46
56
  for (const cb of listeners) {
47
- try { cb({ mode: 'auto', resolved: _mq.matches ? 'ink' : 'paper' }); } catch {}
57
+ try { cb({ mode: 'auto', resolved: _mq.matches ? 'ink' : 'paper' }); } catch { /* swallow: a listener's error must not block notifying the rest */ }
48
58
  }
49
59
  }
50
60
  };
@@ -62,7 +72,7 @@ export function applyTheme(mode) {
62
72
  ? (_mq && _mq.matches ? 'ink' : 'paper')
63
73
  : mode;
64
74
  for (const cb of listeners) {
65
- try { cb({ mode, resolved }); } catch {}
75
+ try { cb({ mode, resolved }); } catch { /* swallow: a listener's error must not block notifying the rest */ }
66
76
  }
67
77
  return mode;
68
78
  }
@@ -82,6 +92,80 @@ export function onThemeChange(cb) {
82
92
  return () => listeners.delete(cb);
83
93
  }
84
94
 
95
+ // ---- Accent + density: independent attribute controllers ----
96
+
97
+ function readStoredKey(key, valid) {
98
+ try { const v = window.localStorage.getItem(key); return valid.has(v) ? v : null; } catch { return null; }
99
+ }
100
+
101
+ export function applyAccent(accent) {
102
+ if (!isBrowser()) return accent;
103
+ if (VALID_ACCENT.has(accent)) {
104
+ document.documentElement.setAttribute('data-accent', accent);
105
+ try { window.localStorage.setItem(ACCENT_KEY, accent); } catch { /* swallow: persistence is best-effort, accent still applies in-memory */ }
106
+ } else {
107
+ // No accent attribute = the theme's default accent (green).
108
+ document.documentElement.removeAttribute('data-accent');
109
+ try { window.localStorage.removeItem(ACCENT_KEY); } catch { /* swallow: persistence is best-effort, accent still applies in-memory */ }
110
+ }
111
+ return accent;
112
+ }
113
+
114
+ export function getAccent() {
115
+ if (!isBrowser()) return null;
116
+ return document.documentElement.getAttribute('data-accent');
117
+ }
118
+
119
+ export function applyDensity(density) {
120
+ if (!isBrowser()) return density;
121
+ if (VALID_DENSITY.has(density)) {
122
+ document.documentElement.setAttribute('data-density', density);
123
+ try { window.localStorage.setItem(DENSITY_KEY, density); } catch { /* swallow: persistence is best-effort, density still applies in-memory */ }
124
+ }
125
+ return density;
126
+ }
127
+
128
+ export function getDensity() {
129
+ if (!isBrowser()) return null;
130
+ return document.documentElement.getAttribute('data-density');
131
+ }
132
+
133
+ // ---- Direction: derived from an active locale, not user-toggled ----
134
+ //
135
+ // Unlike theme/accent/density (explicit user picks), direction is DERIVED
136
+ // from whichever locale the consumer's i18n catalog has active — mirrors the
137
+ // data-theme pattern (one attribute the CSS reads) but the input is a BCP-47
138
+ // locale tag, not a direct rtl/ltr choice. Intl.Locale(locale).textInfo is
139
+ // the real platform primitive for this (no manual RTL-language list to
140
+ // maintain). Falls back to 'ltr' for a locale the runtime can't resolve
141
+ // (unknown tag, or a runtime without Intl.Locale.textInfo support).
142
+ export function applyDirection(locale) {
143
+ if (!isBrowser()) return 'ltr';
144
+ let dir = 'ltr';
145
+ try {
146
+ const info = new Intl.Locale(locale).textInfo;
147
+ if (info && (info.direction === 'rtl' || info.direction === 'ltr')) dir = info.direction;
148
+ } catch { /* swallow: unresolvable locale tag or no Intl.Locale.textInfo support — default ltr already set */ }
149
+ document.documentElement.setAttribute('dir', dir);
150
+ try { window.localStorage.setItem(LOCALE_KEY, locale); } catch { /* swallow: persistence is best-effort, direction still applies in-memory */ }
151
+ return dir;
152
+ }
153
+
154
+ export function getDirection() {
155
+ if (!isBrowser()) return 'ltr';
156
+ return document.documentElement.getAttribute('dir') || 'ltr';
157
+ }
158
+
159
+ // Restores the last-applied locale's direction on boot (no-op if none
160
+ // stored — leaves whatever dir the page was authored/SSR'd with).
161
+ export function initDirection() {
162
+ if (!isBrowser()) return 'ltr';
163
+ let stored = null;
164
+ try { stored = window.localStorage.getItem(LOCALE_KEY); } catch { /* swallow: no stored locale, use SSR/authored dir as-is */ }
165
+ if (stored) return applyDirection(stored);
166
+ return getDirection();
167
+ }
168
+
85
169
  // Auto-init on browser import. Picks stored value, else falls back to
86
170
  // whatever data-theme is already on <html> (set by page-html.js), else 'auto'.
87
171
  export function initTheme() {
@@ -90,10 +174,19 @@ export function initTheme() {
90
174
  const fromAttr = document.documentElement.getAttribute('data-theme');
91
175
  const initial = stored || (VALID.has(fromAttr) ? fromAttr : 'auto');
92
176
  applyTheme(initial);
177
+ // Restore persisted accent/density (no-op if none stored — keeps the
178
+ // theme's default accent and the page's authored density).
179
+ const accent = readStoredKey(ACCENT_KEY, VALID_ACCENT);
180
+ if (accent) applyAccent(accent);
181
+ const density = readStoredKey(DENSITY_KEY, VALID_DENSITY);
182
+ if (density) applyDensity(density);
93
183
  return initial;
94
184
  }
95
185
 
96
186
  if (isBrowser()) {
97
187
  // Run on next microtask so SSR-injected attributes settle first.
98
- Promise.resolve().then(() => { try { initTheme(); } catch {} });
188
+ Promise.resolve().then(() => {
189
+ try { initTheme(); } catch { /* swallow: deferred init is a progressive enhancement, page already rendered without it */ }
190
+ try { initDirection(); } catch { /* swallow: deferred init is a progressive enhancement, page already rendered without it */ }
191
+ });
99
192
  }
@@ -1,35 +1,106 @@
1
1
  // <ds-chat> custom element — auto-registers when SDK loads in a browser.
2
- // Properties: el.messages = [{role,text}, ...]; el.placeholder = string.
3
- // Emits a bubbling, composed 'send' event with { detail: { text } }.
2
+ // Attributes / properties:
3
+ // el.messages = [{who,text,time,name,...}, ...]
4
+ // el.placeholder, el.title, el.sub, el.disabled
5
+ // Emits a bubbling, composed 'send' event with { detail: { text } } when the
6
+ // user submits via the built-in composer.
4
7
 
5
8
  import * as webjsx from '../../vendor/webjsx/index.js';
6
- import { Chat } from '../components/chat.js';
9
+ import { Chat, ChatComposer } from '../components/chat.js';
7
10
 
8
11
  class DsChat extends HTMLElement {
9
12
  constructor() {
10
13
  super();
11
14
  this._messages = [];
12
- this._placeholder = 'type, then ';
15
+ this._placeholder = 'type, then enter';
16
+ this._title = 'chat';
17
+ this._sub = '';
18
+ this._composerValue = '';
19
+ this._disabled = false;
20
+ this._scrollPending = false;
13
21
  }
14
- static get observedAttributes() { return ['messages', 'placeholder']; }
22
+ static get observedAttributes() { return ['messages', 'placeholder', 'title', 'sub', 'disabled']; }
15
23
  attributeChangedCallback(name, _old, val) {
16
24
  if (name === 'messages') {
17
25
  try { this._messages = JSON.parse(val); } catch { this._messages = []; }
26
+ this._scrollPending = true;
18
27
  } else if (name === 'placeholder') {
19
28
  this._placeholder = val || '';
29
+ } else if (name === 'title') {
30
+ this._title = val || 'chat';
31
+ } else if (name === 'sub') {
32
+ this._sub = val || '';
33
+ } else if (name === 'disabled') {
34
+ this._disabled = val != null && val !== 'false';
20
35
  }
21
36
  this._render();
22
37
  }
23
- set messages(v) { this._messages = Array.isArray(v) ? v : []; this._render(); }
38
+ set messages(v) { this._messages = Array.isArray(v) ? v : []; this._scrollPending = true; this._render(); }
24
39
  get messages() { return this._messages; }
25
40
  set placeholder(v) { this._placeholder = v || ''; this._render(); }
26
41
  get placeholder() { return this._placeholder; }
27
- connectedCallback() { this.classList.add('ds-247420'); this._render(); }
28
- _send(text) { this.dispatchEvent(new CustomEvent('send', { detail: { text }, bubbles: true, composed: true })); }
42
+ set title(v) { this._title = v || 'chat'; this._render(); }
43
+ get title() { return this._title; }
44
+ set sub(v) { this._sub = v || ''; this._render(); }
45
+ get sub() { return this._sub; }
46
+ set disabled(v) { this._disabled = !!v; this._render(); }
47
+ get disabled() { return this._disabled; }
48
+ connectedCallback() {
49
+ this.classList.add('ds-247420');
50
+ this._render();
51
+ }
52
+ _send(text) {
53
+ this._composerValue = '';
54
+ this.dispatchEvent(new CustomEvent('send', { detail: { text }, bubbles: true, composed: true }));
55
+ this._render();
56
+ }
29
57
  _render() {
30
58
  if (!this.isConnected) return;
31
- const node = Chat({ messages: this._messages, onSend: (t) => this._send(t) });
59
+ // The factory captures `value` at construction time, so the click handler
60
+ // it builds sees a stale empty string. Pass a no-op and own send wiring
61
+ // ourselves by reading the live textarea below.
62
+ const self = this;
63
+ const composer = ChatComposer({
64
+ value: this._composerValue,
65
+ placeholder: this._placeholder,
66
+ disabled: this._disabled,
67
+ onInput: (v) => { self._composerValue = v; self._syncSendButton(); },
68
+ onSend: () => { /* superseded by live read below */ },
69
+ });
70
+ const node = Chat({
71
+ title: this._title,
72
+ sub: this._sub,
73
+ messages: this._messages,
74
+ composer,
75
+ });
32
76
  webjsx.applyDiff(this, node);
77
+ // Wire send button + Enter-key to read the LIVE textarea value rather than
78
+ // the closure's stale prop. Idempotent — only attach once per composer DOM node.
79
+ const composerEl = this.querySelector('.chat-composer');
80
+ if (composerEl && !composerEl._dsBound) {
81
+ composerEl._dsBound = true;
82
+ const ta = composerEl.querySelector('textarea');
83
+ const btn = composerEl.querySelector('button.send');
84
+ const submit = () => {
85
+ const v = ta ? ta.value.trim() : '';
86
+ if (!v || this._disabled) return;
87
+ if (ta) ta.value = '';
88
+ this._send(v);
89
+ };
90
+ if (btn) btn.addEventListener('click', (e) => { e.preventDefault(); submit(); });
91
+ if (ta) ta.addEventListener('keydown', (e) => {
92
+ if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit(); }
93
+ });
94
+ }
95
+ if (this._scrollPending) {
96
+ this._scrollPending = false;
97
+ const thread = this.querySelector('.chat-thread');
98
+ if (thread) thread.scrollTop = thread.scrollHeight;
99
+ }
100
+ }
101
+ _syncSendButton() {
102
+ const btn = this.querySelector('.chat-composer button.send');
103
+ if (btn) btn.disabled = this._disabled || !this._composerValue.trim();
33
104
  }
34
105
  }
35
106
 
@@ -70,8 +70,47 @@ function classifyAndReply(text) {
70
70
  return REPLIES.text();
71
71
  }
72
72
 
73
+ // `phase` drives the transcript. AICat renders the happy path; `empty` is a
74
+ // fresh session, `error` is a model-side failure, `loading` is history being
75
+ // rehydrated. All three are reachable from the sidebar so they stay live
76
+ // reference surfaces rather than paths only a real outage would reveal.
77
+ const PHASES = ['ready', 'loading', 'empty', 'error'];
78
+
79
+ // Turn-shaped shimmer for history rehydration. Reuses .ds-event-row-skeleton +
80
+ // .ds-skel* (app-shell/files.css) — avatar / body / timestamp, same rhythm.
81
+ function TranscriptSkeleton() {
82
+ return h('div', {},
83
+ ...Array.from({ length: 5 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-event-row-skeleton' },
84
+ h('span', { class: 'ds-skel ds-skel-icon' }),
85
+ h('span', { class: 'ds-skel ds-skel-title' }),
86
+ h('span', { class: 'ds-skel ds-skel-meta' })
87
+ ))
88
+ );
89
+ }
90
+
91
+ function TranscriptEmpty() {
92
+ return h('div', { class: 'ds-empty-state' },
93
+ h('div', { class: 'ds-empty-state-glyph' }, '( )'),
94
+ h('p', { class: 'ds-empty-state-msg' }, 'fresh session, nothing said yet'),
95
+ h('p', { class: 'ds-empty-state-hint' }, 'ask for code, markdown, an image, a pdf or a file attachment — or pick one of the prompts under "try" on the left and aicat answers in that shape.')
96
+ );
97
+ }
98
+
99
+ function TranscriptError() {
100
+ return h('div', { class: 'ds-alert ds-alert-error' },
101
+ h('span', { class: 'ds-alert-icon' }, '!'),
102
+ h('div', { class: 'ds-alert-content' },
103
+ h('div', { class: 'ds-alert-title' }, 'aicat stopped mid-reply'),
104
+ h('div', { class: 'ds-alert-message' }, 'the model returned a truncated response, so the last turn is incomplete rather than wrong-but-whole. your prompt is still in the box — resending replays it against a fresh context.'),
105
+ h('div', { class: 'ds-alert-retry' },
106
+ h('button', { class: 'btn', onclick: () => { state.phase = 'ready'; kit.render(); } }, 'resend last turn')
107
+ )
108
+ )
109
+ );
110
+ }
111
+
73
112
  const state = {
74
- draft: '', thinking: false, mood: 'idle',
113
+ draft: '', thinking: false, mood: 'idle', phase: 'ready',
75
114
  messages: [
76
115
  { who: 'them', name: 'aicat', text: 'hi. I am **aicat**. I read fast and I knock things off shelves.', time: '·' },
77
116
  { who: 'them', name: 'aicat', parts: [{ kind: 'md', text: 'try one of these:\n\n- ask for `code` (react/python — pick a flavour)\n- ask for the **token pdf** or the **mascot image**\n- ask me to attach a *config file*\n- or just chat — I respond in markdown.' }], time: '·' }
@@ -100,14 +139,20 @@ function send(text) {
100
139
 
101
140
  function App() {
102
141
  return AppShell({
103
- topbar: Topbar({ brand: '247420', leaf: 'aicat', items: [['index', '../../'], ['chat', '../chat/'], ['source ', 'https://github.com/AnEntrypoint/design']] }),
142
+ topbar: Topbar({ brand: '247420', leaf: 'aicat', items: [['index', '../../'], ['chat', '../chat/'], ['source ->', 'https://github.com/AnEntrypoint/design']] }),
104
143
  crumb: Crumb({ trail: ['247420', 'kits'], leaf: 'aicat' }),
105
144
  side: Side({
106
145
  sections: [
107
146
  { group: 'session', items: [
108
- { glyph: '', label: 'new chat', key: 'new', onClick: (e) => { e.preventDefault(); state.messages = state.messages.slice(0, 2); kit.render(); } },
109
- { glyph: '', label: 'history', count: 7, key: 'h' }
147
+ { glyph: '+', label: 'new chat', key: 'new', onClick: (e) => { e.preventDefault(); state.messages = state.messages.slice(0, 2); kit.render(); } },
148
+ { glyph: '~', label: 'history', count: 7, key: 'h' }
110
149
  ] },
150
+ // Reachable state switcher for the transcript.
151
+ { group: 'session state', items: PHASES.map((p) => ({
152
+ glyph: h('span', { class: state.phase === p ? 'ds-dot ds-dot-on' : 'ds-dot ds-dot-off' }),
153
+ label: p, key: 'ph-' + p, active: state.phase === p,
154
+ onClick: (e) => { e.preventDefault(); state.phase = p; kit.render(); }
155
+ })) },
111
156
  { group: 'try', items: PRESETS.map((p, i) => ({
112
157
  glyph: '·', label: p.q.length > 22 ? p.q.slice(0, 22) + '…' : p.q, key: 'p' + i,
113
158
  onClick: (e) => { e.preventDefault(); send(p.q); }
@@ -123,7 +168,10 @@ function App() {
123
168
  status: state.thinking ? 'thinking…' : (state.mood === 'happy' ? 'online · purring' : 'online · idle'),
124
169
  face: FACES[state.mood] || FACES.idle
125
170
  }),
126
- AICat({
171
+ state.phase === 'loading' ? Panel({ title: 'restoring session', children: TranscriptSkeleton() })
172
+ : state.phase === 'error' ? Panel({ title: 'reply failed', children: TranscriptError() })
173
+ : state.phase === 'empty' ? Panel({ title: 'new session', children: TranscriptEmpty() })
174
+ : AICat({
127
175
  name: 'aicat',
128
176
  status: state.thinking ? 'thinking…' : 'online · purring',
129
177
  messages: state.messages, thinking: state.thinking,
@@ -146,7 +194,7 @@ function App() {
146
194
  )
147
195
  ],
148
196
  status: Status({
149
- left: ['aicat', ' ' + state.messages.length + ' turns', state.thinking ? ' thinking' : ' idle'],
197
+ left: ['aicat', '- ' + (state.phase === 'ready' ? state.messages.length : 0) + ' turns', state.thinking ? '- thinking' : '- idle', '- ' + state.phase],
150
198
  right: ['247420 / mmxxvi']
151
199
  })
152
200
  });
@@ -11,6 +11,8 @@
11
11
  <link rel="icon" type="image/svg+xml" href="../../favicon.svg">
12
12
  <link rel="stylesheet" href="../../colors_and_type.css">
13
13
  <link rel="stylesheet" href="../../app-shell.css">
14
+ <link rel="stylesheet" href="../../chat.css">
15
+ <link rel="stylesheet" href="../../editor-primitives.css">
14
16
  <script type="importmap">
15
17
  { "imports": {
16
18
  "webjsx": "../../vendor/webjsx/index.js",