feat(gui): iOS-style app folders in the My Apps grid (#3525)

* feat(gui): iOS-style app folders in the My Apps grid

Drag one app onto another and let it settle: a well opens under the
target and the drop makes a folder of the two. Dropping onto an existing
folder joins it. A folder opens by growing out of its own icon into a
card over a blurred grid, where its apps can be launched, rearranged,
renamed, or carried back out.

Hovering a tile mid-drag means two things — "push over, I'm passing
through" and "swallow me" — and the tile is barely bigger than its icon,
so pixels can't separate them; motion does. The shuffle is held while
the folder offer stands and fires when the icon leaves the tile, or at
the drop, so a quick drop onto a neighbour still reorders exactly as it
did. The offer itself re-arms rather than cancelling on movement: the
last events of a drag are the ones carrying the icon onto the target and
nothing is dispatched while it rests, so a cancel-on-movement dwell
could never fire at all.

Folders are stored in their own kv key; the grid's ORDER stays entirely
in the existing saved app order, with a folder occupying its first
member's slot and its members contiguous. Every saved order therefore
stays valid with no migration, and an app whose installedApps page
failed to load keeps both its folder and its position. Folders never
nest, one that drops below two apps dissolves, and a corrupt kv value
degrades to "no folders" rather than a broken tab.

Elsewhere:
- Search looks THROUGH folders — a match the user then has to hunt for
  inside one is not an answer.
- Minimize morphs into the FOLDER when an app lives in one, and a
  /app/<name> landing opens the folder so the launch grows out of the
  icon where the app actually is.
- The uninstall FLIP keyed surviving tiles by app name, which a folder
  tile doesn't have; it now keys by identity.

New pure model in appGroups.js with tests; verified end to end in the
running dashboard (create, join, open, rename, reorder, eject, ungroup,
launch-from-folder) alongside plain reorder, search, and uninstall.

* fix(gui): keep the folder name field from inheriting input[type=text] sizing

style.css styles every input[type=text] with `width: 100%` and grows it to
`padding: 7px; border: 2px` on focus. `input.myapps-group-name` matches at the
same specificity, so it only wins the properties it actually declares — width
was never one of them, and the focus rule declared neither padding nor border
width. The name field therefore spanned the entire folder card (so its hover
and focus chip read as a full-width bar rather than the name) and grew 8px
taller the moment it was clicked, shoving the folder's app grid down.

Spell the three out, in both the resting and the focus rule — the same trap
.myapps-search already documents next door.

* fix(gui): size folder icon ghosts to the icon they stand on

Border-box only reaches a folder's icon through `.dashboard * { box-sizing }`,
and every ghost cloned from one is appended to <body>, outside that rule: the
drag ghost, the click-time launch flourish, and the open/minimize morph ghosts
all fall back to content-box, where .myapps-group-icon's 5px padding is added
to the 56px slot. Each ghost rendered 66px square and 5px off, so it visibly
popped at exactly the moment it was supposed to sit flush on the real icon.

State box-sizing on the rule itself so a clone carries it wherever it lands.

* fix(gui): stop a closing folder from swallowing the next click

_closeGroup drops the open class and leaves the overlay in place for
GROUP_PANEL_CLOSE_MS so the card can recede into its tile. The scrim is
`position: fixed; inset: 0` and still hit-testable for that whole quarter
second, so a click on the grid during it landed on the outgoing overlay — whose
handler only re-runs _closeGroup, now a no-op. Shutting a folder and reaching
straight for an app did nothing.

Take the outgoing overlay out of hit-testing; it has no interactive job left.

* fix(gui): keep a folder name typed right up to the moment it closes

The name box commits on blur, and every exit that goes through a pointer blurs
it while the folder is still open — so clicking outside, or launching an app
from inside, keeps what was typed. Escape does not: _closeGroup clears
_openGroupId first and only then moves focus to the tile below (or removes the
card outright), so the blur arrives with no open folder to rename and
_renameGroup drops it. A brand-new folder opens with its name selected for
exactly this edit, so "type Games, press Escape" — the obvious way to dismiss
a dialog — was the path most likely to lose it.

Commit the pending name on the way out, before the folder id is gone.

* fix(gui): close an open folder when the Apps tab is re-entered

The dashboard hides an inactive section and calls onActivate on the way back
in; there is no deactivate hook, so a folder left open survives the round trip
and greets the user still open over a grid they walked away from. Worse,
onActivate's focusSearch then lands the caret in the search box behind the
folder's scrim, and typing filters the grid the card is covering — a modal with
the keyboard pointed outside it.

Shut the folder as the tab comes back: returning to the tab is returning to
the grid.

* fix(gui): move focus into a folder when it opens

Opening a folder called .focus({ preventScroll: true }) on a jQuery
object. jQuery's .focus() shorthand reads a lone non-function argument
as event DATA and binds a handler with it, so it never moved focus:
the folder opened modal over the grid with focus still on the tile
behind its scrim, where Tab walked away through the inert grid instead
of cycling inside the dialog — and the object it bound as a handler
threw a TypeError on that tile's every subsequent focus.

Focus the DOM node instead, as every other focus call in this file
already does.

* fix(gui): stop renaming a folder from swallowing the click that commits it

The folder's name box commits on blur, and blur fires on the PRESS —
before the click that press belongs to. Committing re-rendered, and the
re-render replaced every tile in the open folder, so by the time the
click was dispatched the tile under the pointer was detached and the
delegated handler never saw it. Typing a name and then tapping an app
in the folder — the path a brand-new folder puts the user on — renamed
the folder and did nothing else; the app only opened on a second click.

Rebuild the folder's contents only when they actually differ from what
is on screen. A rename doesn't change them, so nothing is detached, and
a background refresh no longer throws away hover/focus either. Tiles
that survive get their drag resting-rects cleared, since the card can
have moved under them since the rects were taken.

* fix(gui): keep Enter in a folder's name box from leaving the folder

Committing the name with Enter blurred the box, which left focus on
<body> — outside a dialog that is marked aria-modal and that traps Tab
on its own subtree. The next Tab therefore walked off through the inert
grid the folder is covering, exactly what the trap exists to prevent.

Step out onto the folder's first app instead; the same blur still
commits the name. The keystroke is stopped at the box because the
grid's document-level key handler reads Enter on a focused tile as
"launch it", and would otherwise have taken the focus move as its cue
to open an app the user never asked for.

* fix(gui): stop an open folder clipping its own uninstall badges

The folder's grid scrolls, so it clips anything outside its padding box
— and reorder mode's uninstall badge deliberately overhangs the top-left
corner of every tile. The top row's badges therefore rendered as flat
tabs rather than circles, on the one surface where they are a touch
user's only way to uninstall an app they have filed away.

Give the scroller 9px of top padding for the overhang to sit in and take
it straight back off as margin, so the card and everything in it stays
exactly where it was.

* fix(gui): hold page edge-flips while a folder merge is being offered

A tile in the pager's last column sits inside the 60px edge-flip zone, so
resting a dragged icon on it — the folder-making gesture — armed the edge
dwell alongside the merge dwell, and the page flipped out from under the
very folder the user was watching form (the merge target scrolls away
mid-offer, and the drop then resolves against its stale resting rect).
Worst on phones, where 72px tiles overlap the zone across the whole last
column.

A live merge offer now holds the edge flip: entering the zone arms no
dwell while an offer stands, and an offer that arrives during a running
dwell is re-checked at the flip (a resting pointer fires no event that
could clear the timer). Carrying the icon off the tile withdraws the offer
and the hold with it, so deliberate flips — resting in the bare edge
gutter — behave as before.

* fix(gui): make Escape cancel a folder rename instead of saving it

Escape pressed mid-edit fell through to the folder's close handler, and
closing commits whatever the name box holds — so the one key every inline
rename uses for "never mind" stored the abandoned half-typed name. Escape
in the box now puts the stored name back and steps out to the folder's
tiles, exactly the cancel Finder/Explorer taught; with nothing left to
cancel (name untouched) it falls through and closes the folder as before,
so a second press still exits.

* fix(gui): refill the folder well when the merge countdown restarts

The merge dwell pins its stillness anchor at first contact with the tile,
and a hand decelerating INTO a target routinely covers more than the
7px allowance between that moment and the first tick — so the countdown
quietly restarts. The well's fill, tuned to the same 460ms, had already
completed by then and just sat there half-open: a drop it seemed to
promise a folder for actually reordered. Restart the fill with the
countdown, so what the well shows is always the countdown that is
actually running.

* fix(gui): keep keyboard focus inside an open folder across its edits

The folder card is a modal dialog, but three of its flows stranded focus
on <body>, where Tab walks the inert grid behind the scrim:

- Remove from Folder rebuilds the card's grid after the context menu has
  dropped focus, so nothing inside the dialog holds it. The rebuild now
  hands focus back to the same app's tile (or the first) whenever it finds
  focus on <body> — never stealing from an uninstall modal, which holds it
  legitimately.
- The same eject dissolving the folder re-renders the grid right after
  _closeGroup's focus hand-back, replacing the very tile it chose. The
  ejected app's own tile — where the user is looking — takes focus instead.
- A click on the card's empty space focused nothing at all. The card now
  carries tabindex=-1 so such clicks land on it (no visible ring; a focus
  ring around the whole card would misread plumbing as selection), and the
  Tab trap wraps from there instead of stepping off through the scrim.

* fix(gui): size the folder name box to the name it holds

The box sat at the browser's default ~20ch regardless of content: a short
name floated in a hover pill far wider than the word, and a 40-character
one clipped while the card had room to spare. field-sizing: content hugs
the text between a 90px floor and the card's width, iOS-style; engines
without the property keep the default box exactly as before.
This commit is contained in:
Nariman Jelveh
2026-08-08 00:28:03 -07:00
committed by GitHub
parent aea828ff12
commit 06d94534e8
6 changed files with 2104 additions and 71 deletions
File diff suppressed because it is too large Load Diff
+416
View File
@@ -0,0 +1,416 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* The My Apps folder model. A folder is a named set of app names; the grid's
* left-to-right ORDER still lives entirely in the saved app order (see
* appOrder.js) a folder occupies the slot of its first member, and its
* members sit contiguously in that flat order. Keeping the two records
* orthogonal means an app that is temporarily missing (an installedApps page
* that failed to load) keeps both its folder and its position, and every
* existing saved order stays valid without migration.
*/
/** kv key under which the user's My Apps folders are stored. */
export const APP_GROUPS_KV_KEY = 'dashboard_app_groups';
/** Longest folder name that is stored; longer input is clipped. */
export const MAX_GROUP_NAME_LENGTH = 40;
/** Sanity caps, so a corrupt (or hostile) kv value can't wedge the grid. */
export const MAX_GROUPS = 100;
export const MAX_GROUP_APPS = 100;
/**
* @typedef {{ id: string, name: string, apps: string[] }} AppGroup
* @typedef {{ type: 'app', app: object } | { type: 'group', group: AppGroup, apps: object[] }} GridItem
*/
/**
* Trim a folder name to what is worth storing: whitespace collapsed, clipped
* to {@link MAX_GROUP_NAME_LENGTH}. Anything unusable becomes '' callers
* decide whether that means "keep the old name" (rename) or "use the default"
* (creation).
*
* @param {unknown} name
* @returns {string}
*/
export function normalizeGroupName (name) {
if ( typeof name !== 'string' ) return '';
return name.replace(/\s+/g, ' ').trim().slice(0, MAX_GROUP_NAME_LENGTH);
}
/**
* Parse the persisted folders value. Tolerates every shape kv can hand back
* (a JSON string, an already-deserialized array, null for "never saved") and
* any corruption inside it. Two invariants are enforced here rather than at
* every call site: an app belongs to at most one folder (first claim wins),
* and a folder always has at least two members a folder of one is strictly
* worse than a plain tile, so it is dropped and its member becomes loose.
* Corrupt input degrades to "no folders", never to a broken Apps tab.
*
* @param {unknown} raw - value returned by `puter.kv.get`
* @returns {AppGroup[]}
*/
export function parseAppGroups (raw) {
let list = raw;
if ( typeof raw === 'string' ) {
try {
list = JSON.parse(raw);
} catch ( _e ) {
return [];
}
}
if ( ! Array.isArray(list) ) return [];
const out = [];
const seenIds = new Set();
const claimed = new Set();
for ( const entry of list ) {
if ( ! entry || typeof entry !== 'object' ) continue;
const id = typeof entry.id === 'string' ? entry.id : '';
if ( ! id || seenIds.has(id) ) continue;
const apps = [];
if ( Array.isArray(entry.apps) ) {
for ( const name of entry.apps ) {
if ( typeof name !== 'string' || name.length === 0 ) continue;
if ( claimed.has(name) ) continue;
apps.push(name);
claimed.add(name);
if ( apps.length >= MAX_GROUP_APPS ) break;
}
}
if ( apps.length < 2 ) {
// Release the names so a later, well-formed folder can claim them.
for ( const name of apps ) claimed.delete(name);
continue;
}
seenIds.add(id);
out.push({ id, name: normalizeGroupName(entry.name), apps });
if ( out.length >= MAX_GROUPS ) break;
}
return out;
}
/**
* Serialize folders to the persisted shape. Runs the value back through
* {@link parseAppGroups} so the read and write shapes stay in lockstep and a
* folder that an edit emptied out can never be written.
*
* @param {AppGroup[]} groups
* @returns {AppGroup[]}
*/
export function serializeAppGroups (groups) {
return parseAppGroups(Array.isArray(groups) ? groups : []);
}
/**
* An id for a new folder. Folders are stored as one kv value that is written
* whole, so two devices creating a folder at the same moment already resolve
* last-write-wins; the id only has to be unique enough that a surviving
* record never collides with one made elsewhere.
*
* @returns {string}
*/
export function makeGroupId () {
return `g${Date.now().toString(36)}${Math.random().toString(36).slice(2, 8)}`;
}
/**
* A default name for a new folder: `base`, then `base 2`, `base 3`, so two
* folders are never named the same thing. The base is passed in (rather than
* read from i18n here) to keep this module free of UI dependencies.
*
* @param {AppGroup[]} groups
* @param {string} base
* @returns {string}
*/
export function defaultGroupName (groups, base) {
const taken = new Set(
(Array.isArray(groups) ? groups : []).map(g => g && g.name),
);
if ( ! taken.has(base) ) return base;
for ( let n = 2; n < MAX_GROUPS + 2; n++ ) {
const candidate = `${base} ${n}`;
if ( ! taken.has(candidate) ) return candidate;
}
return base;
}
/**
* The folder holding `appName`, or null when the app is loose.
*
* @param {AppGroup[]} groups
* @param {string} appName
* @returns {AppGroup|null}
*/
export function findGroupOfApp (groups, appName) {
if ( ! Array.isArray(groups) || typeof appName !== 'string' ) return null;
for ( const g of groups ) {
if ( g && Array.isArray(g.apps) && g.apps.includes(appName) ) return g;
}
return null;
}
/**
* Fold an ordered app list into the items the grid actually renders: loose
* apps stay as they are, and each folder is emitted once, at the slot of its
* first present member, carrying its members in the folder's own order.
*
* A folder whose members mostly failed to load renders as whatever it has:
* with fewer than two present members its member is drawn as a plain tile and
* the record is left untouched the same "stale names are ignored, never
* destroyed" rule reconcileAppOrder follows, so a flaky page of installedApps
* can't dissolve a folder.
*
* @param {Array<{name: string}>} apps - apps in grid order
* @param {AppGroup[]} groups
* @returns {GridItem[]}
*/
export function buildGridItems (apps, groups) {
if ( ! Array.isArray(apps) ) return [];
const list = Array.isArray(groups) ? groups : [];
if ( list.length === 0 ) return apps.map(app => ({ type: 'app', app }));
const owner = new Map();
for ( const g of list ) {
if ( ! g || ! Array.isArray(g.apps) ) continue;
for ( const name of g.apps ) {
if ( ! owner.has(name) ) owner.set(name, g);
}
}
// Members present in `apps`, in the folder's own order.
const members = new Map();
for ( const app of apps ) {
const g = owner.get(app && app.name);
if ( ! g ) continue;
if ( ! members.has(g.id) ) members.set(g.id, []);
members.get(g.id).push(app);
}
for ( const g of list ) {
const present = members.get(g.id);
if ( ! present || present.length < 2 ) continue;
const rank = new Map(g.apps.map((name, i) => [name, i]));
present.sort((a, b) => rank.get(a.name) - rank.get(b.name));
}
const emitted = new Set();
const items = [];
for ( const app of apps ) {
const g = owner.get(app && app.name);
const present = g ? (members.get(g.id) || []) : [];
if ( ! g || present.length < 2 ) {
items.push({ type: 'app', app });
continue;
}
if ( emitted.has(g.id) ) continue;
emitted.add(g.id);
items.push({ type: 'group', group: g, apps: present });
}
return items;
}
/**
* The apps behind {@link buildGridItems}' output, flattened back to a single
* ordered list folder members contiguous, in folder order. This is the
* shape the saved app order wants, so a folder edit and a drag both persist
* through the same path.
*
* @param {GridItem[]} items
* @returns {object[]}
*/
export function flattenGridItems (items) {
const out = [];
if ( ! Array.isArray(items) ) return out;
for ( const item of items ) {
if ( ! item ) continue;
if ( item.type === 'group' ) out.push(...(item.apps || []));
else if ( item.app ) out.push(item.app);
}
return out;
}
/**
* Move `movedName` to sit immediately after the last of `anchorNames` in a
* flat order how a drop into a folder places the app beside the rest of
* that folder's members, and how ejecting one places it beside the folder it
* came out of. With no anchor present the name goes to the tail rather than
* jumping the queue at the front. The input array is not mutated.
*
* @param {string[]} names
* @param {string} movedName
* @param {string[]} anchorNames
* @returns {string[]}
*/
export function orderWithAppAfter (names, movedName, anchorNames) {
const out = (Array.isArray(names) ? names : []).filter(name => name !== movedName);
const anchors = new Set(Array.isArray(anchorNames) ? anchorNames : []);
let at = -1;
for ( let i = 0; i < out.length; i++ ) {
if ( anchors.has(out[i]) ) at = i;
}
if ( at === -1 ) out.push(movedName);
else out.splice(at + 1, 0, movedName);
return out;
}
/**
* A new folder holding `appNames`, replacing any folder membership those apps
* already had. Returns the new folder list and the new folder's id; a folder
* left with fewer than two members by the move dissolves (serializeAppGroups
* enforces it). Returns `{ groups, id: null }` unchanged when there aren't two
* distinct apps to put in it.
*
* @param {AppGroup[]} groups
* @param {string[]} appNames
* @param {string} name
* @returns {{ groups: AppGroup[], id: string|null }}
*/
export function createGroup (groups, appNames, name) {
const members = [];
for ( const appName of (Array.isArray(appNames) ? appNames : []) ) {
if ( typeof appName !== 'string' || appName.length === 0 ) continue;
if ( ! members.includes(appName) ) members.push(appName);
}
if ( members.length < 2 ) {
return { groups: serializeAppGroups(groups), id: null };
}
const id = makeGroupId();
const stripped = withoutApps(groups, members);
return {
groups: serializeAppGroups([
...stripped,
{ id, name: normalizeGroupName(name), apps: members.slice(0, MAX_GROUP_APPS) },
]),
id,
};
}
/**
* Add `appName` to the folder `groupId` (at the end, where a drop lands),
* taking it out of whatever folder it was in. A no-op when the folder is
* gone or full.
*
* @param {AppGroup[]} groups
* @param {string} groupId
* @param {string} appName
* @returns {AppGroup[]}
*/
export function addAppToGroup (groups, groupId, appName) {
const target = findGroupById(groups, groupId);
if ( ! target || typeof appName !== 'string' || appName.length === 0 ) {
return serializeAppGroups(groups);
}
if ( target.apps.includes(appName) ) return serializeAppGroups(groups);
if ( target.apps.length >= MAX_GROUP_APPS ) return serializeAppGroups(groups);
return serializeAppGroups(withoutApps(groups, [appName]).map(g => (
g.id === groupId ? { ...g, apps: [...g.apps, appName] } : g
)));
}
/**
* Take `appName` out of every folder. The folder it leaves dissolves if that
* empties it below two members.
*
* @param {AppGroup[]} groups
* @param {string} appName
* @returns {AppGroup[]}
*/
export function removeAppFromGroups (groups, appName) {
return serializeAppGroups(withoutApps(groups, [appName]));
}
/**
* Dissolve a folder; its members become loose tiles where the folder stood.
*
* @param {AppGroup[]} groups
* @param {string} groupId
* @returns {AppGroup[]}
*/
export function removeGroup (groups, groupId) {
return serializeAppGroups(
(Array.isArray(groups) ? groups : []).filter(g => g && g.id !== groupId),
);
}
/**
* Rename a folder. An unusable name (empty, whitespace only) leaves the
* existing one alone a nameless folder is a folder the user can't tell
* apart from the next one.
*
* @param {AppGroup[]} groups
* @param {string} groupId
* @param {string} name
* @returns {AppGroup[]}
*/
export function renameGroup (groups, groupId, name) {
const clean = normalizeGroupName(name);
if ( ! clean ) return serializeAppGroups(groups);
return serializeAppGroups((Array.isArray(groups) ? groups : []).map(g => (
g && g.id === groupId ? { ...g, name: clean } : g
)));
}
/**
* Re-order a folder's members. Names not in `appNames` (members that weren't
* on screen to be dragged) keep their relative order at the tail, so
* reordering what you can see never drops what you can't.
*
* @param {AppGroup[]} groups
* @param {string} groupId
* @param {string[]} appNames
* @returns {AppGroup[]}
*/
export function reorderGroupApps (groups, groupId, appNames) {
const target = findGroupById(groups, groupId);
if ( ! target ) return serializeAppGroups(groups);
const wanted = (Array.isArray(appNames) ? appNames : [])
.filter(name => target.apps.includes(name));
const seen = new Set(wanted);
const apps = [...new Set(wanted), ...target.apps.filter(name => ! seen.has(name))];
return serializeAppGroups((Array.isArray(groups) ? groups : []).map(g => (
g && g.id === groupId ? { ...g, apps } : g
)));
}
/**
* @param {AppGroup[]} groups
* @param {string} groupId
* @returns {AppGroup|null}
*/
export function findGroupById (groups, groupId) {
if ( ! Array.isArray(groups) || typeof groupId !== 'string' ) return null;
return groups.find(g => g && g.id === groupId && Array.isArray(g.apps)) || null;
}
/** Every folder with `names` removed from it; folders are left unsealed (a
* caller's serializeAppGroups drops any that fell below two members). */
function withoutApps (groups, names) {
const drop = new Set(names);
return (Array.isArray(groups) ? groups : [])
.filter(g => g && Array.isArray(g.apps))
.map(g => ({ ...g, apps: g.apps.filter(name => ! drop.has(name)) }));
}
+332
View File
@@ -0,0 +1,332 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { describe, it, expect } from 'vitest';
import {
parseAppGroups,
serializeAppGroups,
normalizeGroupName,
defaultGroupName,
findGroupOfApp,
findGroupById,
buildGridItems,
flattenGridItems,
orderWithAppAfter,
createGroup,
addAppToGroup,
removeAppFromGroups,
removeGroup,
renameGroup,
reorderGroupApps,
MAX_GROUP_NAME_LENGTH,
} from './appGroups.js';
import { reconcileAppOrder, serializeAppOrder } from './appOrder.js';
const mk = (...ns) => ns.map(n => ({ name: n }));
const names = apps => apps.map(a => a.name);
const group = (id, name, ...apps) => ({ id, name, apps });
// The grid as the user reads it: 'a' for a loose app, '[Work: a, b]' for a folder.
const shape = items => items.map(item => (
item.type === 'group'
? `[${item.group.name}: ${names(item.apps).join(', ')}]`
: item.app.name
));
describe('normalizeGroupName', () => {
it('collapses whitespace and trims', () => {
expect(normalizeGroupName(' Work Stuff \n')).toBe('Work Stuff');
});
it('clips to the stored maximum', () => {
expect(normalizeGroupName('x'.repeat(200))).toHaveLength(MAX_GROUP_NAME_LENGTH);
});
it('returns an empty string for anything unusable', () => {
expect(normalizeGroupName(' ')).toBe('');
expect(normalizeGroupName(null)).toBe('');
expect(normalizeGroupName(42)).toBe('');
});
});
describe('parseAppGroups', () => {
it('parses a JSON string and an already-deserialized array alike', () => {
const raw = [group('g1', 'Work', 'a', 'b')];
expect(parseAppGroups(JSON.stringify(raw))).toEqual(raw);
expect(parseAppGroups(raw)).toEqual(raw);
});
it('returns no folders for never-saved or corrupt values', () => {
expect(parseAppGroups(null)).toEqual([]);
expect(parseAppGroups('not json')).toEqual([]);
expect(parseAppGroups('{"nope":1}')).toEqual([]);
expect(parseAppGroups(7)).toEqual([]);
});
it('drops folders of fewer than two apps', () => {
// A folder of one is strictly worse than a plain tile.
const out = parseAppGroups([group('g1', 'Solo', 'a'), group('g2', 'Pair', 'b', 'c')]);
expect(out.map(g => g.id)).toEqual(['g2']);
});
it('gives an app claimed by two folders to the first, and drops the loser if that empties it', () => {
const out = parseAppGroups([
group('g1', 'First', 'a', 'b'),
group('g2', 'Second', 'a', 'b'),
group('g3', 'Third', 'a', 'c', 'd'),
]);
expect(out.map(g => g.id)).toEqual(['g1', 'g3']);
expect(out[1].apps).toEqual(['c', 'd']);
});
it('drops entries without a usable id and de-duplicates ids', () => {
const out = parseAppGroups([
{ name: 'No id', apps: ['a', 'b'] },
group('g1', 'Keep', 'a', 'b'),
group('g1', 'Dup id', 'c', 'd'),
]);
expect(out).toEqual([group('g1', 'Keep', 'a', 'b')]);
});
it('drops non-string and duplicate member names', () => {
const out = parseAppGroups([{ id: 'g1', name: 'Work', apps: ['a', '', null, 'a', 'b', 3] }]);
expect(out[0].apps).toEqual(['a', 'b']);
});
it('normalizes the name', () => {
expect(parseAppGroups([{ id: 'g1', name: ' Work ', apps: ['a', 'b'] }])[0].name).toBe('Work');
expect(parseAppGroups([{ id: 'g1', apps: ['a', 'b'] }])[0].name).toBe('');
});
});
describe('serializeAppGroups', () => {
it('round-trips through parseAppGroups', () => {
const groups = [group('g1', 'Work', 'a', 'b')];
expect(parseAppGroups(JSON.stringify(serializeAppGroups(groups)))).toEqual(groups);
});
it('drops a folder an edit emptied below two members', () => {
expect(serializeAppGroups([group('g1', 'Work', 'a')])).toEqual([]);
});
});
describe('defaultGroupName', () => {
it('uses the base name when it is free', () => {
expect(defaultGroupName([], 'Folder')).toBe('Folder');
});
it('numbers around names already in use', () => {
const groups = [group('g1', 'Folder', 'a', 'b'), group('g2', 'Folder 2', 'c', 'd')];
expect(defaultGroupName(groups, 'Folder')).toBe('Folder 3');
});
});
describe('findGroupOfApp / findGroupById', () => {
const groups = [group('g1', 'Work', 'a', 'b')];
it('finds the folder holding an app', () => {
expect(findGroupOfApp(groups, 'b').id).toBe('g1');
expect(findGroupOfApp(groups, 'z')).toBe(null);
expect(findGroupOfApp(null, 'a')).toBe(null);
});
it('finds a folder by id', () => {
expect(findGroupById(groups, 'g1').name).toBe('Work');
expect(findGroupById(groups, 'nope')).toBe(null);
});
});
describe('buildGridItems', () => {
it('passes apps straight through when there are no folders', () => {
expect(shape(buildGridItems(mk('a', 'b'), []))).toEqual(['a', 'b']);
expect(shape(buildGridItems(mk('a', 'b'), null))).toEqual(['a', 'b']);
});
it('puts a folder in the slot of its first present member', () => {
const items = buildGridItems(mk('a', 'b', 'c', 'd'), [group('g1', 'Work', 'b', 'd')]);
expect(shape(items)).toEqual(['a', '[Work: b, d]', 'c']);
});
it('orders members by the folder record, not by grid order', () => {
const items = buildGridItems(mk('a', 'b', 'c'), [group('g1', 'Work', 'c', 'a')]);
expect(shape(items)).toEqual(['[Work: c, a]', 'b']);
});
it('renders a folder whose members mostly failed to load as a loose tile', () => {
// 'b' is missing this session; the record is untouched, but one member
// is not a folder — it draws as the plain app it is.
const items = buildGridItems(mk('a', 'c'), [group('g1', 'Work', 'a', 'b')]);
expect(shape(items)).toEqual(['a', 'c']);
});
it('drops nothing when every member is missing', () => {
expect(shape(buildGridItems(mk('c'), [group('g1', 'Work', 'a', 'b')]))).toEqual(['c']);
});
it('does not mutate the folder records it reads', () => {
const groups = [group('g1', 'Work', 'c', 'a')];
buildGridItems(mk('a', 'b', 'c'), groups);
expect(groups[0].apps).toEqual(['c', 'a']);
});
it('handles non-array input defensively', () => {
expect(buildGridItems(null, [])).toEqual([]);
});
});
describe('flattenGridItems', () => {
it('expands folders in place, members contiguous', () => {
const apps = mk('a', 'b', 'c', 'd');
const items = buildGridItems(apps, [group('g1', 'Work', 'b', 'd')]);
expect(names(flattenGridItems(items))).toEqual(['a', 'b', 'd', 'c']);
});
it('round-trips into a saved app order that rebuilds the same grid', () => {
const apps = mk('a', 'b', 'c', 'd');
const groups = [group('g1', 'Work', 'b', 'd')];
const order = serializeAppOrder(flattenGridItems(buildGridItems(apps, groups)));
const rebuilt = buildGridItems(reconcileAppOrder(apps, order), groups);
expect(shape(rebuilt)).toEqual(['a', '[Work: b, d]', 'c']);
});
it('handles non-array input defensively', () => {
expect(flattenGridItems(null)).toEqual([]);
});
});
describe('orderWithAppAfter', () => {
it('moves a name to just after the last anchor', () => {
expect(orderWithAppAfter(['a', 'b', 'c', 'd'], 'd', ['b', 'c'])).toEqual(['a', 'b', 'c', 'd']);
expect(orderWithAppAfter(['a', 'b', 'c', 'd'], 'a', ['b', 'c'])).toEqual(['b', 'c', 'a', 'd']);
});
it('appends when no anchor is present rather than jumping to the front', () => {
expect(orderWithAppAfter(['a', 'b'], 'a', ['zz'])).toEqual(['b', 'a']);
});
it('inserts a name that was not in the list at all', () => {
expect(orderWithAppAfter(['a', 'b'], 'new', ['a'])).toEqual(['a', 'new', 'b']);
});
it('does not mutate its input', () => {
const order = ['a', 'b', 'c'];
orderWithAppAfter(order, 'c', ['a']);
expect(order).toEqual(['a', 'b', 'c']);
});
});
describe('createGroup', () => {
it('creates a folder from two apps', () => {
const { groups, id } = createGroup([], ['a', 'b'], 'Work');
expect(id).toBeTruthy();
expect(groups).toEqual([{ id, name: 'Work', apps: ['a', 'b'] }]);
});
it('takes the apps out of the folders they were in', () => {
const before = [group('g1', 'Old', 'a', 'x', 'y')];
const { groups, id } = createGroup(before, ['a', 'b'], 'New');
expect(groups.find(g => g.id === 'g1').apps).toEqual(['x', 'y']);
expect(groups.find(g => g.id === id).apps).toEqual(['a', 'b']);
});
it('dissolves a folder the move emptied below two members', () => {
const { groups, id } = createGroup([group('g1', 'Old', 'a', 'x')], ['a', 'b'], 'New');
expect(groups.map(g => g.id)).toEqual([id]);
});
it('refuses to make a folder without two distinct apps', () => {
expect(createGroup([], ['a', 'a'], 'Work')).toEqual({ groups: [], id: null });
expect(createGroup([], ['a'], 'Work')).toEqual({ groups: [], id: null });
});
});
describe('addAppToGroup', () => {
it('appends to the folder, where the drop landed', () => {
const out = addAppToGroup([group('g1', 'Work', 'a', 'b')], 'g1', 'c');
expect(out[0].apps).toEqual(['a', 'b', 'c']);
});
it('moves the app out of the folder it was in', () => {
const before = [group('g1', 'Work', 'a', 'b'), group('g2', 'Play', 'c', 'd')];
const out = addAppToGroup(before, 'g1', 'c');
expect(out.find(g => g.id === 'g1').apps).toEqual(['a', 'b', 'c']);
// 'g2' is down to one member, so it dissolves.
expect(out.find(g => g.id === 'g2')).toBeUndefined();
});
it('is a no-op for an unknown folder or an app already in it', () => {
const before = [group('g1', 'Work', 'a', 'b')];
expect(addAppToGroup(before, 'nope', 'c')).toEqual(before);
expect(addAppToGroup(before, 'g1', 'a')).toEqual(before);
});
it('does not mutate its input', () => {
const before = [group('g1', 'Work', 'a', 'b')];
addAppToGroup(before, 'g1', 'c');
expect(before[0].apps).toEqual(['a', 'b']);
});
});
describe('removeAppFromGroups / removeGroup', () => {
it('takes an app out of its folder', () => {
const out = removeAppFromGroups([group('g1', 'Work', 'a', 'b', 'c')], 'b');
expect(out[0].apps).toEqual(['a', 'c']);
});
it('dissolves the folder when that leaves one member', () => {
expect(removeAppFromGroups([group('g1', 'Work', 'a', 'b')], 'b')).toEqual([]);
});
it('dissolves a folder outright', () => {
const before = [group('g1', 'Work', 'a', 'b'), group('g2', 'Play', 'c', 'd')];
expect(removeGroup(before, 'g1').map(g => g.id)).toEqual(['g2']);
});
});
describe('renameGroup', () => {
it('renames and normalizes', () => {
expect(renameGroup([group('g1', 'Work', 'a', 'b')], 'g1', ' Play ')[0].name).toBe('Play');
});
it('keeps the old name when the new one is unusable', () => {
expect(renameGroup([group('g1', 'Work', 'a', 'b')], 'g1', ' ')[0].name).toBe('Work');
});
});
describe('reorderGroupApps', () => {
it('applies the on-screen order', () => {
const out = reorderGroupApps([group('g1', 'Work', 'a', 'b', 'c')], 'g1', ['c', 'a', 'b']);
expect(out[0].apps).toEqual(['c', 'a', 'b']);
});
it('keeps members that were not on screen at the tail', () => {
// 'm' did not load this session, so no drag could place it.
const out = reorderGroupApps([group('g1', 'Work', 'a', 'm', 'b')], 'g1', ['b', 'a']);
expect(out[0].apps).toEqual(['b', 'a', 'm']);
});
it('ignores names that are not members', () => {
const out = reorderGroupApps([group('g1', 'Work', 'a', 'b')], 'g1', ['zz', 'b', 'a']);
expect(out[0].apps).toEqual(['b', 'a']);
});
it('is a no-op for an unknown folder', () => {
const before = [group('g1', 'Work', 'a', 'b')];
expect(reorderGroupApps(before, 'nope', ['b', 'a'])).toEqual(before);
});
});
+25 -7
View File
@@ -4148,22 +4148,40 @@ $.fn.focusWindow = function (event) {
* tab AND the tile must sit on the pager page currently in view (pages are
* laid side by side in a horizontal scroller, so an off-page tile has a
* rendered box the user can't see). Returns the tile element, or null.
*
* An app filed away in a folder has no tile of its own while the folder is
* shut its FOLDER's tile stands in, so minimizing sends the window to where
* the user will actually look for the app (and where opening it from will put
* it back). See buildGroupTileHtml for the data-group-apps this reads.
*/
function dashboard_tile_in_view (app_name) {
if ( ! app_name || typeof CSS === 'undefined' || ! CSS.escape ) return null;
const tiles = document.querySelectorAll(
`.dashboard-section-apps.active .myapps-tile[data-app-name="${CSS.escape(app_name)}"]`,
);
for ( const tile of tiles ) {
const in_view = tile => {
const rect = tile.getBoundingClientRect();
if ( rect.width <= 0 || rect.height <= 0 ) continue;
if ( rect.width <= 0 || rect.height <= 0 ) return false;
const scroller = tile.closest('.myapps-pager-scroller');
const clip = (scroller || tile.parentElement).getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
if ( cx >= clip.left && cx <= clip.right && cy >= clip.top && cy <= clip.bottom ) {
return tile;
return cx >= clip.left && cx <= clip.right && cy >= clip.top && cy <= clip.bottom;
};
const tiles = document.querySelectorAll(
`.dashboard-section-apps.active .myapps-tile[data-app-name="${CSS.escape(app_name)}"]`,
);
for ( const tile of tiles ) {
if ( in_view(tile) ) return tile;
}
const folders = document.querySelectorAll('.dashboard-section-apps.active .myapps-group-tile');
for ( const folder of folders ) {
let names;
try {
names = JSON.parse(folder.dataset.groupApps || '[]');
} catch ( _e ) {
continue;
}
if ( Array.isArray(names) && names.includes(app_name) && in_view(folder) ) return folder;
}
return null;
}
+302 -10
View File
@@ -112,6 +112,15 @@
--dashboard-legacy-bar-start: #dbe3ef;
--dashboard-legacy-bar-mid: #c2ccdc;
/* App folders (My Apps): the icon's container, the well that opens under a
drag about to make one, and the scrim + card of an opened folder. All
translucent, so they read as a layer over the grid rather than a slab. */
--dashboard-folder-surface: rgba(118, 130, 152, 0.16);
--dashboard-folder-border: rgba(15, 23, 42, 0.07);
--dashboard-folder-well: rgba(118, 130, 152, 0.32);
--dashboard-folder-scrim: rgba(244, 246, 250, 0.55);
--dashboard-folder-panel: rgba(255, 255, 255, 0.78);
}
body {
@@ -221,6 +230,12 @@ body {
--dashboard-legacy-bar-start: #3f3f46;
--dashboard-legacy-bar-mid: #52525b;
--dashboard-folder-surface: rgba(255, 255, 255, 0.13);
--dashboard-folder-border: rgba(255, 255, 255, 0.09);
--dashboard-folder-well: rgba(255, 255, 255, 0.24);
--dashboard-folder-scrim: rgba(9, 9, 11, 0.55);
--dashboard-folder-panel: rgba(46, 46, 50, 0.82);
}
}
@@ -719,6 +734,17 @@ body {
height: 100%;
display: flex;
flex-direction: column;
/* Tile geometry, declared on the tab so BOTH the pager (which reads these
via getComputedStyle in computeLayout, inheritance included) and the
folder panel a fixed overlay outside the container lay tiles out to
the same measurements. */
--myapps-tile-w: 100px;
/* icon 56 + label margin 10 + one 12px/1.3 label line (~16) */
--myapps-tile-h: 82px;
--myapps-gap-x: 32px;
--myapps-gap-y: 32px;
--myapps-dots-h: 28px;
--myapps-icon-size: 56px;
}
.myapps-search-wrap {
@@ -857,13 +883,8 @@ input.myapps-search:disabled {
}
.myapps-container {
/* Pager geometry; TabApps.js reads these to size pages. */
--myapps-tile-w: 100px;
/* icon 56 + label margin 10 + one 12px/1.3 label line (~16) */
--myapps-tile-h: 82px;
--myapps-gap-x: 32px;
--myapps-gap-y: 32px;
--myapps-dots-h: 28px;
/* Pager geometry is inherited from .myapps-tab; TabApps.js reads it here
(computeLayout) to size pages. */
flex: 1;
min-height: 0;
display: flex;
@@ -1131,6 +1152,240 @@ input.myapps-search:disabled {
word-break: break-word;
}
/* -- App folders --
A folder wears the same tile skeleton as an app, with its icon slot filled
by iOS's miniature 3x3 grid of what is inside. */
.myapps-group-icon {
/* Overrides .myapps-tile-icon's centring flexbox: the mini-grid fills the
slot rather than sitting in the middle of it. */
display: block;
/* Stated rather than left to `.dashboard *`: the drag ghost and the launch
/ minimize morph ghosts are CLONES of this box parked on <body>, outside
that rule, where the padding below would be added to the 56px slot and
land a 66px ghost over the 56px icon it is meant to sit on. */
box-sizing: border-box;
padding: 5px;
border-radius: 14px;
background: var(--dashboard-folder-surface);
box-shadow: inset 0 0 0 1px var(--dashboard-folder-border);
-webkit-backdrop-filter: blur(2px);
backdrop-filter: blur(2px);
}
.myapps-group-icon-grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 2.5px;
width: 100%;
height: 100%;
}
.myapps-group-icon-grid img {
/* min-* 0: grid items refuse to shrink past their intrinsic size
otherwise, and a wide icon would push the mini-grid out of the slot. */
min-width: 0;
min-height: 0;
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 3px;
}
/* The well a drag opens under the tile it is hovering: the folder-to-be,
growing behind the icon. Present (transparent) on every tile so it can
animate in without a reflow; only a drag ever reveals it. */
.myapps-tile::before {
content: '';
position: absolute;
top: -6px;
left: 50%;
width: calc(var(--myapps-icon-size, 56px) + 12px);
height: calc(var(--myapps-icon-size, 56px) + 12px);
margin-left: calc((var(--myapps-icon-size, 56px) + 12px) / -2);
border-radius: 16px;
background: var(--dashboard-folder-well);
box-shadow: inset 0 0 0 1px var(--dashboard-folder-border);
opacity: 0;
transform: scale(0.62);
pointer-events: none;
}
/* Pending: the well fills over the dwell the drop is waiting out
(DRAG_MERGE_DWELL_MS in TabApps.js) for the ordinary gesture (carry the
icon over, stop) the fill IS the countdown, so leaving before it completes
is an informed choice rather than a surprise. */
body.myapps-reordering .myapps-tile-merge-pending::before {
opacity: 0.55;
transform: scale(0.9);
transition: opacity 460ms ease-out, transform 460ms ease-out;
}
/* Armed: the drop will make (or join) a folder. Snaps open with a little
overshoot, and the icon settles into the well. */
body.myapps-reordering .myapps-tile-merge-armed::before {
opacity: 1;
transform: scale(1);
transition: opacity 140ms ease-out, transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* animation: none reorder mode's jiggle is an animated transform and would
otherwise win over these scales, leaving the target wobbling as if nothing
were about to happen to it. */
body.myapps-reordering .myapps-tile.myapps-tile-merge-pending .myapps-tile-icon {
animation: none;
transform: scale(0.92);
}
body.myapps-reordering .myapps-tile.myapps-tile-merge-armed .myapps-tile-icon {
animation: none;
transform: scale(0.78);
transition: transform 180ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
/* -- Opened folder --
The grid recedes behind a blurred scrim and the folder grows out of its own
icon into a card (see _animateGroupPanelOpen). Sits inside the Apps tab, so
it travels with the dashboard window; below the uninstall modal's z-index. */
.myapps-group-overlay {
position: fixed;
inset: 0;
z-index: 250;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
}
.myapps-group-backdrop {
position: absolute;
inset: 0;
background: var(--dashboard-folder-scrim);
-webkit-backdrop-filter: blur(18px) saturate(1.3);
backdrop-filter: blur(18px) saturate(1.3);
opacity: 0;
transition: opacity 0.24s ease;
}
.myapps-group-open .myapps-group-backdrop {
opacity: 1;
}
.myapps-group-panel {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
max-width: 100%;
padding: 22px 26px 26px;
border-radius: 28px;
background: var(--dashboard-folder-panel);
-webkit-backdrop-filter: blur(28px) saturate(1.6);
backdrop-filter: blur(28px) saturate(1.6);
box-shadow:
inset 0 0 0 1px var(--dashboard-folder-border),
0 24px 64px var(--dashboard-shadow-medium);
opacity: 0;
transform: scale(0.92);
transform-origin: center;
/* Mirrors GROUP_PANEL_OPEN_MS / GROUP_PANEL_CLOSE_MS in TabApps.js; the
curve is the window morph's, so a folder opening and an app opening
feel like the same gesture at two scales. */
transition: transform 0.24s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.2s ease;
will-change: transform;
}
.myapps-group-open .myapps-group-panel {
opacity: 1;
transform: none;
transition: transform 0.38s cubic-bezier(0.32, 0.72, 0, 1), opacity 0.22s ease;
}
/* The card's tabindex=-1 exists to catch clicks on its empty space (keeping
focus inside the modal folder, where the Tab trap can see it) a ring
around the whole card would misread that plumbing as selection. */
.myapps-group-panel:focus {
outline: none;
}
/* Carrying an app past the card's edge takes it out of the folder: the card
shrinks back as if making way (see _updateEjectState). */
.myapps-group-ejecting .myapps-group-panel {
transform: scale(0.95);
opacity: 0.75;
}
input.myapps-group-name {
/* style.css's input[type=text] rules match this box at the SAME
specificity, so only what is spelled out here wins. width, padding and
border have to be stated (twice the focus rule below faces
input[type=text]:focus, which sets padding 7px / border 2px): left
unstated, the name field stretches across the whole card and its box
grows 8px taller the moment it is clicked, shoving the folder's grid
down. Same trap .myapps-search documents. */
-webkit-appearance: none;
appearance: none;
box-sizing: border-box;
width: auto;
max-width: 100%;
/* Hug the name (iOS sizes the field to its text): a short name in the
default ~20ch box floats in a hover pill far wider than the word, and
a long one clips while the card has room. Engines without field-sizing
keep that default box same behavior as before, just less tailored. */
field-sizing: content;
min-width: 90px;
padding: 4px 10px;
border: 1px solid transparent;
border-radius: 8px;
background: transparent;
font-size: 15px;
font-weight: 600;
text-align: center;
color: var(--dashboard-text-heading);
outline: none;
transition: background 0.15s ease, border-color 0.15s ease;
}
@media (hover: hover) {
input.myapps-group-name:hover {
background: var(--dashboard-folder-surface);
}
}
input.myapps-group-name:focus {
padding: 4px 10px;
border-width: 1px;
border-color: var(--select-color);
background: var(--dashboard-card-background);
}
.myapps-group-panel-grid {
/* --myapps-group-cols-max is the widest the card may get; the actual count
is set inline by _refreshGroupPanel so a folder of three doesn't sit in
the corner of a four-wide card. */
--myapps-group-cols-max: 4;
display: grid;
grid-template-columns: repeat(var(--myapps-group-cols, 4), var(--myapps-tile-w));
column-gap: var(--myapps-gap-x);
row-gap: 24px;
justify-content: center;
align-content: start;
/* Reorder mode's uninstall badge overhangs the top corner of every tile,
and the scroller below clips whatever leaves its padding box the top
row's badges came out sliced flat. Hold the overhang inside the padding,
pulled straight back out again so the resting layout is unchanged. */
padding-top: 9px;
margin-top: -9px;
/* A folder big enough to need it scrolls rather than growing past the
viewport; the row gap keeps the last row from looking cut off. */
max-height: min(52vh, 430px);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
}
/* Uninstall confirmation modal */
.myapps-modal-overlay {
position: fixed;
@@ -1591,6 +1846,17 @@ body.myapps-reordering .myapps-tile {
transition: none;
}
/* Folders still open and the well still fills they just do it without
the travel: the states are information, the motion is decoration. */
.myapps-group-backdrop,
.myapps-group-panel,
.myapps-group-open .myapps-group-panel,
body.myapps-reordering .myapps-tile-merge-pending::before,
body.myapps-reordering .myapps-tile-merge-armed::before,
body.myapps-reordering .myapps-tile.myapps-tile-merge-armed .myapps-tile-icon {
transition: none;
}
.dashboard-sidebar,
.dashboard-sidebar-scrim,
.dashboard-sidebar-toggle {
@@ -2973,13 +3239,11 @@ body.myapps-reordering .myapps-tile {
.dashboard-tab-content.myapps-tab {
padding-bottom: max(6px, env(safe-area-inset-bottom));
}
.myapps-container {
--myapps-tile-w: 72px;
--myapps-tile-h: 74px;
--myapps-gap-x: 12px;
--myapps-gap-y: 30px;
--myapps-icon-size: 52px;
}
/* Spread the fixed column count across the page width, iOS-style;
@@ -2998,6 +3262,34 @@ body.myapps-reordering .myapps-tile {
height: 52px;
border-radius: 12px;
}
.myapps-group-icon {
padding: 4px;
border-radius: 12px;
}
.myapps-group-overlay {
padding: 16px;
}
.myapps-group-panel {
gap: 10px;
padding: 16px 18px 20px;
border-radius: 24px;
}
.myapps-group-panel-grid {
row-gap: 18px;
max-height: 46vh;
}
}
/* Three columns rather than four once four (4x72 + 3x12 + the card's padding)
no longer fits a phone's width. */
@media (max-width: 400px) {
.myapps-group-panel-grid {
--myapps-group-cols-max: 3;
}
}
/* Desktop: Make metadata wrapper transparent */
+7
View File
@@ -31,6 +31,13 @@ const en = {
ai_app_unavailable: 'AI app is not available. Please try again later.',
all_fields_required: 'All fields are required.',
allow: 'Allow',
app_group_default_name: 'Folder',
app_group_name_aria: 'Folder name',
app_group_open: 'Open',
app_group_remove_from_folder: 'Remove from Folder',
app_group_rename: 'Rename',
app_group_tile_aria: '%%, folder of %% apps',
app_group_ungroup: 'Ungroup',
apply: 'Apply',
ascending: 'Ascending',
associated_websites: 'Associated Websites',