Преглед изворни кода

Home "Add new..." block: a card on the site again, solid on phones in NEO (#5915)

* Home "Add new..." block: a card on the site again, solid on phones in NEO

#5835 gave the block the content-only design box, so on the site it lost
its card and title at every width. It is back on design box 11 ("Add
new..." card). NEO keeps one card with no title: its App config turns
the title off and makes the background solid on phones, where the
translucent block fill was nearly the page colour next to the feed's
solid cards.

The design box config gets a `solid` key: tiers with the opaque card
fill (theme u-block-bg-solid) instead of the translucent u-block-bg one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: blockBgClasses without a nested ternary (Sonar S3358)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: tests for the block background classes

The per-tier builder moves to lib/block-bg-classes.ts (no app imports) so
node --test can run it: solid to translucent, a background that turns off
on desktop, and the same classes as before without solid.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Neo: the block background test is TypeScript and type-checked

The native typecheck allows .ts import paths (it never emits), as
apps/next's tsconfig already does, so Node tests can import their module
directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Andrey Yasko пре 1 дан
родитељ
комит
ae9bd4bb78

+ 1 - 1
install/sql/system.sql

@@ -6375,7 +6375,7 @@ INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `
 ('sys_home', 1, 'system', '', '_sys_page_block_title_homepage_menu', 13, 0, 0, 2147483647, 'menu', 'sys_homepage', 0, 1, 1, 0, 0),
 
 ('sys_home', 2, 'system', '', '_sys_page_block_title_profile_stats', 3, 0, 0, 2147483644, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:13:"profile_stats";s:6:"params";a:0:{}s:5:"class";s:20:"TemplServiceProfiles";}', 0, 0, 1, 0, 1),
-('sys_home', 3, 'system', '_sys_page_block_title_sys_create_post', '_sys_page_block_title_create_post', 0, 1, 4, 2147483644, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:1:{i:0;i:0;}s:5:"class";s:13:"TemplServices";}', 0, 0, 1, 0, 1);
+('sys_home', 3, 'system', '_sys_page_block_title_sys_create_post', '_sys_page_block_title_create_post', 11, 1, 4, 2147483644, 'service', 'a:4:{s:6:"module";s:6:"system";s:6:"method";s:20:"get_create_post_form";s:6:"params";a:1:{i:0;i:0;}s:5:"class";s:13:"TemplServices";}', 0, 0, 1, 0, 1);
 
 -- content blocks for APP (activated via separate enable-app-pages.sql)
 INSERT INTO `sys_pages_blocks` (`object`, `cell_id`, `module`, `title_system`, `title`, `description`, `icon`, `designbox_id`, `class`, `submenu`, `tabs`, `async`, `visible_for_levels`, `hidden_on`, `type`, `content`, `content_empty`, `text`, `text_updated`, `help`, `cache_lifetime`, `config_api`, `deletable`, `copyable`, `active`, `active_api`, `order`) VALUES

+ 1 - 1
modules/system/install/sql/enable-app-pages.sql

@@ -13,7 +13,7 @@ UPDATE `sys_pages_blocks` SET `config_api`='{\r\n\"content_type\": \"browse_simp
 UPDATE `sys_pages_blocks` SET `config_api`='{\r\n\"content_type\": \"browse_simple\", \r\n\"header_more_url\": \"/follow-suggestions\", \r\n\"header_more_text\": \"View All\"\r\n}' WHERE `object`='sys_home' AND `module`='system' AND `title_system`='_sys_page_block_title_sys_recom_subscriptions';
 UPDATE `sys_pages_blocks` SET `config_api`='{\"rounded\": true}' WHERE `object`='sys_home' AND `module`='system' AND `title_system`='_sys_page_block_system_title_login';
 UPDATE `sys_pages_blocks` SET `config_api`='{\r\n\"content_type\":\"browse_simple\", \r\n\"header_more_url\": \"/context-invitations\", \r\n\"header_more_text\": \"View All\"\r\n}' WHERE `object`='sys_home' AND `module`='system' AND `title_system`='_sys_page_block_title_sys_invitations';
-UPDATE `sys_pages_blocks` SET `config_api`='{"designbox": {"bg": true, "padding": true}}' WHERE `object`='sys_home' AND `module`='system' AND `title_system`='_sys_page_block_title_sys_create_post';
+UPDATE `sys_pages_blocks` SET `config_api`='{"designbox": {"bg": true, "padding": true, "title": false, "solid": ["mobile"]}}' WHERE `object`='sys_home' AND `module`='system' AND `title_system`='_sys_page_block_title_sys_create_post';
 
 -- 'active_api' switch
 UPDATE `sys_pages_blocks` SET `active_api`=1 WHERE `object`='sys_home' AND `module`='system' AND `title`='_sys_page_block_title_create_account';

+ 1 - 0
neo/AGENTS.md

@@ -190,6 +190,7 @@ A block's chrome (background, padding, title) comes from UNA's `designbox_id`, w
 ```
 
 - Each key lists the tiers where that part shows: `mobile` (no prefix), `tablet` (`sm:`), `desktop` (`lg:`). `true` and `false` mean every tier or none.
+- `solid` lists the tiers where the background is the opaque card fill (theme `u-block-bg-solid`) instead of the translucent `u-block-bg` one. Use it for a block among solid cards, such as the home "Add new..." block above the feed on phones (`"solid": ["mobile"]`). The classes come from `lib/block-bg-classes.ts`; run its tests with `node --test packages/app/lib/block-bg-classes.test.ts`.
 - A missing key keeps `designbox_id`. Props passed from code (`showBg`, `showPadding`, `showTitle`) win over both.
 - Keys sit under `designbox` because every `config_api` key is also spread into each element of the block as a prop.
 - `components/block-wrapper.js` reads them and `ui/molecules/page/page-block.js` turns tiers into classes through `responsiveClasses`. Prefixed classes must stay listed in `@source inline(...)` in `design/styles/utilities.css`; update that list if `theme.blocks` `u-block-bg` or the block padding changes.

+ 4 - 1
neo/packages/app/components/block-wrapper.js

@@ -90,13 +90,15 @@ export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg
 
     // The block's App config (Studio: "Config (for App in JSON format)") can set
     // the design box per breakpoint, e.g. {"designbox": {"bg": ["tablet", "desktop"]}}.
-    // Each key (bg, padding, title, rounded) lists the tiers where that part shows;
+    // Each key (bg, padding, title, rounded) lists the tiers where that part shows,
+    // and solid the tiers where the background is the opaque card fill;
     // a missing key keeps designbox_id. Props from code still win below.
     const designbox = config?.designbox || {};
     if (designbox.bg != null) bIsShowBg = normalizeTiers(designbox.bg);
     if (designbox.padding != null) bIsShowPadding = normalizeTiers(designbox.padding);
     if (designbox.title != null) bIsShowTitle = normalizeTiers(designbox.title);
     const rounded = designbox.rounded ?? config?.rounded;
+    const solid = designbox.solid;
 
     if (typeof showBg !== 'undefined') {
         bIsShowBg = showBg;
@@ -189,6 +191,7 @@ export function BlockWrapper({ config, block, wrapperClassses, showTitle, showBg
                     isBg={bIsShowBg}
                     isPad={bIsShowPadding}
                     rounded={rounded}
+                    solid={solid}
                     className={[
                         "w-full mx-auto",
                         fill ? "flex h-full min-h-0 flex-1 flex-col" : "",

+ 2 - 2
neo/packages/app/design/styles/utilities.css

@@ -20,8 +20,8 @@
 @source inline("{sm:,md:,lg:,}rounded-{2xl,xl,none}");
 @source inline("{sm:,md:,lg:,}{p,gap}-{0..4}");
 /* Block design box per breakpoint (responsive-classes `bg`, `block-pad-*`, `title`): keep in
-   sync with theme.blocks u-block-bg / u-block-pad-x / u-block-pad-y (settings/theme/elements.js). */
-@source inline("{sm:,lg:,}{bg-card/60,bg-transparent,shadow-block-outline,shadow-none}");
+   sync with theme.blocks u-block-bg / u-block-bg-solid / u-block-pad-x / u-block-pad-y (settings/theme/elements.js). */
+@source inline("{sm:,lg:,}{bg-card,bg-card/60,bg-transparent,shadow-block-outline,shadow-none}");
 @source inline("{sm:,lg:,}dark:{shadow-block-outline-deep,shadow-none}");
 @source inline("{sm:,md:,lg:,}{px,py,my}-{0..4}");
 /* Panel handle offsets: half the page gap (gap-N → -mx-N/2), see page-layout/universal.js. */

+ 52 - 0
neo/packages/app/lib/block-bg-classes.test.ts

@@ -0,0 +1,52 @@
+/// <reference types="node" />
+// Run with `node --test packages/app/lib/block-bg-classes.test.ts` from the NEO root (Node 24 strips the types).
+import assert from 'node:assert/strict';
+import test from 'node:test';
+import { buildBlockBgClasses } from './block-bg-classes.ts';
+
+// The default theme (settings/theme/elements.js u-block-bg / u-block-bg-solid, responsive-classes `bg` off).
+const theme = {
+    on: ' bg-card/60 shadow-block-outline dark:shadow-block-outline-deep ',
+    solidFill: 'bg-card',
+    off: 'bg-transparent shadow-none dark:shadow-none',
+};
+
+test('without solid, a background on every tier is the theme fill', () => {
+    assert.equal(buildBlockBgClasses(true, false, theme), 'bg-card/60 shadow-block-outline dark:shadow-block-outline-deep');
+});
+
+test('without solid, a background from tablet up is what the bg preset gave', () => {
+    assert.equal(
+        buildBlockBgClasses(['tablet', 'desktop'], false, theme),
+        'bg-transparent shadow-none dark:shadow-none sm:bg-card/60 sm:shadow-block-outline sm:dark:shadow-block-outline-deep',
+    );
+});
+
+test('solid on phones turns translucent from tablet up', () => {
+    assert.equal(
+        buildBlockBgClasses(true, ['mobile'], theme),
+        'bg-card shadow-block-outline dark:shadow-block-outline-deep sm:bg-card/60 sm:shadow-block-outline sm:dark:shadow-block-outline-deep',
+    );
+});
+
+test('solid everywhere swaps only the fill', () => {
+    assert.equal(buildBlockBgClasses(true, true, theme), 'bg-card shadow-block-outline dark:shadow-block-outline-deep');
+});
+
+test('a background that turns off on desktop clears the fill and outline there', () => {
+    assert.equal(
+        buildBlockBgClasses(['mobile', 'tablet'], ['mobile'], theme),
+        'bg-card shadow-block-outline dark:shadow-block-outline-deep sm:bg-card/60 sm:shadow-block-outline sm:dark:shadow-block-outline-deep lg:bg-transparent lg:shadow-none lg:dark:shadow-none',
+    );
+});
+
+test('solid on a tier without background has no effect', () => {
+    assert.equal(
+        buildBlockBgClasses(['tablet', 'desktop'], ['mobile'], theme),
+        buildBlockBgClasses(['tablet', 'desktop'], false, theme),
+    );
+});
+
+test('no background, no classes', () => {
+    assert.equal(buildBlockBgClasses(false, ['mobile'], theme), '');
+});

+ 46 - 0
neo/packages/app/lib/block-bg-classes.ts

@@ -0,0 +1,46 @@
+/**
+ * Per-tier classes for a block's background (block config `designbox.bg` and
+ * `designbox.solid`, see block-wrapper.js). No app imports, so
+ * `node --test packages/app/lib/block-bg-classes.test.ts` runs it from the NEO root.
+ */
+
+export type BgTier = 'mobile' | 'tablet' | 'desktop';
+
+const TIERS: readonly BgTier[] = ['mobile', 'tablet', 'desktop'];
+const PREFIX: Record<BgTier, string> = { mobile: '', tablet: 'sm:', desktop: 'lg:' };
+
+const tokens = (value: string) => String(value || '').trim().split(/\s+/).filter(Boolean);
+const inTiers = (tiers: boolean | BgTier[], tier: BgTier) => tiers === true || (Array.isArray(tiers) && tiers.includes(tier));
+
+/**
+ * `bg`: tiers with the background; `solid`: tiers where it uses `solidFill` (the opaque
+ * card colour) in place of `on`'s first bg- utility. `off` is used where `bg` is off.
+ * A tier adds classes only where its value differs from the tier below.
+ */
+export function buildBlockBgClasses(
+    bg: boolean | BgTier[],
+    solid: boolean | BgTier[],
+    { on, solidFill, off }: { on: string; solidFill: string; off: string },
+): string {
+    if (!bg) return '';
+
+    let swapped = false;
+    const onSolid = tokens(on).map((token) => {
+        if (swapped || !token.startsWith('bg-')) return token;
+        swapped = true;
+        return solidFill;
+    }).join(' ');
+
+    const classes: string[] = [];
+    let previous: string | null = null;
+    for (const tier of TIERS) {
+        let value = off;
+        if (inTiers(bg, tier)) value = inTiers(solid, tier) ? onSolid : tokens(on).join(' ');
+        if (value !== previous) {
+            classes.push(...tokens(value).map((token) => PREFIX[tier] + token));
+            previous = value;
+        }
+    }
+
+    return classes.join(' ');
+}

+ 14 - 0
neo/packages/app/lib/responsive-classes.ts

@@ -1,4 +1,5 @@
 import { appSetting } from 'app/lib/util';
+import { buildBlockBgClasses } from 'app/lib/block-bg-classes';
 
 /**
  * Build responsive class strings from theme utilities + breakpoint tiers.
@@ -241,6 +242,19 @@ export function normalizeTiers(value: any): boolean | Tier[] {
     return tiers.length ? tiers : false;
 }
 
+/**
+ * A block's background (theme `u-block-bg`) on the tiers in `bg`, with the opaque card fill
+ * (`u-block-bg-solid`) instead of the translucent one on the tiers in `solid`: block config
+ * `designbox.solid`, for a block that sits among solid cards, like the feed's on phones.
+ */
+export function blockBgClasses(bg: any, solid?: any) {
+    return buildBlockBgClasses(normalizeTiers(bg), normalizeTiers(solid), {
+        on: appSetting('theme', 'blocks', 'u-block-bg') || '',
+        solidFill: appSetting('theme', 'blocks', 'u-block-bg-solid') || 'bg-card',
+        off: PRESETS.bg?.off || '',
+    });
+}
+
 /**
  * Register a responsive preset. Add matching literals to `@source inline(...)` in utilities.css.
  */

+ 2 - 0
neo/packages/app/settings/theme/elements.js

@@ -125,6 +125,8 @@ export const settingsElements = {
             ' text-card-foreground gap-4 ',
         'u-block-bg':
             ' bg-card/60 shadow-block-outline dark:shadow-block-outline-deep ',
+        // The fill on tiers in a block's `designbox.solid` (replaces u-block-bg's bg- utility).
+        'u-block-bg-solid': 'bg-card',
         'u-block-pad':
             ' p-4 ',
         'u-block-pad-y':

+ 5 - 3
neo/packages/app/ui/molecules/page/page-block.js

@@ -1,7 +1,7 @@
 import { View } from 'app/design/view'
 import { Text } from 'app/design/typography'
 import { appSetting } from 'app/lib/util'
-import { normalizeTiers, responsiveClasses } from 'app/lib/responsive-classes'
+import { blockBgClasses, normalizeTiers, responsiveClasses } from 'app/lib/responsive-classes'
 
 const blockTheme = appSetting('theme', 'blocks')
 
@@ -17,6 +17,7 @@ function createBlockComponent({
         isBg,
         isPad,
         rounded,
+        solid,
         ...props
     }) {
         // isBg / isPad: on or off (UNA designbox), or the tiers ['mobile', 'tablet',
@@ -24,8 +25,9 @@ function createBlockComponent({
         const bg = baseClass === 'u-block-base' ? normalizeTiers(isBg) : false
         const pad = normalizeTiers(isPad)
 
-        // Card chrome (fill, outline shadow, radius) only where UNA background is on.
-        const bgClass = bg === true ? blockTheme['u-block-bg'] : bg ? responsiveClasses('bg', bg) : ''
+        // Card chrome (fill, outline shadow, radius) only where UNA background is on;
+        // `solid`: tiers with the opaque card fill (block config `designbox.solid`).
+        const bgClass = bg ? blockBgClasses(bg, solid) : ''
         const roundedClass = bg ? responsiveClasses('rounded', rounded) : ''
 
         // UNA padding: py on the shell, px on header / content / footer.

+ 2 - 1
neo/scripts/typecheck.js

@@ -37,7 +37,8 @@ const PLATFORMS = {
         extraSuffixes: ['.ios', '.android'],
         dirs: ['packages/app', 'apps/expo/app'],
         ambient: ['apps/expo'],
-        compilerOptions: { strict: true },
+        // Node tests import their module as `./name.ts` (Node runs them without a build), as apps/next allows.
+        compilerOptions: { strict: true, allowImportingTsExtensions: true },
     },
 };
 

+ 6 - 6
tests/units/install/SystemSqlDefaultsTest.php

@@ -15,25 +15,25 @@ class SystemSqlDefaultsTest extends \PHPUnit\Framework\TestCase
         return $sSql;
     }
 
-    public function testHomeCreatePostBlockIsContentOnly(): void
+    public function testHomeCreatePostBlockIsACardWithTitle(): void
     {
         // The home page's create-post block: designbox_id follows object, cell, module, title_system and title.
         $bFound = preg_match("/\\('sys_home', \\d+, 'system', '_sys_page_block_title_sys_create_post', '_sys_page_block_title_create_post', (\\d+),/", self::systemSql(), $aMatch);
 
         $this->assertSame(1, $bFound, 'system.sql adds the create-post block to the home page.');
-        $this->assertSame(0, (int)$aMatch[1], 'It shows only its form: the content-only design box (BX_DB_CONTENT_ONLY).');
+        $this->assertSame(11, (int)$aMatch[1], 'On the site it is the "Add new..." card: content, title, background and padding (BX_DB_PADDING_DEF).');
     }
 
-    public function testHomeCreatePostBlockHasBackgroundInApp(): void
+    public function testHomeCreatePostBlockInAppHasNoTitleAndASolidPhoneBackground(): void
     {
-        // NEO draws the composer row straight in the block, so the block's App config gives it a background
-        // and padding there. The title is left to the design box (none with the content-only one).
+        // NEO draws the composer row straight in the block. Its App config keeps the background and padding,
+        // drops the design box's title, and makes the background opaque on phones, where the feed's cards are.
         $sSql = file_get_contents(__DIR__ . '/../../../modules/system/install/sql/enable-app-pages.sql');
         $bFound = preg_match("/UPDATE `sys_pages_blocks` SET `config_api`='([^']*)' WHERE `object`='sys_home' AND `module`='system' AND `title_system`='_sys_page_block_title_sys_create_post';/", (string)$sSql, $aMatch);
 
         $this->assertSame(1, $bFound, 'enable-app-pages.sql sets the App config of the home create-post block.');
         $aConfig = json_decode($aMatch[1], true);
-        $this->assertSame(['bg' => true, 'padding' => true], $aConfig['designbox'] ?? null, 'Background and padding on every tier, title from the design box.');
+        $this->assertSame(['bg' => true, 'padding' => true, 'title' => false, 'solid' => ['mobile']], $aConfig['designbox'] ?? null);
     }
 
     public function testPoweredByBadgeFollowsTheFooterColour(): void