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.
- package/dist/247420.js +62 -12
- package/dist/app-shell.css +40 -594
- package/dist/colors_and_type.css +607 -47
- package/dist/index.html +227 -277
- package/dist/preview/buttons.html +39 -28
- package/dist/preview/colors-core.html +18 -8
- package/dist/preview/colors-lore.html +26 -17
- package/dist/preview/colors-semantic.html +24 -18
- package/dist/preview/dateline.html +6 -5
- package/dist/preview/dropzone.html +4 -3
- package/dist/preview/file-grid.html +10 -9
- package/dist/preview/file-row.html +14 -13
- package/dist/preview/file-toolbar.html +4 -3
- package/dist/preview/file-viewer.html +4 -3
- package/dist/preview/header.html +26 -24
- package/dist/preview/icons-unicode.html +8 -5
- package/dist/preview/index-row.html +17 -9
- package/dist/preview/inputs.html +4 -3
- package/dist/preview/manifesto.html +34 -17
- package/dist/preview/motion-default.js +77 -57
- package/dist/preview/rules.html +4 -3
- package/dist/preview/spacing.html +13 -12
- package/dist/preview/stamps-lore.html +5 -4
- package/dist/preview/stamps.html +4 -3
- package/dist/preview/theme-ink.html +7 -6
- package/dist/preview/type-display.html +14 -5
- package/dist/preview/type-mono.html +6 -5
- package/dist/preview/type-prose.html +12 -10
- package/dist/preview/type-scale.html +4 -3
- package/dist/preview/wordmarks.html +10 -7
- package/dist/site/content/globals/site.yaml +5 -3
- package/dist/site/content/pages/freddie.yaml +2 -2
- package/dist/site/content/pages/home.yaml +55 -55
- package/dist/site/theme.mjs +96 -348
- package/dist/slides/index.html +13 -13
- package/dist/src/bootstrap.js +10 -1
- package/dist/src/components/chat.js +43 -195
- package/dist/src/components/community.js +18 -166
- package/dist/src/components/content.js +30 -210
- package/dist/src/components/files-modals.js +14 -106
- package/dist/src/components/files.js +15 -116
- package/dist/src/components/freddie/helpers.js +53 -8
- package/dist/src/components/freddie.js +35 -25
- package/dist/src/components/shell.js +16 -114
- package/dist/src/components/theme-toggle.js +69 -25
- package/dist/src/components.js +92 -13
- package/dist/src/deck-stage.js +6 -4
- package/dist/src/highlight.js +51 -7
- package/dist/src/index.js +58 -12
- package/dist/src/kits/os/about-app.js +2 -3
- package/dist/src/kits/os/app-panes.css +46 -70
- package/dist/src/kits/os/browser-app.js +28 -1
- package/dist/src/kits/os/files-app.js +7 -0
- package/dist/src/kits/os/freddie/pages-chat.js +90 -75
- package/dist/src/kits/os/freddie/pages-core.js +9 -9
- package/dist/src/kits/os/freddie/pages-os.js +8 -8
- package/dist/src/kits/os/freddie/pages-tools.js +14 -14
- package/dist/src/kits/os/freddie/routes.js +21 -19
- package/dist/src/kits/os/freddie-dashboard.css +42 -21
- package/dist/src/kits/os/freddie-dashboard.js +6 -6
- package/dist/src/kits/os/icons.js +38 -16
- package/dist/src/kits/os/index.js +11 -0
- package/dist/src/kits/os/launcher.css +26 -10
- package/dist/src/kits/os/launcher.js +3 -0
- package/dist/src/kits/os/shell.js +72 -108
- package/dist/src/kits/os/terminal-app.js +1 -1
- package/dist/src/kits/os/theme.css +1330 -150
- package/dist/src/kits/os/wm.css +68 -15
- package/dist/src/kits/os/wm.js +93 -17
- package/dist/src/markdown.js +121 -12
- package/dist/src/motion.js +18 -2
- package/dist/src/page-html.js +62 -134
- package/dist/src/theme.js +106 -13
- package/dist/src/web-components/ds-chat.js +80 -9
- package/dist/ui_kits/aicat/app.js +54 -6
- package/dist/ui_kits/aicat/index.html +2 -0
- package/dist/ui_kits/blog/index.html +33 -17
- package/dist/ui_kits/chat/app.js +90 -22
- package/dist/ui_kits/chat/index.html +2 -0
- package/dist/ui_kits/community/app.js +86 -24
- package/dist/ui_kits/community/index.html +1 -0
- package/dist/ui_kits/dashboard/app.js +123 -34
- package/dist/ui_kits/dashboard/index.html +1 -0
- package/dist/ui_kits/docs/index.html +43 -33
- package/dist/ui_kits/error_404/app.js +78 -23
- package/dist/ui_kits/file_browser/README.md +1 -1
- package/dist/ui_kits/file_browser/app.js +45 -10
- package/dist/ui_kits/file_browser/index.html +1 -0
- package/dist/ui_kits/gallery/app.js +130 -51
- package/dist/ui_kits/homepage/app.js +156 -138
- package/dist/ui_kits/project_page/app.js +142 -125
- package/dist/ui_kits/project_page/index.html +2 -1
- package/dist/ui_kits/search/app.js +52 -15
- package/dist/ui_kits/settings/app.js +166 -39
- package/dist/ui_kits/signin/app.js +175 -49
- package/dist/ui_kits/signin/index.html +1 -0
- package/dist/ui_kits/slide_deck/app.js +83 -49
- package/dist/ui_kits/system_primer/app.js +57 -40
- package/dist/ui_kits/terminal/app.js +160 -43
- package/package.json +1 -1
- package/src/page-html/client-script.js +40 -11
- 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@
|
|
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
|
|
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
|
|
31
|
-
h('span', {
|
|
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', {
|
|
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 ? '
|
|
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',
|
|
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', '
|
|
60
|
-
return Panel({ title: 'theme',
|
|
61
|
-
Field({ label: 'mode', children: h('div', {
|
|
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',
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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,
|
|
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', {
|
|
83
|
-
h('input', { class: 'input', value: state.api_key, readonly: true
|
|
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-' +
|
|
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',
|
|
92
|
-
h('p', {
|
|
93
|
-
h('div', {
|
|
94
|
-
h('button', { class: 'btn
|
|
95
|
-
h('button', { class: 'btn
|
|
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
|
|
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-
|
|
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.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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', '
|
|
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
|
-
|
|
14
|
-
|
|
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,
|
|
27
|
+
function Provider({ glyph, label, provider }) {
|
|
28
|
+
const isLoading = state.loading === provider;
|
|
21
29
|
return h('button', {
|
|
22
|
-
class: 'btn',
|
|
23
|
-
|
|
24
|
-
|
|
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', {
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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,
|
|
40
|
-
h('label', {
|
|
41
|
-
h('span', {
|
|
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', {
|
|
45
|
-
h('span', {
|
|
46
|
-
h('input', { class: 'input', type: 'password', placeholder: '
|
|
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', {
|
|
49
|
-
h('label', {
|
|
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', {
|
|
168
|
+
h('span', { class: 'ds-auth-remember-text' }, 'remember me')
|
|
52
169
|
),
|
|
53
|
-
h('a', { href: '#reset', onclick: (e) => { e.preventDefault(); setMode('reset'); },
|
|
170
|
+
h('a', { href: '#reset', onclick: (e) => { e.preventDefault(); setMode('reset'); }, class: 'ds-auth-forgot' }, 'forgot password?')
|
|
54
171
|
) : null,
|
|
55
|
-
|
|
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
|
|
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' ?
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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', '
|
|
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
|
|
88
|
-
h('div', {
|
|
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
|
-
|
|
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
|
-
|
|
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', {
|
|
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', '
|
|
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",
|