Feat v0.6.17 bugfixes (#52)
Some checks failed
CI/CD / detect-changes (push) Successful in 16s
CI/CD / test-frontend (push) Successful in 6s
CI/CD / test-go-pg (push) Failing after 2m54s
CI/CD / test-sqlite (push) Failing after 3m7s
CI/CD / build-and-deploy (push) Has been skipped

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit was merged in pull request #52.
This commit is contained in:
2026-04-01 16:36:43 +00:00
committed by xcaliber
parent ff19a1b4d3
commit e7d1b53ebf
12 changed files with 111 additions and 30 deletions

View File

@@ -2,6 +2,42 @@
All notable changes to Armature are documented here. All notable changes to Armature are documented here.
## v0.6.17 — Bug Fixes & Welcome Logic
Fixes broken UI interactions (folder creation, team member add), dropdown
overflow, welcome surface auto-disable, and bare-install default behavior.
### Fixed
- **Notes "Add folder" button** — `prompt()` replaced with `sw.prompt()`
so the dialog renders correctly in the extension iframe sandbox.
- **Admin "Add team members"** — user list API returns `{data:[…]}`; handler
now unwraps the envelope (`Array.isArray(u) ? u : u.data`) so the user
picker populates.
- **Package filter dropdown overflow** — removed `right:0` constraint on
`.sw-dropdown__list`, added `min-width:max-content` and `overflow-x:hidden`
so option labels ("Extension", "Workflow") render fully without a scrollbar.
- **Admin actions cell wrapping** — switched `.admin-actions-cell` from
`white-space:nowrap` to flexbox with `flex-wrap:wrap; gap:4px` so buttons
don't overflow on narrow viewports.
### Changed
- **Welcome surface auto-disable** — welcome page now redirects to `/` when
any non-core extension surface is installed. Removed from the topbar
navigation surface list so it never appears alongside real surfaces.
- **Zero default bundled packages** — `defaultBundledPackages` map is now
empty. Fresh installs start bare; use `BUNDLED_PACKAGES` env var to control
what gets auto-installed per environment (`*` for all, comma-separated list
for selective).
- **Bundled filter logic** — `nil` (from `*`) means install all; empty map
(default) means install nothing. Previous code treated both as "install all".
- **User menu conditional items** — Docs, Settings, and Team Admin menu
entries only appear when those surfaces are actually enabled, not assumed.
- **SDK imperative host mount** — `ToastContainer` and `DialogStack` are now
auto-mounted by the SDK boot sequence for extension surfaces that lack an
AppShell, preventing missing toast/dialog hosts.
## v0.6.16 — Usability Survey Gate ## v0.6.16 — Usability Survey Gate
Machine-auditable UI quality gate. Four new audit scripts, a structured survey Machine-auditable UI quality gate. Four new audit scripts, a structured survey

View File

@@ -1 +1 @@
0.6.15 0.6.17

View File

@@ -1622,7 +1622,7 @@
setShowUnfiled(true); setShowUnfiled(true);
} }
async function handleCreateFolder(parentId) { async function handleCreateFolder(parentId) {
var name = prompt('Folder name:'); var name = await sw.prompt('Folder name:');
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
try { try {
await api.post('/folders', { name: name.trim(), parent_id: parentId || '' }); await api.post('/folders', { name: name.trim(), parent_id: parentId || '' });

View File

@@ -747,6 +747,10 @@ func (h *PackageHandler) ListEnabledSurfaces(c *gin.Context) {
if p.Type != "surface" && p.Type != "full" { if p.Type != "surface" && p.Type != "full" {
continue continue
} }
// Welcome is a fallback surface, not a navigable destination
if p.ID == "welcome" {
continue
}
route, _ := p.Manifest["route"].(string) route, _ := p.Manifest["route"].(string)
icon, _ := p.Manifest["icon"].(string) icon, _ := p.Manifest["icon"].(string)
enabled = append(enabled, navSurface{ enabled = append(enabled, navSurface{

View File

@@ -22,18 +22,12 @@ import (
"armature/triggers" "armature/triggers"
) )
// defaultBundledPackages is the curated set of packages installed by default. // defaultBundledPackages is empty — fresh installs start bare.
// Other packages still ship in the Docker image but require BUNDLED_PACKAGES // Use BUNDLED_PACKAGES env var to control what gets installed per environment:
// to be set explicitly (or "*" for all). // dev: BUNDLED_PACKAGES=*
// // test: BUNDLED_PACKAGES=notes,chat,chat-core
// Recommended production override: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules // prod: BUNDLED_PACKAGES=notes,chat,chat-core,mermaid-renderer,schedules
var defaultBundledPackages = map[string]bool{ var defaultBundledPackages = map[string]bool{}
"notes": true,
"chat": true,
"chat-core": true,
"mermaid-renderer": true,
"schedules": true,
}
// InstallBundledPackages scans bundledDir for .pkg archives and installs // InstallBundledPackages scans bundledDir for .pkg archives and installs
// any that don't already exist in the database. Called once at startup // any that don't already exist in the database. Called once at startup
@@ -74,7 +68,8 @@ func InstallBundledPackages(bundledDir, packagesDir, allowlist string, stores st
} }
// Check allowlist by filename (strip .pkg extension = package ID) // Check allowlist by filename (strip .pkg extension = package ID)
if len(allowed) > 0 { // nil = install all ("*"), empty map = install nothing (empty default set)
if allowed != nil {
pkgName := strings.TrimSuffix(entry.Name(), ".pkg") pkgName := strings.TrimSuffix(entry.Name(), ".pkg")
if !allowed[pkgName] { if !allowed[pkgName] {
filtered++ filtered++

View File

@@ -494,6 +494,10 @@ func (e *Engine) RegisterPageRoutes(base *gin.RouterGroup, mw PageRouteMiddlewar
continue continue
} }
handler := e.RenderSurface(s.ID) handler := e.RenderSurface(s.ID)
// Welcome auto-redirects to / when extension surfaces exist
if s.ID == "welcome" {
handler = e.welcomeRedirectIfSurfaces(handler)
}
registerRoutes(group, s, handler) registerRoutes(group, s, handler)
} }

View File

@@ -176,6 +176,28 @@ func (e *Engine) surfacePath(ctx context.Context, id string) string {
return "/s/" + id return "/s/" + id
} }
// welcomeRedirectIfSurfaces wraps a welcome surface handler so that
// /welcome auto-redirects to / when any extension surface is installed.
// This means the welcome page only renders for truly empty deployments.
func (e *Engine) welcomeRedirectIfSurfaces(inner gin.HandlerFunc) gin.HandlerFunc {
return func(c *gin.Context) {
if e.stores.Packages != nil {
if pkgs, err := e.stores.Packages.List(c.Request.Context()); err == nil {
for _, p := range pkgs {
if p.Source == "core" || !p.Enabled {
continue
}
if p.Type == "surface" || p.Type == "full" {
c.Redirect(http.StatusTemporaryRedirect, e.cfg.BasePath+"/")
return
}
}
}
}
inner(c)
}
}
// disabledRedirect returns a handler that redirects to /. // disabledRedirect returns a handler that redirects to /.
// The DefaultSurfaceRedirect handler will resolve to the appropriate surface. // The DefaultSurfaceRedirect handler will resolve to the appropriate surface.
func (e *Engine) disabledRedirect() gin.HandlerFunc { func (e *Engine) disabledRedirect() gin.HandlerFunc {

View File

@@ -288,7 +288,7 @@
.admin-user-name { font-weight: 600; font-size: 13px; } .admin-user-name { font-weight: 600; font-size: 13px; }
.admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; } .admin-user-handle { font-size: 11px; color: var(--text-3); margin-top: 1px; }
.admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); } .admin-user-time { color: var(--text-3); font-size: 12px; font-family: var(--mono); }
.admin-actions-cell { white-space: nowrap; text-align: right; } .admin-actions-cell { display: flex; flex-wrap: wrap; gap: 4px; justify-content: flex-end; }
/* ── Provider Cards ──────────────────────── */ /* ── Provider Cards ──────────────────────── */
.provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); } .provider-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: var(--sp-4); }

View File

@@ -274,8 +274,9 @@
.sw-dropdown__value { flex: 1; text-align: left; } .sw-dropdown__value { flex: 1; text-align: left; }
.sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; } .sw-dropdown__chevron { color: var(--text-3); font-size: 0.7em; }
.sw-dropdown__list { .sw-dropdown__list {
position: absolute; top: calc(100% + 4px); left: 0; right: 0; position: absolute; top: calc(100% + 4px); left: 0;
z-index: 6000; max-height: 240px; overflow-y: auto; min-width: max-content;
z-index: 6000; max-height: 240px; overflow-y: auto; overflow-x: hidden;
background: var(--bg-surface); border: 1px solid var(--border); background: var(--bg-surface); border: 1px solid var(--border);
border-radius: var(--radius); box-shadow: var(--shadow-lg); border-radius: var(--radius); box-shadow: var(--shadow-lg);
animation: sw-fade-in 0.1s ease; animation: sw-fade-in 0.1s ease;

View File

@@ -193,7 +193,24 @@ export async function boot() {
events.connect(BASE + '/ws'); events.connect(BASE + '/ws');
} }
// 10. Signal ready // 10. Mount imperative hosts (Toast, DialogStack) if not already present.
// Core surfaces mount these via AppShell; extension surfaces need SDK to do it.
try {
const hostId = 'sw-imperative-hosts';
if (!document.getElementById(hostId)) {
const mount = document.createElement('div');
mount.id = hostId;
document.body.appendChild(mount);
const { ToastContainer } = await import('../primitives/toast.js');
const { DialogStack } = await import('../shell/dialog-stack.js');
const { render } = preact;
render(html`<${ToastContainer} /><${DialogStack} />`, mount);
}
} catch (e) {
console.warn('[sw] Imperative host mount failed:', e.message);
}
// 11. Signal ready
events.emit('sdk.ready', {}, { localOnly: true }); events.emit('sdk.ready', {}, { localOnly: true });
document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } })); document.dispatchEvent(new CustomEvent('sw:ready', { detail: { sw } }));
console.log(`[sw] SDK v${sw._sdk} ready`); console.log(`[sw] SDK v${sw._sdk} ready`);

