anentrypoint-design 0.0.414 → 0.0.416

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
@@ -1,6 +1,7 @@
1
1
  import * as webjsx from 'webjsx';
2
2
  import { Topbar, Crumb, Status, Side, AppShell, Panel, Heading, Lede, Chip, Btn, Row } from 'ds/components.js';
3
3
  import { mountKit } from 'ds/bootstrap.js';
4
+ import { shortUid } from 'ds/uid.js';
4
5
  const h = webjsx.createElement;
5
6
 
6
7
  const root = document.getElementById('root');
@@ -8,57 +9,131 @@ const root = document.getElementById('root');
8
9
  const state = {
9
10
  section: 'profile',
10
11
  name: 'lanmower',
11
- email: 'almagestfraternite@gmail.com',
12
+ email: 'almagestfraternite@247420.xyz',
12
13
  handle: '@247420',
13
14
  bio: 'creative department of the internet. always open. always a little high.',
14
15
  theme: 'auto',
15
16
  motion: true,
16
17
  notify: { mentions: true, releases: true, marketing: false },
17
- api_key: 'sk-247420-•••••••-c2a',
18
- dirty: false
18
+ api_key: 'sk-247420-*******-c2a',
19
+ dirty: false,
20
+ lastSaved: null,
21
+ draft: null,
22
+ showConfirmDiscard: false,
23
+ // Which state the notifications surface renders in. Toggled from the
24
+ // sidebar so loading / empty / error are reachable here, not just on a
25
+ // real backend failure.
26
+ phase: 'ready'
19
27
  };
20
28
 
21
29
  const sections = [
22
- { id: 'profile', label: 'profile', glyph: '' },
23
- { id: 'theme', label: 'theme', glyph: '' },
24
- { id: 'notify', label: 'notifications',glyph: '' },
30
+ { id: 'profile', label: 'profile', glyph: '@' },
31
+ { id: 'theme', label: 'theme', glyph: '*' },
32
+ { id: 'notify', label: 'notifications',glyph: '~' },
25
33
  { id: 'api', label: 'api keys', glyph: '⌘' },
26
34
  { id: 'danger', label: 'danger zone', glyph: '!' }
27
35
  ];
28
36
 