View File

@@ -27,7 +27,7 @@ function _currentSurface() {
export function UserMenu({ placement = 'down-right', onAction, extraItems }) { export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const [open, setOpen] = useState(false); const [open, setOpen] = useState(false);
const [extSurfaces, setExtSurfaces] = useState([]); const [allSurfaces, setAllSurfaces] = useState([]);
const btnRef = useRef(null); const btnRef = useRef(null);
const sw = window.sw; const sw = window.sw;
@@ -35,17 +35,19 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
const authenticated = sw?.auth?.isAuthenticated; const authenticated = sw?.auth?.isAuthenticated;
// Fetch installed surfaces once on mount. // Fetch installed surfaces once on mount.
// Filter out core surfaces that have dedicated menu entries below. const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs', 'welcome']);
const CORE_IDS = new Set(['admin', 'settings', 'team-admin', 'workflow', 'workflow-landing', 'docs']);
useEffect(() => { useEffect(() => {
if (!sw?.api?.surfaces?.list) return; if (!sw?.api?.surfaces?.list) return;
sw.api.surfaces.list().then(data => { sw.api.surfaces.list().then(data => {
const raw = Array.isArray(data) ? data : data?.data || []; const raw = Array.isArray(data) ? data : data?.data || [];
setExtSurfaces(raw.filter(s => !CORE_IDS.has(s.id))); setAllSurfaces(raw);
}).catch(() => {}); }).catch(() => {});
}, [authenticated]); }, [authenticated]);
const extSurfaces = allSurfaces.filter(s => !CORE_IDS.has(s.id));
const enabledIds = new Set(allSurfaces.map(s => s.id));
const items = useMemo(() => { const items = useMemo(() => {
const current = _currentSurface(); const current = _currentSurface();
const list = []; const list = [];
@@ -68,13 +70,13 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
} }
// ── Standard items ───────────────────── // ── Standard items ─────────────────────
// Docs — skip if current surface IS docs // Docs — only if enabled and not current
if (current !== 'docs') { if (current !== 'docs' && enabledIds.has('docs')) {
list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' }); list.push({ label: 'Docs', action: 'docs', icon: '\ud83d\udcd6' });
} }
// Settings — skip if current surface IS settings // Settings — only if enabled and not current
if (current !== 'settings') { if (current !== 'settings' && enabledIds.has('settings')) {
list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' }); list.push({ label: 'Settings', action: 'settings', icon: '\u2699\ufe0f' });
} }
@@ -85,7 +87,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
// Team Admin — requires admin role on at least one team // Team Admin — requires admin role on at least one team
const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin'); const hasTeamAdmin = sw?.auth?.teams?.some(t => t.my_role === 'admin');
if (authenticated && hasTeamAdmin && current !== 'team-admin') { if (authenticated && hasTeamAdmin && current !== 'team-admin' && enabledIds.has('team-admin')) {
list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' }); list.push({ label: 'Team Admin', action: 'team-admin', icon: '\ud83d\udc65' });
} }
@@ -98,7 +100,7 @@ export function UserMenu({ placement = 'down-right', onAction, extraItems }) {
list.push({ label: 'Sign Out', action: 'sign-out' }); list.push({ label: 'Sign Out', action: 'sign-out' });
return list; return list;
}, [user, authenticated, extraItems, extSurfaces]); }, [user, authenticated, extraItems, extSurfaces, enabledIds]);
function handleSelect(action) { function handleSelect(action) {
setOpen(false); setOpen(false);

View File

@@ -33,7 +33,7 @@ export default function TeamsSection() {
sw.api.admin.users.list(), sw.api.admin.users.list(),
]); ]);
setMembers(m || []); setMembers(m || []);
const uList = u || []; const uList = Array.isArray(u) ? u : (u && u.data) || [];
setAllUsers(uList); setAllUsers(uList);
} catch (e) { sw.toast(e.message, 'error'); } } catch (e) { sw.toast(e.message, 'error'); }
} }