37
+ // Draft management: auto-save to localStorage on every dirty change
38
+ function saveDraft() {
39
+ const draft = {
40
+ name: state.name,
41
+ email: state.email,
42
+ handle: state.handle,
43
+ bio: state.bio,
44
+ theme: state.theme,
45
+ motion: state.motion,
46
+ notify: { ...state.notify },
47
+ timestamp: Date.now()
48
+ };
49
+ localStorage.setItem('settings-draft', JSON.stringify(draft));
50
+ state.draft = draft;
51
+ }
52
+
53
+ function loadDraft() {
54
+ const stored = localStorage.getItem('settings-draft');
55
+ if (stored) {
56
+ try {
57
+ const draft = JSON.parse(stored);
58
+ state.draft = draft;
59
+ return draft;
60
+ } catch (e) {
61
+ return null;
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+
67
+ function restoreDraft(draft) {
68
+ if (draft) {
69
+ state.name = draft.name;
70
+ state.email = draft.email;
71
+ state.handle = draft.handle;
72
+ state.bio = draft.bio;
73
+ state.theme = draft.theme;
74
+ state.motion = draft.motion;
75
+ state.notify = { ...draft.notify };
76
+ }
77
+ }
78
+
79
+ function clearDraft() {
80
+ localStorage.removeItem('settings-draft');
81
+ state.draft = null;
82
+ }
83
+
84
+ function DiscardConfirmModal({ onConfirm, onCancel }) {
85
+ const draft = state.draft;
86
+ const timestamp = draft?.timestamp ? new Date(draft.timestamp).toLocaleString() : 'unknown time';
87
+ return h('div', { class: 'ds-modal-backdrop', onclick: (e) => { if (e.target === e.currentTarget) onCancel(); } },
88
+ h('div', { class: 'ds-modal ds-modal-small ds-settings-modal' },
89
+ h('div', { class: 'ds-modal-head' }, 'Discard unsaved changes?'),
90
+ h('div', { class: 'ds-modal-body ds-modal-body-form' },
91
+ h('p', { class: 'ds-modal-note' }, 'You have unsaved changes. A draft was saved at ' + timestamp + '.'),
92
+ h('div', { class: 'ds-draft-preview' },
93
+ 'Name: ' + state.name, h('br'), 'Email: ' + state.email, h('br'),
94
+ draft && draft.theme && draft.theme !== 'auto' ? ['Theme: ' + draft.theme, h('br')] : null
95
+ ),
96
+ h('div', { class: 'ds-modal-actions' },
97
+ h('button', { class: 'btn', onclick: () => { restoreDraft(draft); onCancel(); } }, 'Restore draft'),
98
+ h('button', { class: 'btn btn-primary danger ds-btn-warn', onclick: onConfirm }, 'Discard & continue')
99
+ )
100
+ )
101
+ )
102
+ );
103
+ }
104
+
29
105
  function Field({ label, hint, children }) {
30
- return h('label', { class: 'ds-field', style: 'display:flex;flex-direction:column;gap:6px;margin:10px 0' },
31
- h('span', { style: 'font-family:var(--ff-mono);font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:var(--panel-text-3)' }, label),
106
+ return h('label', { class: 'ds-field ds-field-block' },
107
+ h('span', { class: 'ds-field-eyebrow' }, label),
32
108
  children,
33
- hint ? h('span', { style: 'font-size:12px;color:var(--panel-text-2)' }, hint) : null
109
+ hint ? h('span', { class: 'ds-hint-sm' }, hint) : null
34
110
  );
35
111
  }
36
112
 
37
113
  function Toggle({ on, onChange, label }) {
38
114
  return h('button', {
39
- class: on ? 'btn btn-primary' : 'btn',
40
- style: 'min-width:78px',
115
+ class: (on ? 'btn btn-primary' : 'btn') + ' ds-toggle-btn',
41
116
  onclick: () => { onChange(!on); state.dirty = true; kit.render(); }
42
- }, on ? ' on' : ' off', label ? h('span', { style: 'margin-left:8px;color:inherit;opacity:0.7' }, label) : null);
117
+ }, on ? '[x] on' : '[ ] off', label ? h('span', { class: 'ds-toggle-label' }, label) : null);
43
118
  }
44
119
 
45
120
  function Profile() {
46
- return Panel({ title: 'profile', style: 'margin:8px 0', children: h('div', { style: 'padding:14px 18px' },
121
+ return Panel({ title: 'profile', class: 'ds-panel-gap', children: h('div', { class: 'ds-settings-body' },
47
122
  Field({ label: 'name', hint: 'shown on commits and PRs.', children:
48
- h('input', { class: 'input', value: state.name, oninput: (e) => { state.name = e.target.value; state.dirty = true; } }) }),
123
+ h('input', { class: 'input', value: state.name, oninput: (e) => { state.name = e.target.value; state.dirty = true; saveDraft(); kit.render(); } }) }),
49
124
  Field({ label: 'email', hint: 'used for git identity. never mailed.', children:
50
- h('input', { class: 'input', type: 'email', value: state.email, oninput: (e) => { state.email = e.target.value; state.dirty = true; } }) }),
125
+ h('input', { class: 'input', type: 'email', value: state.email, oninput: (e) => { state.email = e.target.value; state.dirty = true; saveDraft(); kit.render(); } }) }),
51
126
  Field({ label: 'handle', children:
52
127
  h('input', { class: 'input', value: state.handle, oninput: (e) => { state.handle = e.target.value; state.dirty = true; } }) }),
53
128
  Field({ label: 'bio', hint: 'one sentence. plain text.', children:
54
- h('textarea', { class: 'input', rows: 3, oninput: (e) => { state.bio = e.target.value; state.dirty = true; } }, state.bio) })
129
+ h('textarea', { class: 'input', rows: 3, oninput: (e) => { state.bio = e.target.value; state.dirty = true; saveDraft(); kit.render(); } }, state.bio) })
55
130
  ) });
56
131
  }
57
132
 
58
133
  function Theme() {
59
- const opts = [['auto', 'auto'], ['light', 'light'], ['dark', 'dark']];
60
- return Panel({ title: 'theme', style: 'margin:8px 0', children: h('div', { style: 'padding:14px 18px' },
61
- Field({ label: 'mode', children: h('div', { style: 'display:flex;gap:6px' },
134
+ const opts = [['auto', 'auto'], ['light', 'light'], ['dark', 'dark']];
135
+ return Panel({ title: 'theme', class: 'ds-panel-gap', children: h('div', { class: 'ds-settings-body' },
136
+ Field({ label: 'mode', children: h('div', { class: 'ds-btn-row ds-btn-row-tight' },
62
137
  ...opts.map(([k, l]) => h('button', { key: k,
63
138
  class: state.theme === k ? 'btn btn-primary' : 'btn',
64
139
  onclick: () => { state.theme = k; state.dirty = true; kit.render(); } }, l))
@@ -68,31 +143,72 @@ function Theme() {
68
143
  ) });
69
144
  }
70
145
 
146
+ const PHASES = ['ready', 'loading', 'empty', 'error'];
147
+
148
+ // Row-shaped shimmer for preferences still being fetched. Reuses
149
+ // .ds-event-row-skeleton + .ds-skel* (app-shell/files.css) — Row() renders the
150
+ // same title / sub / trailing-control rhythm.
151
+ function PrefsSkeleton() {
152
+ return h('div', {},
153
+ ...Array.from({ length: 3 }, (_, i) => h('div', { key: 'sk' + i, class: 'ds-event-row-skeleton' },
154
+ h('span', { class: 'ds-skel ds-skel-title' }),
155
+ h('span', { class: 'ds-skel ds-skel-meta' })
156
+ ))
157
+ );
158
+ }
159
+
160
+ function PrefsEmpty() {
161
+ return h('div', { class: 'ds-empty-state' },
162
+ h('div', { class: 'ds-empty-state-glyph' }, '[ ]'),
163
+ h('p', { class: 'ds-empty-state-msg' }, 'no notification channels connected'),
164
+ h('p', { class: 'ds-empty-state-hint' }, 'mentions, releases and product updates need somewhere to go. verify an email or add a webhook and the toggles for it appear here.')
165
+ );
166
+ }
167
+
168
+ function PrefsError() {
169
+ return h('div', { class: 'ds-alert ds-alert-error' },
170
+ h('span', { class: 'ds-alert-icon' }, '!'),
171
+ h('div', { class: 'ds-alert-content' },
172
+ h('div', { class: 'ds-alert-title' }, 'preferences did not save'),
173
+ h('div', { class: 'ds-alert-message' }, 'the server holds a newer copy of these toggles than this tab does, so saving would overwrite a change made elsewhere. reloading pulls the current values and keeps your draft alongside them.'),
174
+ h('div', { class: 'ds-alert-retry' },
175
+ h('button', { class: 'btn', onclick: () => { state.phase = 'ready'; kit.render(); } }, 'reload preferences')
176
+ )
177
+ )
178
+ );
179
+ }
180
+
71
181
  function Notify() {
72
- return Panel({ title: 'notifications', style: 'margin:8px 0', children: [
73
- Row({ key: 'n1', code: '◆', title: 'mentions', sub: 'when someone @s you', meta: h('span', {}, Toggle({ on: state.notify.mentions, onChange: (v) => state.notify.mentions = v })) }),
74
- Row({ key: 'n2', code: '◇', title: 'releases', sub: 'on every tagged build', meta: h('span', {}, Toggle({ on: state.notify.releases, onChange: (v) => state.notify.releases = v })) }),
75
- Row({ key: 'n3', code: '◇', title: 'marketing', sub: 'occasional product updates', meta: h('span', {}, Toggle({ on: state.notify.marketing, onChange: (v) => state.notify.marketing = v })) })
182
+ if (state.phase === 'loading') return Panel({ title: 'notifications', class: 'ds-panel-gap', children: PrefsSkeleton() });
183
+ if (state.phase === 'error') return Panel({ title: 'notifications', class: 'ds-panel-gap', children: PrefsError() });
184
+ if (state.phase === 'empty') return Panel({ title: 'notifications', class: 'ds-panel-gap', children: PrefsEmpty() });
185
+ // No `code` these are settings, not an indexed list. Carrying a one-glyph
186
+ // code would reserve the row's 12ch leading gutter for a single character
187
+ // and strand the label far right of its own panel edge.
188
+ return Panel({ title: 'notifications', class: 'ds-panel-gap', children: [
189
+ Row({ key: 'n1', title: 'mentions', sub: 'when someone @s you', meta: Toggle({ on: state.notify.mentions, onChange: (v) => state.notify.mentions = v }) }),
190
+ Row({ key: 'n2', title: 'releases', sub: 'on every tagged build', meta: Toggle({ on: state.notify.releases, onChange: (v) => state.notify.releases = v }) }),
191
+ Row({ key: 'n3', title: 'marketing', sub: 'occasional product updates', meta: Toggle({ on: state.notify.marketing, onChange: (v) => state.notify.marketing = v }) })
76
192
  ] });
77
193
  }
78
194
 
79
195
  function ApiKeys() {
80
- return Panel({ title: 'api keys', count: 1, style: 'margin:8px 0', children: h('div', { style: 'padding:14px 18px' },
196
+ return Panel({ title: 'api keys', count: 1, class: 'ds-panel-gap', children: h('div', { class: 'ds-settings-body' },
81
197
  Field({ label: 'production key', hint: 'rotate quarterly.', children:
82
- h('div', { style: 'display:flex;gap:8px' },
83
- h('input', { class: 'input', value: state.api_key, readonly: true, style: 'flex:1;font-family:var(--ff-mono)' }),
198
+ h('div', { class: 'ds-btn-row' },
199
+ h('input', { class: 'input ds-key-input', value: state.api_key, readonly: true }),
84
200
  h('button', { class: 'btn', onclick: () => { navigator.clipboard?.writeText(state.api_key); } }, 'copy'),
85
- h('button', { class: 'btn', onclick: () => { state.api_key = 'sk-247420-' + Math.random().toString(36).slice(2, 10) + '-' + Math.random().toString(36).slice(2, 5); state.dirty = true; kit.render(); } }, 'rotate')
201
+ h('button', { class: 'btn', onclick: () => { state.api_key = 'sk-247420-' + shortUid(8) + '-' + shortUid(5); state.dirty = true; kit.render(); } }, 'rotate')
86
202
  ) })
87
203
  ) });
88
204
  }
89
205
 
90
206
  function Danger() {
91
- return Panel({ title: 'danger zone', kind: 'danger', style: 'margin:8px 0', children: h('div', { style: 'padding:14px 18px;display:flex;flex-direction:column;gap:10px' },
92
- h('p', { style: 'margin:0;color:var(--panel-text-2)' }, 'these actions are permanent.'),
93
- h('div', { style: 'display:flex;gap:8px' },
94
- h('button', { class: 'btn', style: 'color:var(--mascot,#e0a200)' }, 'export account'),
95
- h('button', { class: 'btn', style: 'color:var(--warn)' }, 'delete account')
207
+ return Panel({ title: 'danger zone', kind: 'danger', class: 'ds-panel-gap', children: h('div', { class: 'ds-settings-body ds-settings-body-stack' },
208
+ h('p', { class: 'ds-note-quiet' }, 'these actions are permanent.'),
209
+ h('div', { class: 'ds-btn-row' },
210
+ h('button', { class: 'btn ds-btn-mascot' }, 'export account'),
211
+ h('button', { class: 'btn ds-btn-warn' }, 'delete account')
96
212
  )
97
213
  ) });
98
214
  }
@@ -100,7 +216,7 @@ function Danger() {
100
216
  function App() {
101
217
  const view = { profile: Profile, theme: Theme, notify: Notify, api: ApiKeys, danger: Danger }[state.section]();
102
218
  return AppShell({
103
- topbar: Topbar({ brand: '247420', leaf: 'settings', items: [['index', '../../'], ['source ', 'https://github.com/AnEntrypoint/design']] }),
219
+ topbar: Topbar({ brand: '247420', leaf: 'settings', items: [['index', '../../'], ['source ->', 'https://github.com/AnEntrypoint/design']] }),
104
220
  crumb: Crumb({ trail: ['247420', 'kits'], leaf: 'settings · ' + state.section }),
105
221
  side: Side({
106
222
  sections: [
@@ -108,23 +224,34 @@ function App() {
108
224
  glyph: s.glyph, label: s.label,
109
225
  href: '#' + s.id, active: state.section === s.id, key: s.id,
110
226
  onClick: (e) => { e.preventDefault(); state.section = s.id; kit.render(); }
227
+ })) },
228
+ // Reachable state switcher — applies to the notifications
229
+ // section, this kit's list-shaped data surface.
230
+ { group: 'prefs state', items: PHASES.map((p) => ({
231
+ glyph: h('span', { class: state.phase === p ? 'ds-dot ds-dot-on' : 'ds-dot ds-dot-off' }),
232
+ label: p, key: 'ph-' + p, active: state.phase === p,
233
+ onClick: (e) => { e.preventDefault(); state.phase = p; state.section = 'notify'; kit.render(); }
111
234
  })) }
112
235
  ]
113
236
  }),
114
237
  main: [
115
- h('div', { class: 'ds-section', style: 'padding:8px' },
238
+ h('div', { class: 'ds-app-surface ds-settings-main' },
116
239
  Heading({ level: 1, children: 'settings' }),
117
240
  Lede({ children: 'every input primitive in one surface — fields, toggles, segmented buttons, danger panel, save bar.' }),
118
241
  view,
119
- state.dirty ? h('div', { style: 'position:sticky;bottom:8px;display:flex;justify-content:flex-end;gap:8px;padding:10px;background:var(--panel-2);border-radius:10px;margin:8px 0' },
120
- h('span', { style: 'flex:1;color:var(--panel-text-2)' }, 'unsaved changes'),
121
- h('button', { class: 'btn', onclick: () => { state.dirty = false; kit.render(); } }, 'discard'),
122
- h('button', { class: 'btn btn-primary', onclick: () => { state.dirty = false; kit.render(); } }, 'save')
242
+ state.showConfirmDiscard ? DiscardConfirmModal({
243
+ onCancel: () => { state.showConfirmDiscard = false; kit.render(); },
244
+ onConfirm: () => { state.dirty = false; clearDraft(); state.showConfirmDiscard = false; kit.render(); }
245
+ }) : null,
246
+ state.dirty ? h('div', { class: 'ds-savebar' },
247
+ h('span', { class: 'ds-savebar-note' }, 'unsaved changes · draft auto-saved'),
248
+ h('button', { class: 'btn', onclick: () => { state.showConfirmDiscard = true; kit.render(); } }, 'discard'),
249
+ h('button', { class: 'btn btn-primary', onclick: () => { saveDraft(); state.dirty = false; state.lastSaved = Date.now(); kit.render(); } }, 'save')
123
250
  ) : null
124
251
  )
125
252
  ],
126
253
  status: Status({
127
- left: ['settings', ' ' + state.section, state.dirty ? ' dirty' : ' saved'],
254
+ left: ['settings', '- ' + state.section, state.dirty ? '- dirty' : '- saved', '- prefs ' + state.phase],
128
255
  right: ['247420 / mmxxvi']
129
256
  })
130
257
  });
@@ -1,74 +1,196 @@
1
1
  import * as webjsx from 'webjsx';
2
- import { Topbar, Crumb, Status, AppShell, Panel, Heading, Lede, Chip } from 'ds/components.js';
2
+ import { Topbar, Crumb, Status, AppShell, Panel, Heading, Lede, Chip, Icon, Divider } from 'ds/components.js';
3
3
  import { mountKit } from 'ds/bootstrap.js';
4
+ import { shortUid } from 'ds/uid.js';
4
5
  const h = webjsx.createElement;
5
6
 
6
7
  const root = document.getElementById('root');
7
- const state = { mode: 'signin', email: '', password: '', remember: true, sent: false, error: '' };
8
+ const state = { mode: 'signin', email: '', password: '', remember: true, sent: false, error: '', loading: null, demoUrl: '' };
8
9
 
9
- function setMode(m) { state.mode = m; state.sent = false; state.error = ''; kit.render(); }
10
+ function setMode(m) { state.mode = m; state.sent = false; state.error = ''; state.loading = null; state.demoUrl = ''; kit.render(); }
10
11
 
11
12
  function submit(e) {
12
13
  e.preventDefault();
13
- if (!state.email.includes('@')) { state.error = 'enter a real email.'; kit.render(); return; }
14
- if (state.mode !== 'magic' && state.password.length < 6) { state.error = 'password must be at least 6 characters.'; kit.render(); return; }
14
+ // Name the problem and the fix, in the surface's terse lowercase voice.
15
+ // "enter a real email" told the user they were wrong without saying what
16
+ // would be right.
17
+ if (!state.email.trim()) { state.error = 'email is empty — enter the address on your account.'; kit.render(); return; }
18
+ if (!state.email.includes('@')) { state.error = 'that address has no @ — check for a typo.'; kit.render(); return; }
19
+ if (state.mode !== 'magic' && state.mode !== 'reset' && state.password.length < 6) {
20
+ state.error = 'password is too short — 6 characters minimum.'; kit.render(); return;
21
+ }
15
22
  state.error = '';
16
23
  state.sent = true;
17
24
  kit.render();
18
25
  }
19
26
 
20
- function Provider({ glyph, label, onClick }) {
27
+ function Provider({ glyph, label, provider }) {
28
+ const isLoading = state.loading === provider;
21
29
  return h('button', {
22
- class: 'btn',
23
- style: 'flex:1;display:flex;align-items:center;justify-content:center;gap:8px;padding:10px',
24
- onclick: onClick || ((e) => { e.preventDefault(); state.error = '(stub) provider not wired'; kit.render(); })
30
+ class: 'btn ds-auth-provider-btn' + (isLoading ? ' ds-auth-provider-btn--loading' : ''),
31
+ onclick: (e) => {
32
+ e.preventDefault();
33
+ if (isLoading) return;
34
+ state.loading = provider;
35
+ state.error = '';
36
+ kit.render();
37
+ startOAuthFlow(provider);
38
+ },
39
+ disabled: isLoading
25
40
  },
26
- h('span', { style: 'font-family:var(--ff-mono);color:var(--panel-text-3)' }, glyph),
27
- h('span', {}, label)
41
+ h('span', { class: 'ds-auth-provider-glyph' + (isLoading ? ' ds-spin' : '') }, isLoading ? Icon('refresh') : glyph),
42
+ h('span', {}, isLoading ? 'redirecting...' : label)
28
43
  );
29
44
  }
30
45
 
46
+ // Config comes from a plain global, NOT import.meta.env. There is no Vite in
47
+ // this repo, so `import.meta.env` is undefined in the browser and reading a
48
+ // property off it THROWS — and because the config object was built before the
49
+ // try below, that throw escaped the catch entirely and left every provider
50
+ // button stuck on "redirecting..." forever with no error shown. A host app that
51
+ // wants real credentials sets globalThis.__DS_AUTH before mounting.
52
+ const AUTH_ENV = (typeof globalThis !== 'undefined' && globalThis.__DS_AUTH) || {};
53
+
54
+ function startOAuthFlow(provider) {
55
+ const config = {
56
+ github: {
57
+ clientId: AUTH_ENV.githubClientId || 'demo-github-client-id',
58
+ redirectUri: window.location.origin + '/auth/callback/github'
59
+ },
60
+ google: {
61
+ clientId: AUTH_ENV.googleClientId || 'demo-google-client-id',
62
+ redirectUri: window.location.origin + '/auth/callback/google'
63
+ },
64
+ sso: {
65
+ endpoint: AUTH_ENV.ssoEndpoint || 'https://sso.247420.xyz/authorize',
66
+ redirectUri: window.location.origin + '/auth/callback/sso'
67
+ }
68
+ }[provider];
69
+
70
+ if (!config) {
71
+ state.error = 'provider not configured';
72
+ state.loading = null;
73
+ kit.render();
74
+ return;
75
+ }
76
+
77
+ try {
78
+ if (provider === 'github') {
79
+ const scopes = ['user:email', 'read:user'].join(' ');
80
+ const params = new URLSearchParams({
81
+ client_id: config.clientId,
82
+ redirect_uri: config.redirectUri,
83
+ scope: scopes,
84
+ state: generateState()
85
+ });
86
+ go('https://github.com/login/oauth/authorize?' + params);
87
+ } else if (provider === 'google') {
88
+ const scopes = ['openid', 'email', 'profile'].join(' ');
89
+ const params = new URLSearchParams({
90
+ client_id: config.clientId,
91
+ redirect_uri: config.redirectUri,
92
+ response_type: 'code',
93
+ scope: scopes,
94
+ state: generateState()
95
+ });
96
+ go('https://accounts.google.com/o/oauth2/v2/auth?' + params);
97
+ } else if (provider === 'sso') {
98
+ const params = new URLSearchParams({
99
+ redirect_uri: config.redirectUri,
100
+ state: generateState()
101
+ });
102
+ go(config.endpoint + '?' + params);
103
+ }
104
+ } catch (err) {
105
+ state.error = 'oauth flow failed: ' + (err.message || 'unknown error');
106
+ state.loading = null;
107
+ kit.render();
108
+ }
109
+ }
110
+
111
+ // A specimen must not actually leave the specimen. Without real credentials the
112
+ // navigation above would send a visitor to github.com with the literal client id
113
+ // "demo-github-client-id" and strand them on a provider error page, so unless a
114
+ // host app supplied real config we show the exact URL that WOULD be opened and
115
+ // stay put. That is more useful than a redirect anyway: it makes the request
116
+ // this component builds inspectable, which is what a specimen is for.
117
+ function isDemoConfig() {
118
+ return !AUTH_ENV.githubClientId && !AUTH_ENV.googleClientId && !AUTH_ENV.ssoEndpoint;
119
+ }
120
+
121
+ function go(url) {
122
+ if (isDemoConfig()) {
123
+ state.loading = null;
124
+ state.demoUrl = url;
125
+ kit.render();
126
+ return;
127
+ }
128
+ window.location.href = url;
129
+ }
130
+
131
+ function generateState() {
132
+ return btoa(JSON.stringify({
133
+ nonce: shortUid(11),
134
+ timestamp: Date.now()
135
+ }));
136
+ }
137
+
31
138
  function Form() {
32
139
  if (state.sent) {
33
- return h('div', { style: 'padding:18px;display:flex;flex-direction:column;gap:10px;align-items:center;text-align:center' },
34
- h('div', { style: 'font-size:32px;color:var(--panel-accent)' }, '✓'),
35
- h('p', { style: 'margin:0;font-weight:600' }, state.mode === 'magic' ? 'check your email' : (state.mode === 'reset' ? 'reset link sent' : 'welcome back')),
36
- h('p', { style: 'margin:0;color:var(--panel-text-2)' }, state.mode === 'magic' ? 'we sent a sign-in link to ' + state.email : (state.mode === 'reset' ? 'follow the link to set a new password.' : 'redirecting…'))
140
+ // A confirmation with no way back is a trap: mistype the address and
141
+ // the only recovery was a page reload. Every sent state that waits on
142
+ // an email now offers the correction path.
143
+ const waiting = state.mode === 'magic' || state.mode === 'reset';
144
+ return h('div', { class: 'ds-auth-form ds-auth-sent' },
145
+ h('div', { class: 'ds-auth-sent-glyph' }, '[x]'),
146
+ h('p', { class: 'ds-auth-sent-title' }, state.mode === 'magic' ? 'check your email' : (state.mode === 'reset' ? 'reset link sent' : 'welcome back')),
147
+ h('p', { class: 'ds-auth-sent-sub' }, state.mode === 'magic'
148
+ ? 'we sent a sign-in link to ' + state.email + '. it expires in 15 minutes.'
149
+ : (state.mode === 'reset' ? 'we sent a reset link to ' + state.email + '. follow it to set a new password.' : 'signed in. taking you to the index.')),
150
+ waiting ? h('button', {
151
+ class: 'btn',
152
+ onclick: (e) => { e.preventDefault(); state.sent = false; kit.render(); }
153
+ }, 'use a different email') : null
37
154
  );
38
155
  }
39
- return h('form', { onsubmit: submit, style: 'padding:18px;display:flex;flex-direction:column;gap:10px' },
40
- h('label', { style: 'display:flex;flex-direction:column;gap:4px' },
41
- h('span', { style: 'font-family:var(--ff-mono);font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:var(--panel-text-3)' }, 'email'),
156
+ return h('form', { onsubmit: submit, class: 'ds-auth-form' },
157
+ h('label', { class: 'ds-auth-field' },
158
+ h('span', { class: 'ds-auth-field-label' }, 'email'),
42
159
  h('input', { class: 'input', type: 'email', placeholder: 'you@247420.xyz', value: state.email, autocomplete: 'email', oninput: (e) => { state.email = e.target.value; } })
43
160
  ),
44
- state.mode !== 'magic' && state.mode !== 'reset' ? h('label', { style: 'display:flex;flex-direction:column;gap:4px' },
45
- h('span', { style: 'font-family:var(--ff-mono);font-size:11px;letter-spacing:0.06em;text-transform:uppercase;color:var(--panel-text-3)' }, 'password'),
46
- h('input', { class: 'input', type: 'password', placeholder: '••••••••', value: state.password, autocomplete: state.mode === 'signup' ? 'new-password' : 'current-password', oninput: (e) => { state.password = e.target.value; } })
161
+ state.mode !== 'magic' && state.mode !== 'reset' ? h('label', { class: 'ds-auth-field' },
162
+ h('span', { class: 'ds-auth-field-label' }, 'password'),
163
+ h('input', { class: 'input', type: 'password', placeholder: '********', value: state.password, autocomplete: state.mode === 'signup' ? 'new-password' : 'current-password', oninput: (e) => { state.password = e.target.value; } })
47
164
  ) : null,
48
- state.mode === 'signin' ? h('div', { style: 'display:flex;align-items:center;justify-content:space-between' },
49
- h('label', { style: 'display:flex;align-items:center;gap:6px;cursor:pointer' },
165
+ state.mode === 'signin' ? h('div', { class: 'ds-auth-row-between' },
166
+ h('label', { class: 'ds-auth-remember' },
50
167
  h('input', { type: 'checkbox', checked: state.remember, onchange: (e) => { state.remember = e.target.checked; } }),
51
- h('span', { style: 'color:var(--panel-text-2);font-size:13px' }, 'remember me')
168
+ h('span', { class: 'ds-auth-remember-text' }, 'remember me')
52
169
  ),
53
- h('a', { href: '#reset', onclick: (e) => { e.preventDefault(); setMode('reset'); }, style: 'font-size:13px' }, 'forgot password?')
170
+ h('a', { href: '#reset', onclick: (e) => { e.preventDefault(); setMode('reset'); }, class: 'ds-auth-forgot' }, 'forgot password?')
54
171
  ) : null,
55
- state.error ? h('div', { style: 'padding:8px 10px;background:var(--panel-1);border-radius:8px;color:#cc4242;font-size:13px' }, state.error) : null,
172
+ // role=alert so the validation message is announced, not just painted.
173
+ state.error ? h('div', { class: 'ds-auth-error', role: 'alert' }, state.error) : null,
174
+ // Shows the exact authorize URL this component built, instead of
175
+ // navigating away from the specimen with placeholder credentials.
176
+ state.demoUrl ? h('div', { class: 'ds-auth-error', role: 'status' },
177
+ 'demo mode — would open: ' + state.demoUrl) : null,
56
178
  h('button', { class: 'btn btn-primary', type: 'submit' },
57
- state.mode === 'signup' ? 'create account ' :
58
- state.mode === 'magic' ? 'send magic link ' :
59
- state.mode === 'reset' ? 'send reset link ' : 'sign in '
179
+ state.mode === 'signup' ? 'create account ->' :
180
+ state.mode === 'magic' ? 'send magic link ->' :
181
+ state.mode === 'reset' ? 'send reset link ->' : 'sign in ->'
60
182
  ),
61
- state.mode !== 'reset' ? h('div', { style: 'display:flex;align-items:center;gap:10px;margin:6px 0;color:var(--panel-text-3);font-size:12px' },
62
- h('div', { style: 'flex:1;height:1px;background:var(--panel-2)' }),
63
- h('span', {}, 'or'),
64
- h('div', { style: 'flex:1;height:1px;background:var(--panel-2)' })
65
- ) : null,
66
- state.mode !== 'reset' ? h('div', { style: 'display:flex;gap:8px' },
67
- Provider({ glyph: '◆', label: 'github' }),
68
- Provider({ glyph: '◇', label: 'google' }),
69
- Provider({ glyph: '✦', label: 'sso' })
70
- ) : null,
71
- state.mode !== 'reset' && state.mode !== 'magic' ? h('button', { class: 'btn', onclick: (e) => { e.preventDefault(); setMode('magic'); } }, 'use a magic link instead') : null
183
+ state.mode !== 'reset' ? Divider({ label: 'or' }) : null,
184
+ state.mode !== 'reset' ? h('div', { class: 'ds-auth-providers' },
185
+ Provider({ glyph: 'gh', label: 'github', provider: 'github' }),
186
+ Provider({ glyph: 'g', label: 'google', provider: 'google' }),
187
+ Provider({ glyph: '@', label: 'sso', provider: 'sso' })
188
+ ) : null
189
+ // The "use a magic link instead" button was removed from here: it was
190
+ // a full-width default button sitting directly under the three OAuth
191
+ // buttons, so it read as a fourth provider, and it duplicated the
192
+ // magic-link entry already present in the mode row below the panel.
193
+ // One control per action.
72
194
  );
73
195
  }
74
196
 
@@ -76,28 +198,32 @@ function App() {
76
198
  const headings = {
77
199
  signin: ['sign in', 'welcome back. pick a provider or use email.'],
78
200
  signup: ['create', 'join the 247420 portfolio. one account, every kit.'],
79
- magic: ['magic link', 'we’ll email you a one-tap sign-in link. no password.'],
201
+ magic: ['magic link', "we'll email you a one-tap sign-in link. no password."],
80
202
  reset: ['reset', 'enter your email to receive a reset link.']
81
203
  }[state.mode];
82
204
  return AppShell({
83
205
  narrow: true,
84
206
  topbar: Topbar({ brand: '247420', leaf: 'auth', items: [['index', '../../']] }),
85
- crumb: Crumb({ trail: ['247420', 'kits'], leaf: 'signin · ' + state.mode }),
207
+ crumb: Crumb({ trail: ['247420', 'kits'], leaf: state.mode === 'signin' ? 'signin' : 'signin · ' + state.mode }),
86
208
  main: [
87
- h('div', { class: 'ds-section', style: 'padding:8px;display:flex;flex-direction:column;align-items:center' },
88
- h('div', { style: 'width:100%;max-width:440px;display:flex;flex-direction:column;gap:8px;margin:24px 0' },
209
+ h('div', { class: 'ds-section ds-auth-wrap' },
210
+ h('div', { class: 'ds-auth-col' },
89
211
  Heading({ level: 1, children: headings[0] }),
90
212
  Lede({ children: headings[1] }),
91
213
  Panel({ children: Form() }),
92
- h('div', { style: 'display:flex;justify-content:center;gap:14px;margin-top:8px;font-size:13px' },
214
+ // `reset` is a sub-flow of signin, not a fourth mode, so it
215
+ // marks signin as its origin. Without this the mode row
216
+ // showed nothing active during reset and offered no route
217
+ // back — "forgot password?" was a one-way door.
218
+ h('div', { class: 'ds-auth-modes' },
93
219
  ['signin', 'signup', 'magic'].map((m) =>
94
220
  h('a', { key: m, href: '#' + m,
95
221
  onclick: (e) => { e.preventDefault(); setMode(m); },
96
- style: 'color:' + (state.mode === m ? 'var(--panel-text)' : 'var(--panel-text-3)') + ';text-decoration:' + (state.mode === m ? 'underline' : 'none')
97
- }, m === 'signin' ? 'sign in' : m === 'signup' ? 'create account' : 'magic link')
222
+ class: 'ds-auth-mode-link' + ((state.mode === m || (state.mode === 'reset' && m === 'signin')) ? ' ds-auth-mode-link--active' : '')
223
+ }, m === 'signin' ? (state.mode === 'reset' ? '<- back to sign in' : 'sign in') : m === 'signup' ? 'create account' : 'magic link')
98
224
  )
99
225
  ),
100
- h('p', { style: 'text-align:center;font-size:12px;color:var(--panel-text-3);margin:6px 0' },
226
+ h('p', { class: 'ds-auth-fineprint' },
101
227
  'by continuing you agree to the ',
102
228
  Chip({ tone: 'dim', children: 'terms' }), ' and ',
103
229
  Chip({ tone: 'dim', children: 'privacy notice' }), '.'
@@ -106,7 +232,7 @@ function App() {
106
232
  )
107
233
  ],
108
234
  status: Status({
109
- left: ['auth', ' ' + state.mode, state.error ? ' error' : ' ok'],
235
+ left: ['auth', '- ' + state.mode, state.error ? '- error' : '- ok'],
110
236
  right: ['247420 / mmxxvi']
111
237
  })
112
238
  });
@@ -11,6 +11,7 @@
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="../../editor-primitives.css">
14
15
  <script type="importmap">
15
16
  { "imports": {
16
17
  "webjsx": "../../vendor/webjsx/index.js",